From 7978e34cb887ed8b30f4b8f5c54807a67ef37d63 Mon Sep 17 00:00:00 2001 From: "Ji Yong, Kim" Date: Thu, 13 Feb 2025 20:38:45 +0000 Subject: [PATCH] deploy: 1.12.16 --- composer.json | 18 +- src/AnalysedCodeException.php | 13 + src/Analyser/Analyser.php | 137 + src/Analyser/AnalyserResult.php | 162 + src/Analyser/AnalyserResultFinalizer.php | 223 + src/Analyser/ArgumentsNormalizer.php | 299 + src/Analyser/ConditionalExpressionHolder.php | 56 + src/Analyser/ConstantResolver.php | 440 ++ src/Analyser/ConstantResolverFactory.php | 32 + src/Analyser/DirectInternalScopeFactory.php | 97 + src/Analyser/EndStatementResult.php | 28 + src/Analyser/EnsuredNonNullabilityResult.php | 29 + .../EnsuredNonNullabilityResultExpression.php | 42 + src/Analyser/Error.php | 311 + src/Analyser/ExpressionContext.php | 64 + src/Analyser/ExpressionResult.php | 94 + src/Analyser/ExpressionTypeHolder.php | 66 + src/Analyser/FileAnalyser.php | 376 + src/Analyser/FileAnalyserResult.php | 112 + src/Analyser/FinalizerResult.php | 50 + src/Analyser/Ignore/IgnoreLexer.php | 98 + src/Analyser/Ignore/IgnoreParseException.php | 21 + src/Analyser/Ignore/IgnoredError.php | 101 + src/Analyser/Ignore/IgnoredErrorHelper.php | 135 + .../IgnoredErrorHelperProcessedResult.php | 48 + .../Ignore/IgnoredErrorHelperResult.php | 243 + src/Analyser/ImpurePoint.php | 61 + src/Analyser/InternalError.php | 105 + src/Analyser/InternalScopeFactory.php | 43 + src/Analyser/LazyInternalScopeFactory.php | 81 + src/Analyser/LocalIgnoresProcessor.php | 102 + src/Analyser/LocalIgnoresProcessorResult.php | 59 + src/Analyser/MutatingScope.php | 6079 +++++++++++++++ src/Analyser/NameScope.php | 226 + src/Analyser/NodeScopeResolver.php | 6595 +++++++++++++++++ src/Analyser/NullsafeOperatorHelper.php | 84 + src/Analyser/OutOfClassScope.php | 58 + src/Analyser/ProcessClosureResult.php | 54 + src/Analyser/ResultCache/ResultCache.php | 136 + .../ResultCache/ResultCacheClearer.php | 29 + .../ResultCache/ResultCacheManager.php | 1068 +++ .../ResultCache/ResultCacheManagerFactory.php | 11 + .../ResultCache/ResultCacheMetaExtension.php | 40 + .../ResultCache/ResultCacheProcessResult.php | 25 + src/Analyser/RicherScopeGetTypeHelper.php | 83 + src/Analyser/RuleErrorTransformer.php | 89 + src/Analyser/Scope.php | 143 + src/Analyser/ScopeContext.php | 94 + src/Analyser/ScopeFactory.php | 21 + src/Analyser/SpecifiedTypes.php | 229 + src/Analyser/StatementContext.php | 53 + src/Analyser/StatementExitPoint.php | 28 + src/Analyser/StatementResult.php | 194 + src/Analyser/ThrowPoint.php | 80 + src/Analyser/TypeSpecifier.php | 2451 ++++++ src/Analyser/TypeSpecifierAwareExtension.php | 12 + src/Analyser/TypeSpecifierContext.php | 93 + src/Analyser/TypeSpecifierFactory.php | 53 + src/Analyser/UndefinedVariableException.php | 32 + src/Broker/AnonymousClassNameHelper.php | 51 + src/Broker/BrokerFactory.php | 18 + src/Broker/ClassAutoloadingException.php | 48 + src/Broker/ClassNotFoundException.php | 27 + src/Broker/ConstantNotFoundException.php | 27 + src/Broker/FunctionNotFoundException.php | 27 + src/Cache/Cache.php | 29 + src/Cache/CacheItem.php | 37 + src/Cache/CacheStorage.php | 19 + src/Cache/FileCacheStorage.php | 207 + src/Cache/MemoryCacheStorage.php | 41 + src/Classes/ForbiddenClassNameExtension.php | 33 + src/Collectors/CollectedData.php | 88 + src/Collectors/Collector.php | 41 + src/Collectors/Registry.php | 57 + src/Collectors/RegistryFactory.php | 24 + src/Command/AnalyseApplication.php | 225 + src/Command/AnalyseCommand.php | 720 ++ src/Command/AnalyserRunner.php | 89 + src/Command/AnalysisResult.php | 152 + src/Command/ClearResultCacheCommand.php | 102 + src/Command/CommandHelper.php | 599 ++ src/Command/DiagnoseCommand.php | 107 + src/Command/DumpParametersCommand.php | 113 + .../BaselineNeonErrorFormatter.php | 128 + .../BaselinePhpErrorFormatter.php | 111 + .../CheckstyleErrorFormatter.php | 122 + .../CiDetectedErrorFormatter.php | 46 + src/Command/ErrorFormatter/ErrorFormatter.php | 36 + .../ErrorFormatter/GithubErrorFormatter.php | 75 + .../ErrorFormatter/GitlabErrorFormatter.php | 73 + .../ErrorFormatter/JsonErrorFormatter.php | 71 + .../ErrorFormatter/JunitErrorFormatter.php | 91 + .../ErrorFormatter/RawErrorFormatter.php | 50 + .../ErrorFormatter/TableErrorFormatter.php | 167 + .../ErrorFormatter/TeamcityErrorFormatter.php | 117 + src/Command/ErrorsConsoleStyle.php | 209 + src/Command/FixerApplication.php | 589 ++ src/Command/FixerProcessException.php | 11 + src/Command/FixerWorkerCommand.php | 415 ++ src/Command/IgnoredRegexValidator.php | 162 + src/Command/IgnoredRegexValidatorResult.php | 50 + .../InceptionNotSuccessfulException.php | 11 + src/Command/InceptionResult.php | 128 + src/Command/Output.php | 26 + src/Command/OutputStyle.php | 43 + src/Command/Symfony/SymfonyOutput.php | 63 + src/Command/Symfony/SymfonyStyle.php | 89 + src/Command/WorkerCommand.php | 266 + src/Dependency/DependencyResolver.php | 681 ++ src/Dependency/ExportedNode.php | 21 + .../ExportedNode/ExportedAttributeNode.php | 84 + .../ExportedClassConstantNode.php | 92 + .../ExportedClassConstantsNode.php | 107 + .../ExportedNode/ExportedClassNode.php | 186 + .../ExportedNode/ExportedEnumCaseNode.php | 79 + .../ExportedNode/ExportedEnumNode.php | 149 + .../ExportedNode/ExportedFunctionNode.php | 148 + .../ExportedNode/ExportedInterfaceNode.php | 118 + .../ExportedNode/ExportedMethodNode.php | 159 + .../ExportedNode/ExportedParameterNode.php | 107 + .../ExportedNode/ExportedPhpDocNode.php | 66 + .../ExportedNode/ExportedPropertiesNode.php | 138 + .../ExportedNode/ExportedTraitNode.php | 66 + .../ExportedTraitUseAdaptation.php | 107 + src/Dependency/ExportedNodeFetcher.php | 39 + src/Dependency/ExportedNodeResolver.php | 386 + src/Dependency/ExportedNodeVisitor.php | 62 + src/Dependency/NodeDependencies.php | 59 + src/Dependency/RootExportedNode.php | 24 + .../BleedingEdgeToggle.php | 21 + .../ConditionalTagsExtension.php | 93 + src/DependencyInjection/Configurator.php | 220 + src/DependencyInjection/Container.php | 48 + src/DependencyInjection/ContainerFactory.php | 395 + .../DerivativeContainerFactory.php | 53 + .../DuplicateIncludedFilesException.php | 29 + .../InvalidExcludePathsException.php | 28 + .../InvalidIgnoredErrorPatternsException.php | 28 + .../InvalidPhpVersionException.php | 11 + src/DependencyInjection/LoaderFactory.php | 36 + .../MemoizingContainer.php | 65 + src/DependencyInjection/Neon/OptionalPath.php | 13 + src/DependencyInjection/NeonAdapter.php | 221 + src/DependencyInjection/NeonLoader.php | 36 + .../Nette/NetteContainer.php | 96 + .../ParameterNotFoundException.php | 17 + .../ParametersSchemaExtension.php | 19 + .../ProjectConfigHelper.php | 67 + ...assReflectionExtensionRegistryProvider.php | 13 + ...assReflectionExtensionRegistryProvider.php | 49 + src/DependencyInjection/RulesExtension.php | 33 + ...micReturnTypeExtensionRegistryProvider.php | 13 + .../DynamicThrowTypeExtensionProvider.php | 22 + ...nTypeResolverExtensionRegistryProvider.php | 13 + ...micReturnTypeExtensionRegistryProvider.php | 34 + .../LazyDynamicThrowTypeExtensionProvider.php | 34 + ...nTypeResolverExtensionRegistryProvider.php | 30 + ...ypeSpecifyingExtensionRegistryProvider.php | 30 + ...yParameterClosureTypeExtensionProvider.php | 34 + .../LazyParameterOutTypeExtensionProvider.php | 34 + ...ypeSpecifyingExtensionRegistryProvider.php | 13 + .../ParameterClosureTypeExtensionProvider.php | 22 + .../ParameterOutTypeExtensionProvider.php | 22 + .../ValidateExcludePathsExtension.php | 84 + .../ValidateIgnoredErrorsExtension.php | 227 + src/Diagnose/DiagnoseExtension.php | 32 + src/Diagnose/PHPStanDiagnoseExtension.php | 209 + src/File/CouldNotReadFileException.php | 22 + src/File/CouldNotWriteFileException.php | 22 + src/File/FileExcluder.php | 137 + src/File/FileExcluderFactory.php | 47 + src/File/FileExcluderRawFactory.php | 16 + src/File/FileFinder.php | 55 + src/File/FileFinderResult.php | 29 + src/File/FileHelper.php | 103 + src/File/FileMonitor.php | 94 + src/File/FileMonitorResult.php | 45 + src/File/FileReader.php | 37 + src/File/FileWriter.php | 25 + src/File/FuzzyRelativePathHelper.php | 119 + src/File/NullRelativePathHelper.php | 14 + .../ParentDirectoryRelativePathHelper.php | 71 + src/File/PathNotFoundException.php | 22 + src/File/RelativePathHelper.php | 12 + src/File/SimpleRelativePathHelper.php | 27 + ...SystemAgnosticSimpleRelativePathHelper.php | 27 + src/Internal/BytesHelper.php | 32 + src/Internal/CombinationsHelper.php | 36 + src/Internal/ComposerHelper.php | 92 + src/Internal/DeprecatedAttributeHelper.php | 46 + src/Internal/DirectoryCreator.php | 37 + src/Internal/DirectoryCreatorException.php | 25 + src/Internal/SprintfHelper.php | 16 + src/Node/AnonymousClassNode.php | 33 + src/Node/BooleanAndNode.php | 48 + src/Node/BooleanOrNode.php | 48 + src/Node/BreaklessWhileLoopNode.php | 50 + src/Node/CatchWithUnthrownExceptionNode.php | 49 + src/Node/ClassConstantsNode.php | 66 + src/Node/ClassMethod.php | 29 + src/Node/ClassMethodsNode.php | 65 + src/Node/ClassPropertiesNode.php | 416 ++ src/Node/ClassPropertyNode.php | 169 + src/Node/ClassStatementsGatherer.php | 294 + src/Node/ClosureReturnStatementsNode.php | 100 + src/Node/CollectedDataNode.php | 74 + src/Node/Constant/ClassConstantFetch.php | 29 + src/Node/DoWhileLoopConditionNode.php | 47 + src/Node/ExecutionEndNode.php | 53 + src/Node/Expr/AlwaysRememberedExpr.php | 46 + src/Node/Expr/ExistingArrayDimFetch.php | 40 + src/Node/Expr/GetIterableKeyTypeExpr.php | 35 + src/Node/Expr/GetIterableValueTypeExpr.php | 35 + src/Node/Expr/GetOffsetValueTypeExpr.php | 40 + src/Node/Expr/OriginalPropertyTypeExpr.php | 35 + .../ParameterVariableOriginalValueExpr.php | 35 + src/Node/Expr/PropertyInitializationExpr.php | 35 + .../Expr/SetExistingOffsetValueTypeExpr.php | 45 + src/Node/Expr/SetOffsetValueTypeExpr.php | 45 + src/Node/Expr/TypeExpr.php | 40 + src/Node/Expr/UnsetOffsetExpr.php | 40 + src/Node/FileNode.php | 45 + src/Node/FinallyExitPointsNode.php | 53 + src/Node/FunctionCallableNode.php | 46 + src/Node/FunctionReturnStatementsNode.php | 107 + src/Node/InArrowFunctionNode.php | 48 + src/Node/InClassMethodNode.php | 53 + src/Node/InClassNode.php | 44 + src/Node/InClosureNode.php | 48 + src/Node/InForeachNode.php | 35 + src/Node/InFunctionNode.php | 46 + src/Node/InPropertyHookNode.php | 61 + src/Node/InTraitNode.php | 48 + src/Node/InstantiationCallableNode.php | 46 + src/Node/InvalidateExprNode.php | 38 + src/Node/IssetExpr.php | 42 + src/Node/LiteralArrayItem.php | 29 + src/Node/LiteralArrayNode.php | 44 + src/Node/MatchExpressionArm.php | 37 + src/Node/MatchExpressionArmBody.php | 29 + src/Node/MatchExpressionArmCondition.php | 34 + src/Node/MatchExpressionNode.php | 60 + src/Node/Method/MethodCall.php | 37 + src/Node/MethodCallableNode.php | 55 + src/Node/MethodReturnStatementsNode.php | 127 + src/Node/NoopExpressionNode.php | 40 + src/Node/Printer/ExprPrinter.php | 30 + src/Node/Printer/NodeTypePrinter.php | 53 + src/Node/Printer/Printer.php | 94 + src/Node/Property/PropertyAssign.php | 32 + src/Node/Property/PropertyRead.php | 36 + src/Node/Property/PropertyWrite.php | 38 + src/Node/PropertyAssignNode.php | 49 + src/Node/PropertyHookReturnStatementsNode.php | 112 + src/Node/PropertyHookStatementNode.php | 52 + src/Node/ReturnStatement.php | 33 + src/Node/ReturnStatementsNode.php | 43 + src/Node/StaticMethodCallableNode.php | 59 + src/Node/UnreachableStatementNode.php | 46 + src/Node/VarTagChangedExpressionTypeNode.php | 41 + src/Node/VariableAssignNode.php | 49 + src/Node/VirtualNode.php | 12 + src/Parallel/ParallelAnalyser.php | 323 + src/Parallel/Process.php | 159 + src/Parallel/ProcessPool.php | 74 + src/Parallel/ProcessTimedOutException.php | 11 + src/Parallel/Schedule.php | 29 + src/Parallel/Scheduler.php | 75 + src/Parser/AnonymousClassVisitor.php | 53 + src/Parser/ArrayFilterArgVisitor.php | 28 + src/Parser/ArrayFindArgVisitor.php | 29 + src/Parser/ArrayMapArgVisitor.php | 33 + src/Parser/ArrayWalkArgVisitor.php | 28 + src/Parser/ArrowFunctionArgVisitor.php | 42 + src/Parser/CachedParser.php | 98 + src/Parser/CleaningParser.php | 42 + src/Parser/CleaningVisitor.php | 105 + src/Parser/ClosureArgVisitor.php | 42 + src/Parser/ClosureBindArgVisitor.php | 34 + src/Parser/ClosureBindToVarVisitor.php | 31 + src/Parser/CurlSetOptArgVisitor.php | 28 + src/Parser/DeclarePositionVisitor.php | 45 + .../ImmediatelyInvokedClosureVisitor.php | 23 + src/Parser/LastConditionVisitor.php | 94 + src/Parser/LexerFactory.php | 31 + src/Parser/LineAttributesVisitor.php | 29 + .../MagicConstantParamDefaultVisitor.php | 22 + src/Parser/NewAssignedToPropertyVisitor.php | 27 + src/Parser/ParentStmtTypesVisitor.php | 51 + src/Parser/Parser.php | 25 + src/Parser/ParserErrorsException.php | 55 + src/Parser/PathRoutingParser.php | 81 + src/Parser/PhpParserDecorator.php | 41 + src/Parser/PhpParserFactory.php | 29 + .../RemoveUnusedCodeByPhpVersionIdVisitor.php | 99 + src/Parser/RichParser.php | 347 + src/Parser/SimpleParser.php | 61 + src/Parser/StandaloneThrowExprVisitor.php | 29 + src/Parser/StubParser.php | 57 + src/Parser/TraitCollectingVisitor.php | 26 + src/Parser/TryCatchTypeVisitor.php | 75 + src/Parser/TypeTraverserInstanceofVisitor.php | 57 + src/Parser/VariadicFunctionsVisitor.php | 95 + src/Parser/VariadicMethodsVisitor.php | 136 + src/Php/ComposerPhpVersionFactory.php | 122 + src/Php/PhpVersion.php | 399 + src/Php/PhpVersionFactory.php | 45 + src/Php/PhpVersionFactoryFactory.php | 63 + src/Php/PhpVersions.php | 47 + src/PhpDoc/ConstExprNodeResolver.php | 140 + src/PhpDoc/DefaultStubFilesProvider.php | 75 + ...eNodeResolverExtensionRegistryProvider.php | 18 + src/PhpDoc/JsonValidateStubFilesExtension.php | 24 + ...eNodeResolverExtensionRegistryProvider.php | 29 + src/PhpDoc/PhpDocBlock.php | 409 + src/PhpDoc/PhpDocInheritanceResolver.php | 157 + src/PhpDoc/PhpDocNodeResolver.php | 754 ++ src/PhpDoc/PhpDocStringResolver.php | 27 + .../ReflectionClassStubFilesExtension.php | 28 + .../ReflectionEnumStubFilesExtension.php | 28 + src/PhpDoc/ResolvedPhpDocBlock.php | 1246 ++++ src/PhpDoc/SocketSelectStubFilesExtension.php | 24 + src/PhpDoc/StubFilesExtension.php | 30 + src/PhpDoc/StubFilesProvider.php | 15 + src/PhpDoc/StubPhpDocProvider.php | 379 + src/PhpDoc/StubSourceLocatorFactory.php | 55 + src/PhpDoc/StubValidator.php | 293 + src/PhpDoc/Tag/AssertTag.php | 97 + src/PhpDoc/Tag/AssertTagParameter.php | 60 + src/PhpDoc/Tag/DeprecatedTag.php | 21 + src/PhpDoc/Tag/ExtendsTag.php | 23 + src/PhpDoc/Tag/ImplementsTag.php | 23 + src/PhpDoc/Tag/MethodTag.php | 53 + src/PhpDoc/Tag/MethodTagParameter.php | 50 + src/PhpDoc/Tag/MixinTag.php | 23 + src/PhpDoc/Tag/ParamClosureThisTag.php | 30 + src/PhpDoc/Tag/ParamOutTag.php | 28 + src/PhpDoc/Tag/ParamTag.php | 36 + src/PhpDoc/Tag/PropertyTag.php | 47 + src/PhpDoc/Tag/RequireExtendsTag.php | 23 + src/PhpDoc/Tag/RequireImplementsTag.php | 23 + src/PhpDoc/Tag/ReturnTag.php | 38 + src/PhpDoc/Tag/SelfOutTypeTag.php | 28 + src/PhpDoc/Tag/TemplateTag.php | 45 + src/PhpDoc/Tag/ThrowsTag.php | 23 + src/PhpDoc/Tag/TypeAliasImportTag.php | 29 + src/PhpDoc/Tag/TypeAliasTag.php | 37 + src/PhpDoc/Tag/TypedTag.php | 19 + src/PhpDoc/Tag/UsesTag.php | 23 + src/PhpDoc/Tag/VarTag.php | 28 + src/PhpDoc/TypeNodeResolver.php | 1284 ++++ src/PhpDoc/TypeNodeResolverAwareExtension.php | 12 + src/PhpDoc/TypeNodeResolverExtension.php | 34 + ...TypeNodeResolverExtensionAwareRegistry.php | 34 + .../TypeNodeResolverExtensionRegistry.php | 14 + ...eNodeResolverExtensionRegistryProvider.php | 11 + src/PhpDoc/TypeStringResolver.php | 29 + src/Process/CpuCoreCounter.php | 29 + src/Process/ProcessCanceledException.php | 11 + src/Process/ProcessCrashedException.php | 11 + src/Process/ProcessHelper.php | 88 + src/Process/ProcessPromise.php | 99 + .../AdditionalConstructorsExtension.php | 30 + ...llowedSubTypesClassReflectionExtension.php | 37 + .../AnnotationMethodReflection.php | 185 + .../AnnotationPropertyReflection.php | 152 + .../AnnotationsMethodParameterReflection.php | 84 + ...tationsMethodsClassReflectionExtension.php | 138 + ...ionsPropertiesClassReflectionExtension.php | 105 + src/Reflection/Assertions.php | 112 + src/Reflection/AttributeReflection.php | 34 + src/Reflection/AttributeReflectionFactory.php | 135 + .../BetterReflectionProvider.php | 484 ++ .../BetterReflectionProviderFactory.php | 15 + .../BetterReflectionSourceLocatorFactory.php | 141 + .../Reflector/MemoizingReflector.php | 112 + .../AutoloadFunctionsSourceLocator.php | 59 + .../SourceLocator/AutoloadSourceLocator.php | 383 + .../SourceLocator/CachingVisitor.php | 157 + ...JsonAndInstalledJsonSourceLocatorMaker.php | 259 + .../SourceLocator/FetchedNode.php | 44 + .../SourceLocator/FetchedNodesResult.php | 48 + .../SourceLocator/FileNodesFetcher.php | 47 + .../FileReadTrapStreamWrapper.php | 274 + .../OptimizedDirectorySourceLocator.php | 218 + ...OptimizedDirectorySourceLocatorFactory.php | 217 + ...imizedDirectorySourceLocatorRepository.php | 29 + .../OptimizedPsrAutoloaderLocator.php | 65 + .../OptimizedPsrAutoloaderLocatorFactory.php | 13 + .../OptimizedSingleFileSourceLocator.php | 261 + ...ptimizedSingleFileSourceLocatorFactory.php | 11 + ...mizedSingleFileSourceLocatorRepository.php | 29 + .../SourceLocator/PhpFileCleaner.php | 296 + .../PhpVersionBlacklistSourceLocator.php | 45 + .../ReflectionClassSourceLocator.php | 54 + .../RewriteClassAliasSourceLocator.php | 47 + .../SkipClassAliasSourceLocator.php | 48 + .../PhpStormStubsSourceStubberFactory.php | 23 + .../ReflectionSourceStubberFactory.php | 22 + ...tionEnumCaseDynamicReturnTypeExtension.php | 66 + ...flectionEnumDynamicReturnTypeExtension.php | 103 + .../Callables/CallableParametersAcceptor.php | 40 + .../Callables/FunctionCallableVariant.php | 172 + .../Callables/SimpleImpurePoint.php | 73 + src/Reflection/Callables/SimpleThrowPoint.php | 46 + src/Reflection/ClassConstantReflection.php | 30 + src/Reflection/ClassMemberAccessAnswerer.php | 30 + src/Reflection/ClassMemberReflection.php | 20 + src/Reflection/ClassNameHelper.php | 18 + src/Reflection/ClassReflection.php | 1841 +++++ .../ClassReflectionExtensionRegistry.php | 61 + .../Constant/RuntimeConstantReflection.php | 53 + src/Reflection/ConstantNameHelper.php | 27 + src/Reflection/ConstantReflection.php | 25 + src/Reflection/ConstructorsHelper.php | 78 + .../Dummy/ChangedTypeMethodReflection.php | 164 + .../Dummy/ChangedTypePropertyReflection.php | 150 + .../Dummy/DummyClassConstantReflection.php | 115 + .../Dummy/DummyConstructorReflection.php | 156 + .../Dummy/DummyMethodReflection.php | 148 + .../Dummy/DummyPropertyReflection.php | 146 + src/Reflection/EnumCaseReflection.php | 68 + .../ExtendedCallableFunctionVariant.php | 84 + src/Reflection/ExtendedFunctionVariant.php | 62 + src/Reflection/ExtendedMethodReflection.php | 67 + .../ExtendedParameterReflection.php | 30 + src/Reflection/ExtendedParametersAcceptor.php | 24 + src/Reflection/ExtendedPropertyReflection.php | 63 + src/Reflection/FunctionReflection.php | 66 + src/Reflection/FunctionReflectionFactory.php | 41 + src/Reflection/FunctionVariant.php | 67 + .../GenericParametersAcceptorResolver.php | 138 + src/Reflection/InaccessibleMethod.php | 92 + src/Reflection/InitializerExprContext.php | 251 + .../InitializerExprTypeResolver.php | 2180 ++++++ src/Reflection/MethodPrototypeReflection.php | 87 + src/Reflection/MethodReflection.php | 34 + .../MethodsClassReflectionExtension.php | 30 + ...MissingConstantFromReflectionException.php | 26 + .../MissingMethodFromReflectionException.php | 26 + ...MissingPropertyFromReflectionException.php | 26 + .../Mixin/MixinMethodReflection.php | 89 + .../MixinMethodsClassReflectionExtension.php | 100 + ...ixinPropertiesClassReflectionExtension.php | 91 + src/Reflection/NamespaceAnswerer.php | 15 + .../ExtendedNativeParameterReflection.php | 101 + .../Native/NativeFunctionReflection.php | 154 + .../Native/NativeMethodReflection.php | 227 + .../Native/NativeParameterReflection.php | 67 + ...onEnumReturnDynamicReturnTypeExtension.php | 44 + src/Reflection/ParameterReflection.php | 24 + src/Reflection/ParametersAcceptor.php | 32 + src/Reflection/ParametersAcceptorSelector.php | 1102 +++ src/Reflection/PassedByReference.php | 80 + .../Php/ClosureCallMethodReflection.php | 196 + ...allUnresolvedMethodPrototypeReflection.php | 38 + src/Reflection/Php/DummyParameter.php | 50 + ...llowedSubTypesClassReflectionExtension.php | 29 + .../Php/EnumCasesMethodReflection.php | 160 + src/Reflection/Php/EnumPropertyReflection.php | 146 + ...mUnresolvedPropertyPrototypeReflection.php | 37 + src/Reflection/Php/ExitFunctionReflection.php | 147 + src/Reflection/Php/ExtendedDummyParameter.php | 72 + .../Php/PhpClassReflectionExtension.php | 1225 +++ .../PhpFunctionFromParserNodeReflection.php | 338 + src/Reflection/Php/PhpFunctionReflection.php | 278 + .../Php/PhpMethodFromParserNodeReflection.php | 300 + src/Reflection/Php/PhpMethodReflection.php | 529 ++ .../Php/PhpMethodReflectionFactory.php | 47 + .../PhpParameterFromParserNodeReflection.php | 117 + src/Reflection/Php/PhpParameterReflection.php | 152 + src/Reflection/Php/PhpPropertyReflection.php | 292 + .../Php/SimpleXMLElementProperty.php | 160 + .../Php/Soap/SoapClientMethodReflection.php | 101 + ...pClientMethodsClassReflectionExtension.php | 23 + .../Php/UniversalObjectCrateProperty.php | 150 + ...alObjectCratesClassReflectionExtension.php | 95 + src/Reflection/PhpVersionStaticAccessor.php | 31 + .../PropertiesClassReflectionExtension.php | 30 + src/Reflection/PropertyReflection.php | 29 + .../RealClassClassConstantReflection.php | 157 + src/Reflection/ReflectionProvider.php | 40 + .../DirectReflectionProviderProvider.php | 20 + .../DummyReflectionProvider.php | 73 + .../LazyReflectionProviderProvider.php | 21 + .../MemoizingReflectionProvider.php | 100 + .../ReflectionProviderFactory.php | 22 + .../ReflectionProviderProvider.php | 13 + .../SetterReflectionProviderProvider.php | 23 + .../ReflectionProviderStaticAccessor.php | 30 + ...ExtendsMethodsClassReflectionExtension.php | 58 + ...endsPropertiesClassReflectionExtension.php | 58 + src/Reflection/ResolvedFunctionVariant.php | 17 + .../ResolvedFunctionVariantWithCallable.php | 121 + .../ResolvedFunctionVariantWithOriginal.php | 301 + src/Reflection/ResolvedMethodReflection.php | 223 + src/Reflection/ResolvedPropertyReflection.php | 212 + .../SignatureMap/FunctionSignature.php | 46 + .../FunctionSignatureMapProvider.php | 263 + .../NativeFunctionReflectionProvider.php | 195 + .../SignatureMap/ParameterSignature.php | 65 + .../SignatureMap/Php8SignatureMapProvider.php | 512 ++ .../SignatureMap/SignatureMapParser.php | 118 + .../SignatureMap/SignatureMapProvider.php | 44 + .../SignatureMapProviderFactory.php | 28 + src/Reflection/TrivialParametersAcceptor.php | 102 + ...ackUnresolvedMethodPrototypeReflection.php | 145 + ...kUnresolvedPropertyPrototypeReflection.php | 95 + ...ypeUnresolvedMethodPrototypeReflection.php | 155 + ...eUnresolvedPropertyPrototypeReflection.php | 95 + .../Type/IntersectionTypeMethodReflection.php | 227 + .../IntersectionTypePropertyReflection.php | 199 + ...ypeUnresolvedMethodPrototypeReflection.php | 57 + ...eUnresolvedPropertyPrototypeReflection.php | 57 + .../Type/UnionTypeMethodReflection.php | 204 + .../Type/UnionTypePropertyReflection.php | 199 + ...ypeUnresolvedMethodPrototypeReflection.php | 58 + ...eUnresolvedPropertyPrototypeReflection.php | 57 + .../UnresolvedMethodPrototypeReflection.php | 20 + .../UnresolvedPropertyPrototypeReflection.php | 20 + .../WrappedExtendedMethodReflection.php | 172 + .../WrappedExtendedPropertyReflection.php | 143 + src/Reflection/WrapperPropertyReflection.php | 11 + src/Rules/Api/ApiClassConstFetchRule.php | 90 + src/Rules/Api/ApiClassExtendsRule.php | 77 + src/Rules/Api/ApiClassImplementsRule.php | 88 + src/Rules/Api/ApiInstanceofRule.php | 119 + src/Rules/Api/ApiInstanceofTypeRule.php | 158 + src/Rules/Api/ApiInstantiationRule.php | 77 + src/Rules/Api/ApiInterfaceExtendsRule.php | 88 + src/Rules/Api/ApiMethodCallRule.php | 83 + src/Rules/Api/ApiRuleHelper.php | 87 + src/Rules/Api/ApiStaticCallRule.php | 98 + src/Rules/Api/ApiTraitUseRule.php | 58 + src/Rules/Api/BcUncoveredInterface.php | 73 + src/Rules/Api/GetTemplateTypeRule.php | 82 + .../NodeConnectingVisitorAttributesRule.php | 78 + src/Rules/Api/OldPhpParser4ClassRule.php | 80 + .../PhpStanNamespaceIn3rdPartyPackageRule.php | 90 + .../Api/RuntimeReflectionFunctionRule.php | 77 + .../RuntimeReflectionInstantiationRule.php | 96 + src/Rules/Arrays/AllowedArrayKeysTypes.php | 87 + src/Rules/Arrays/ArrayDestructuringRule.php | 118 + src/Rules/Arrays/ArrayUnpackingRule.php | 66 + src/Rules/Arrays/DeadForeachRule.php | 40 + .../DuplicateKeysInLiteralArraysRule.php | 122 + .../Arrays/InvalidKeyInArrayDimFetchRule.php | 69 + .../Arrays/InvalidKeyInArrayItemRule.php | 54 + src/Rules/Arrays/IterableInForeachRule.php | 57 + .../NonexistentOffsetInArrayDimFetchCheck.php | 119 + .../NonexistentOffsetInArrayDimFetchRule.php | 108 + src/Rules/Arrays/OffsetAccessAssignOpRule.php | 98 + .../Arrays/OffsetAccessAssignmentRule.php | 99 + .../OffsetAccessValueAssignmentRule.php | 94 + .../OffsetAccessWithoutDimForReadingRule.php | 40 + .../Arrays/UnpackIterableInArrayRule.php | 70 + src/Rules/AttributesCheck.php | 168 + src/Rules/Cast/EchoRule.php | 58 + src/Rules/Cast/InvalidCastRule.php | 103 + .../Cast/InvalidPartOfEncapsedStringRule.php | 68 + src/Rules/Cast/PrintRule.php | 52 + src/Rules/Cast/UnsetCastRule.php | 41 + src/Rules/ClassCaseSensitivityCheck.php | 56 + src/Rules/ClassForbiddenNameCheck.php | 94 + src/Rules/ClassNameCheck.php | 36 + src/Rules/ClassNameNodePair.php | 25 + ...AccessPrivateConstantThroughStaticRule.php | 62 + src/Rules/Classes/AllowedSubTypesRule.php | 69 + src/Rules/Classes/ClassAttributesRule.php | 70 + .../Classes/ClassConstantAttributesRule.php | 37 + src/Rules/Classes/ClassConstantRule.php | 208 + .../Classes/DuplicateClassDeclarationRule.php | 67 + .../Classes/DuplicateDeclarationRule.php | 134 + src/Rules/Classes/EnumSanityRule.php | 227 + .../ExistingClassInClassExtendsRule.php | 132 + .../Classes/ExistingClassInInstanceOfRule.php | 105 + .../Classes/ExistingClassInTraitUseRule.php | 98 + .../ExistingClassesInClassImplementsRule.php | 95 + .../ExistingClassesInEnumImplementsRule.php | 92 + .../ExistingClassesInInterfaceExtendsRule.php | 93 + .../Classes/ImpossibleInstanceOfRule.php | 114 + .../Classes/InstantiationCallableRule.php | 33 + src/Rules/Classes/InstantiationRule.php | 277 + .../Classes/InvalidPromotedPropertiesRule.php | 104 + src/Rules/Classes/LocalTypeAliasesCheck.php | 360 + src/Rules/Classes/LocalTypeAliasesRule.php | 31 + .../Classes/LocalTypeTraitAliasesRule.php | 40 + .../Classes/LocalTypeTraitUseAliasesRule.php | 35 + src/Rules/Classes/MethodTagCheck.php | 265 + src/Rules/Classes/MethodTagRule.php | 34 + src/Rules/Classes/MethodTagTraitRule.php | 40 + src/Rules/Classes/MethodTagTraitUseRule.php | 35 + src/Rules/Classes/MixinCheck.php | 171 + src/Rules/Classes/MixinRule.php | 31 + src/Rules/Classes/MixinTraitRule.php | 42 + src/Rules/Classes/MixinTraitUseRule.php | 35 + src/Rules/Classes/NewStaticRule.php | 82 + .../Classes/NonClassAttributeClassRule.php | 84 + src/Rules/Classes/PropertyTagCheck.php | 246 + src/Rules/Classes/PropertyTagRule.php | 31 + src/Rules/Classes/PropertyTagTraitRule.php | 40 + src/Rules/Classes/PropertyTagTraitUseRule.php | 35 + src/Rules/Classes/ReadOnlyClassRule.php | 59 + src/Rules/Classes/RequireExtendsRule.php | 88 + src/Rules/Classes/RequireImplementsRule.php | 59 + src/Rules/Classes/TraitAttributeClassRule.php | 40 + .../UnusedConstructorParametersRule.php | 71 + .../BooleanAndConstantConditionRule.php | 160 + .../BooleanNotConstantConditionRule.php | 78 + .../BooleanOrConstantConditionRule.php | 160 + .../ConstantConditionRuleHelper.php | 86 + .../ConstantLooseComparisonRule.php | 92 + .../DoWhileLoopConstantConditionRule.php | 96 + .../ElseIfConstantConditionRule.php | 77 + .../Comparison/IfConstantConditionRule.php | 68 + .../ImpossibleCheckTypeFunctionCallRule.php | 94 + .../Comparison/ImpossibleCheckTypeHelper.php | 423 ++ .../ImpossibleCheckTypeMethodCallRule.php | 111 + ...mpossibleCheckTypeStaticMethodCallRule.php | 121 + .../LogicalXorConstantConditionRule.php | 110 + src/Rules/Comparison/MatchExpressionRule.php | 175 + ...mparisonOperatorsConstantConditionRule.php | 98 + .../StrictComparisonOfDifferentTypesRule.php | 150 + .../TernaryOperatorConstantConditionRule.php | 65 + .../UsageOfVoidMatchExpressionRule.php | 34 + .../WhileLoopAlwaysFalseConditionRule.php | 65 + .../WhileLoopAlwaysTrueConditionRule.php | 92 + .../AlwaysUsedClassConstantsExtension.php | 31 + ...aysUsedClassConstantsExtensionProvider.php | 16 + .../Constants/ClassAsClassConstantRule.php | 41 + src/Rules/Constants/ConstantRule.php | 40 + .../DynamicClassConstantFetchRule.php | 70 + src/Rules/Constants/FinalConstantRule.php | 44 + ...aysUsedClassConstantsExtensionProvider.php | 27 + .../Constants/MagicConstantContextRule.php | 76 + .../MissingClassConstantTypehintRule.php | 97 + .../NativeTypedClassConstantRule.php | 45 + .../Constants/OverridingConstantRule.php | 173 + .../ValueAssignedToClassConstantRule.php | 129 + src/Rules/DateTimeInstantiationRule.php | 72 + ...ructorStatementWithoutImpurePointsRule.php | 55 + ...nctionStatementWithoutImpurePointsRule.php | 55 + ...MethodStatementWithoutImpurePointsRule.php | 72 + ...MethodStatementWithoutImpurePointsRule.php | 69 + ...onstructorWithoutImpurePointsCollector.php | 57 + .../FunctionWithoutImpurePointsCollector.php | 56 + .../MethodWithoutImpurePointsCollector.php | 60 + src/Rules/DeadCode/NoopRule.php | 139 + .../PossiblyPureFuncCallCollector.php | 51 + .../PossiblyPureMethodCallCollector.php | 75 + .../DeadCode/PossiblyPureNewCollector.php | 61 + .../PossiblyPureStaticCallCollector.php | 56 + .../DeadCode/UnreachableStatementRule.php | 32 + .../DeadCode/UnusedPrivateConstantRule.php | 107 + .../DeadCode/UnusedPrivateMethodRule.php | 196 + .../DeadCode/UnusedPrivatePropertyRule.php | 240 + src/Rules/Debug/DebugScopeRule.php | 67 + src/Rules/Debug/DumpPhpDocTypeRule.php | 60 + src/Rules/Debug/DumpTypeRule.php | 60 + src/Rules/Debug/FileAssertRule.php | 203 + src/Rules/DirectRegistry.php | 57 + .../EnumCases/EnumCaseAttributesRule.php | 37 + .../CatchWithUnthrownExceptionRule.php | 70 + .../CaughtExceptionExistenceRule.php | 74 + .../DefaultExceptionTypeResolver.php | 101 + .../Exceptions/ExceptionTypeResolver.php | 40 + ...ngCheckedExceptionInFunctionThrowsRule.php | 48 + ...singCheckedExceptionInMethodThrowsRule.php | 49 + ...eckedExceptionInPropertyHookThrowsRule.php | 56 + .../MissingCheckedExceptionInThrowsCheck.php | 62 + .../Exceptions/NoncapturingCatchRule.php | 43 + .../OverwrittenExitPointByFinallyRule.php | 70 + src/Rules/Exceptions/ThrowExprTypeRule.php | 63 + src/Rules/Exceptions/ThrowExpressionRule.php | 45 + ...VoidFunctionWithExplicitThrowPointRule.php | 72 + ...wsVoidMethodWithExplicitThrowPointRule.php | 73 + ...PropertyHookWithExplicitThrowPointRule.php | 80 + .../TooWideFunctionThrowTypeRule.php | 52 + .../Exceptions/TooWideMethodThrowTypeRule.php | 68 + .../TooWidePropertyHookThrowTypeRule.php | 75 + .../Exceptions/TooWideThrowTypeCheck.php | 51 + src/Rules/FileRuleError.php | 14 + src/Rules/FoundTypeResult.php | 53 + src/Rules/FunctionCallParametersCheck.php | 659 ++ src/Rules/FunctionDefinitionCheck.php | 733 ++ src/Rules/FunctionReturnTypeCheck.php | 112 + src/Rules/Functions/ArrayFilterRule.php | 139 + src/Rules/Functions/ArrayValuesRule.php | 115 + .../Functions/ArrowFunctionAttributesRule.php | 38 + .../ArrowFunctionReturnNullsafeByRefRule.php | 45 + .../Functions/ArrowFunctionReturnTypeRule.php | 71 + src/Rules/Functions/CallCallablesRule.php | 143 + .../CallToFunctionParametersRule.php | 73 + ...unctionStatementWithoutSideEffectsRule.php | 141 + .../CallToNonExistentFunctionRule.php | 72 + src/Rules/Functions/CallUserFuncRule.php | 89 + src/Rules/Functions/ClosureAttributesRule.php | 38 + src/Rules/Functions/ClosureReturnTypeRule.php | 67 + src/Rules/Functions/DefineParametersRule.php | 58 + .../DuplicateFunctionDeclarationRule.php | 60 + ...ingClassesInArrowFunctionTypehintsRule.php | 56 + .../ExistingClassesInClosureTypehintsRule.php | 41 + .../ExistingClassesInTypehintsRule.php | 57 + .../Functions/FunctionAttributesRule.php | 38 + src/Rules/Functions/FunctionCallableRule.php | 114 + .../ImplodeParameterCastableToStringRule.php | 118 + ...eArrowFunctionDefaultParameterTypeRule.php | 71 + ...patibleClosureDefaultParameterTypeRule.php | 71 + .../IncompatibleDefaultParameterTypeRule.php | 71 + src/Rules/Functions/InnerFunctionRule.php | 36 + ...nvalidLexicalVariablesInClosureUseRule.php | 87 + .../MissingFunctionParameterTypehintRule.php | 117 + .../MissingFunctionReturnTypehintRule.php | 78 + src/Rules/Functions/ParamAttributesRule.php | 44 + .../ParameterCastableToNumberRule.php | 85 + .../ParameterCastableToStringRule.php | 119 + .../Functions/PrintfArrayParametersRule.php | 189 + src/Rules/Functions/PrintfHelper.php | 76 + src/Rules/Functions/PrintfParametersRule.php | 119 + .../Functions/RandomIntParametersRule.php | 80 + .../Functions/RedefinedParametersRule.php | 61 + .../Functions/ReturnNullsafeByRefRule.php | 55 + src/Rules/Functions/ReturnTypeRule.php | 71 + .../SortParameterCastableToStringRule.php | 151 + src/Rules/Functions/UnusedClosureUsesRule.php | 43 + .../UselessFunctionReturnValueRule.php | 90 + .../VariadicParametersDeclarationRule.php | 52 + src/Rules/Generators/YieldFromTypeRule.php | 146 + src/Rules/Generators/YieldInGeneratorRule.php | 78 + src/Rules/Generators/YieldTypeRule.php | 97 + src/Rules/Generics/ClassAncestorsRule.php | 90 + src/Rules/Generics/ClassTemplateTypeRule.php | 59 + .../Generics/CrossCheckInterfacesHelper.php | 95 + src/Rules/Generics/EnumAncestorsRule.php | 88 + src/Rules/Generics/EnumTemplateTypeRule.php | 46 + .../FunctionSignatureVarianceRule.php | 45 + .../Generics/FunctionTemplateTypeRule.php | 70 + src/Rules/Generics/GenericAncestorsCheck.php | 197 + src/Rules/Generics/GenericObjectTypeCheck.php | 193 + src/Rules/Generics/InterfaceAncestorsRule.php | 88 + .../Generics/InterfaceTemplateTypeRule.php | 56 + .../Generics/MethodSignatureVarianceRule.php | 44 + .../Generics/MethodTagTemplateTypeCheck.php | 84 + .../Generics/MethodTagTemplateTypeRule.php | 43 + .../MethodTagTemplateTypeTraitRule.php | 53 + src/Rules/Generics/MethodTemplateTypeRule.php | 89 + src/Rules/Generics/PropertyVarianceRule.php | 56 + src/Rules/Generics/TemplateTypeCheck.php | 213 + src/Rules/Generics/TraitTemplateTypeRule.php | 70 + src/Rules/Generics/UsedTraitsRule.php | 90 + src/Rules/Generics/VarianceCheck.php | 111 + src/Rules/IdentifierRuleError.php | 12 + src/Rules/Ignore/IgnoreParseErrorRule.php | 48 + src/Rules/IssetCheck.php | 298 + .../Keywords/ContinueBreakInLoopRule.php | 83 + src/Rules/Keywords/DeclareStrictTypesRule.php | 81 + src/Rules/Keywords/RequireFileExistsRule.php | 138 + src/Rules/LazyRegistry.php | 72 + src/Rules/LineRuleError.php | 12 + src/Rules/MetadataRuleError.php | 15 + .../AbstractMethodInNonAbstractClassRule.php | 76 + .../Methods/AbstractPrivateMethodRule.php | 59 + .../Methods/AlwaysUsedMethodExtension.php | 28 + .../AlwaysUsedMethodExtensionProvider.php | 16 + src/Rules/Methods/CallMethodsRule.php | 79 + .../CallPrivateMethodThroughStaticRule.php | 63 + src/Rules/Methods/CallStaticMethodsRule.php | 89 + ...tructorStatementWithoutSideEffectsRule.php | 73 + ...oMethodStatementWithoutSideEffectsRule.php | 82 + ...cMethodStatementWithoutSideEffectsRule.php | 104 + .../Methods/ConsistentConstructorRule.php | 59 + .../Methods/ConstructorReturnTypeRule.php | 64 + ...irectAlwaysUsedMethodExtensionProvider.php | 21 + .../ExistingClassesInTypehintsRule.php | 68 + src/Rules/Methods/FinalPrivateMethodRule.php | 46 + .../IncompatibleDefaultParameterTypeRule.php | 73 + .../LazyAlwaysUsedMethodExtensionProvider.php | 23 + src/Rules/Methods/MethodAttributesRule.php | 38 + src/Rules/Methods/MethodCallCheck.php | 151 + src/Rules/Methods/MethodCallableRule.php | 67 + .../MethodParameterComparisonHelper.php | 411 + src/Rules/Methods/MethodSignatureRule.php | 292 + .../MethodVisibilityComparisonHelper.php | 52 + .../MethodVisibilityInInterfaceRule.php | 48 + .../MissingMagicSerializationMethodsRule.php | 88 + .../MissingMethodImplementationRule.php | 67 + .../MissingMethodParameterTypehintRule.php | 121 + .../MissingMethodReturnTypehintRule.php | 91 + .../Methods/MissingMethodSelfOutTypeRule.php | 85 + src/Rules/Methods/NullsafeMethodCallRule.php | 38 + src/Rules/Methods/OverridingMethodRule.php | 393 + src/Rules/Methods/ReturnTypeRule.php | 131 + src/Rules/Methods/StaticMethodCallCheck.php | 302 + .../Methods/StaticMethodCallableRule.php | 67 + src/Rules/Missing/MissingReturnRule.php | 152 + src/Rules/MissingTypehintCheck.php | 185 + src/Rules/Names/UsedNamesRule.php | 150 + .../ExistingNamesInGroupUseRule.php | 132 + .../Namespaces/ExistingNamesInUseRule.php | 131 + src/Rules/NonIgnorableRuleError.php | 10 + src/Rules/NullsafeCheck.php | 59 + src/Rules/Operators/InvalidAssignVarRule.php | 107 + .../Operators/InvalidBinaryOperationRule.php | 124 + .../InvalidComparisonOperationRule.php | 168 + .../Operators/InvalidIncDecOperationRule.php | 113 + .../Operators/InvalidUnaryOperationRule.php | 96 + src/Rules/ParameterCastableToStringCheck.php | 73 + src/Rules/PhpDoc/AssertRuleHelper.php | 210 + .../ConditionalReturnTypeRuleHelper.php | 129 + src/Rules/PhpDoc/FunctionAssertRule.php | 38 + .../FunctionConditionalReturnTypeRule.php | 38 + .../PhpDoc/GenericCallableRuleHelper.php | 121 + ...ncompatibleClassConstantPhpDocTypeRule.php | 139 + ...bleParamImmediatelyInvokedCallableRule.php | 95 + .../PhpDoc/IncompatiblePhpDocTypeCheck.php | 237 + .../PhpDoc/IncompatiblePhpDocTypeRule.php | 110 + ...IncompatiblePropertyHookPhpDocTypeRule.php | 86 + .../IncompatiblePropertyPhpDocTypeRule.php | 154 + .../PhpDoc/IncompatibleSelfOutTypeRule.php | 104 + src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 119 + .../PhpDoc/InvalidPhpDocTagValueRule.php | 101 + .../PhpDoc/InvalidPhpDocVarTagTypeRule.php | 161 + .../PhpDoc/InvalidThrowsPhpDocValueRule.php | 113 + src/Rules/PhpDoc/MethodAssertRule.php | 38 + .../MethodConditionalReturnTypeRule.php | 38 + src/Rules/PhpDoc/PhpDocLineHelper.php | 29 + src/Rules/PhpDoc/RequireExtendsCheck.php | 84 + .../RequireExtendsDefinitionClassRule.php | 51 + .../RequireExtendsDefinitionTraitRule.php | 44 + .../RequireImplementsDefinitionClassRule.php | 41 + .../RequireImplementsDefinitionTraitRule.php | 87 + src/Rules/PhpDoc/UnresolvableTypeHelper.php | 33 + .../VarTagChangedExpressionTypeRule.php | 31 + src/Rules/PhpDoc/VarTagTypeRuleHelper.php | 193 + .../PhpDoc/WrongVariableNameInVarTagRule.php | 415 ++ src/Rules/Playground/FunctionNeverRule.php | 52 + src/Rules/Playground/MethodNeverRule.php | 53 + src/Rules/Playground/NeverRuleHelper.php | 50 + src/Rules/Playground/NoPhpCodeRule.php | 42 + src/Rules/Playground/NotAnalysedTraitRule.php | 63 + src/Rules/Playground/PromoteParameterRule.php | 62 + .../Playground/StaticVarWithoutTypeRule.php | 82 + ...AccessPrivatePropertyThroughStaticRule.php | 65 + .../Properties/AccessPropertiesCheck.php | 202 + .../AccessPropertiesInAssignRule.php | 39 + src/Rules/Properties/AccessPropertiesRule.php | 31 + .../AccessStaticPropertiesInAssignRule.php | 39 + .../Properties/AccessStaticPropertiesRule.php | 245 + ...aultValueTypesAssignedToPropertiesRule.php | 69 + ...ctReadWritePropertiesExtensionProvider.php | 24 + .../ExistingClassesInPropertiesRule.php | 98 + ...tingClassesInPropertyHookTypehintsRule.php | 88 + .../Properties/FoundPropertyReflection.php | 182 + .../GetNonVirtualPropertyHookReadRule.php | 129 + .../InvalidCallablePropertyTypeRule.php | 66 + ...zyReadWritePropertiesExtensionProvider.php | 27 + .../MissingPropertyTypehintRule.php | 89 + ...singReadOnlyByPhpDocPropertyAssignRule.php | 80 + .../MissingReadOnlyPropertyAssignRule.php | 83 + .../Properties/NullsafePropertyFetchRule.php | 46 + .../Properties/OverridingPropertyRule.php | 346 + .../Properties/PropertiesInInterfaceRule.php | 102 + .../Properties/PropertyAssignRefRule.php | 72 + .../Properties/PropertyAttributesRule.php | 37 + src/Rules/Properties/PropertyDescriptor.php | 42 + .../Properties/PropertyHookAttributesRule.php | 38 + src/Rules/Properties/PropertyInClassRule.php | 143 + .../Properties/PropertyReflectionFinder.php | 127 + .../ReadOnlyByPhpDocPropertyAssignRefRule.php | 58 + .../ReadOnlyByPhpDocPropertyAssignRule.php | 118 + .../ReadOnlyByPhpDocPropertyRule.php | 39 + .../ReadOnlyPropertyAssignRefRule.php | 58 + .../Properties/ReadOnlyPropertyAssignRule.php | 101 + src/Rules/Properties/ReadOnlyPropertyRule.php | 63 + .../ReadWritePropertiesExtension.php | 35 + .../ReadWritePropertiesExtensionProvider.php | 16 + .../ReadingWriteOnlyPropertiesRule.php | 78 + .../SetNonVirtualPropertyHookAssignRule.php | 94 + .../SetPropertyHookParameterRule.php | 158 + .../TypesAssignedToPropertiesRule.php | 119 + .../Properties/UninitializedPropertyRule.php | 69 + .../WritingToReadOnlyPropertiesRule.php | 68 + src/Rules/Pure/FunctionPurityCheck.php | 144 + src/Rules/Pure/PureFunctionRule.php | 44 + src/Rules/Pure/PureMethodRule.php | 44 + .../Regexp/RegularExpressionPatternRule.php | 133 + .../Regexp/RegularExpressionQuotingRule.php | 243 + src/Rules/Registry.php | 18 + src/Rules/Rule.php | 40 + src/Rules/RuleError.php | 12 + src/Rules/RuleErrorBuilder.php | 285 + src/Rules/RuleErrors/RuleError1.php | 21 + src/Rules/RuleErrors/RuleError101.php | 49 + src/Rules/RuleErrors/RuleError103.php | 57 + src/Rules/RuleErrors/RuleError105.php | 42 + src/Rules/RuleErrors/RuleError107.php | 50 + src/Rules/RuleErrors/RuleError109.php | 57 + src/Rules/RuleErrors/RuleError11.php | 37 + src/Rules/RuleErrors/RuleError111.php | 65 + src/Rules/RuleErrors/RuleError113.php | 42 + src/Rules/RuleErrors/RuleError115.php | 50 + src/Rules/RuleErrors/RuleError117.php | 57 + src/Rules/RuleErrors/RuleError119.php | 65 + src/Rules/RuleErrors/RuleError121.php | 50 + src/Rules/RuleErrors/RuleError123.php | 58 + src/Rules/RuleErrors/RuleError125.php | 65 + src/Rules/RuleErrors/RuleError127.php | 73 + src/Rules/RuleErrors/RuleError13.php | 44 + src/Rules/RuleErrors/RuleError15.php | 52 + src/Rules/RuleErrors/RuleError17.php | 29 + src/Rules/RuleErrors/RuleError19.php | 37 + src/Rules/RuleErrors/RuleError21.php | 44 + src/Rules/RuleErrors/RuleError23.php | 52 + src/Rules/RuleErrors/RuleError25.php | 37 + src/Rules/RuleErrors/RuleError27.php | 45 + src/Rules/RuleErrors/RuleError29.php | 52 + src/Rules/RuleErrors/RuleError3.php | 29 + src/Rules/RuleErrors/RuleError31.php | 60 + src/Rules/RuleErrors/RuleError33.php | 33 + src/Rules/RuleErrors/RuleError35.php | 41 + src/Rules/RuleErrors/RuleError37.php | 48 + src/Rules/RuleErrors/RuleError39.php | 56 + src/Rules/RuleErrors/RuleError41.php | 41 + src/Rules/RuleErrors/RuleError43.php | 49 + src/Rules/RuleErrors/RuleError45.php | 56 + src/Rules/RuleErrors/RuleError47.php | 64 + src/Rules/RuleErrors/RuleError49.php | 41 + src/Rules/RuleErrors/RuleError5.php | 36 + src/Rules/RuleErrors/RuleError51.php | 49 + src/Rules/RuleErrors/RuleError53.php | 56 + src/Rules/RuleErrors/RuleError55.php | 64 + src/Rules/RuleErrors/RuleError57.php | 49 + src/Rules/RuleErrors/RuleError59.php | 57 + src/Rules/RuleErrors/RuleError61.php | 64 + src/Rules/RuleErrors/RuleError63.php | 72 + src/Rules/RuleErrors/RuleError65.php | 22 + src/Rules/RuleErrors/RuleError67.php | 30 + src/Rules/RuleErrors/RuleError69.php | 37 + src/Rules/RuleErrors/RuleError7.php | 44 + src/Rules/RuleErrors/RuleError71.php | 45 + src/Rules/RuleErrors/RuleError73.php | 30 + src/Rules/RuleErrors/RuleError75.php | 38 + src/Rules/RuleErrors/RuleError77.php | 45 + src/Rules/RuleErrors/RuleError79.php | 53 + src/Rules/RuleErrors/RuleError81.php | 30 + src/Rules/RuleErrors/RuleError83.php | 38 + src/Rules/RuleErrors/RuleError85.php | 45 + src/Rules/RuleErrors/RuleError87.php | 53 + src/Rules/RuleErrors/RuleError89.php | 38 + src/Rules/RuleErrors/RuleError9.php | 29 + src/Rules/RuleErrors/RuleError91.php | 46 + src/Rules/RuleErrors/RuleError93.php | 53 + src/Rules/RuleErrors/RuleError95.php | 61 + src/Rules/RuleErrors/RuleError97.php | 34 + src/Rules/RuleErrors/RuleError99.php | 42 + src/Rules/RuleLevelHelper.php | 320 + src/Rules/RuleLevelHelperAcceptsResult.php | 45 + src/Rules/TipRuleError.php | 12 + ...TooWideArrowFunctionReturnTypehintRule.php | 62 + .../TooWideClosureReturnTypehintRule.php | 84 + .../TooWideFunctionParameterOutTypeRule.php | 41 + .../TooWideFunctionReturnTypehintRule.php | 92 + .../TooWideMethodParameterOutTypeRule.php | 41 + .../TooWideMethodReturnTypehintRule.php | 120 + .../TooWideParameterOutTypeCheck.php | 119 + .../TooWidePropertyTypeRule.php | 136 + .../Traits/ConflictingTraitConstantsRule.php | 253 + src/Rules/Traits/ConstantsInTraitsRule.php | 47 + src/Rules/Traits/NotAnalysedTraitRule.php | 65 + src/Rules/Traits/TraitAttributesRule.php | 52 + .../Traits/TraitDeclarationCollector.php | 30 + src/Rules/Traits/TraitUseCollector.php | 31 + src/Rules/Types/InvalidTypesInUnionRule.php | 126 + src/Rules/UnusedFunctionParametersCheck.php | 119 + src/Rules/Variables/CompactVariablesRule.php | 91 + src/Rules/Variables/DefinedVariableRule.php | 73 + src/Rules/Variables/EmptyRule.php | 68 + src/Rules/Variables/IssetRule.php | 52 + src/Rules/Variables/NullCoalesceRule.php | 57 + .../ParameterOutAssignedTypeRule.php | 121 + .../ParameterOutExecutionEndTypeRule.php | 133 + src/Rules/Variables/UnsetRule.php | 78 + src/Rules/Variables/VariableCloningRule.php | 68 + src/Rules/Whitespace/FileWhitespaceRule.php | 96 + src/ShouldNotHappenException.php | 17 + src/Testing/ErrorFormatterTestCase.php | 140 + src/Testing/LevelsTestCase.php | 210 + src/Testing/PHPStanTestCase.php | 251 + src/Testing/RuleTestCase.php | 227 + src/Testing/TestCaseSourceLocatorFactory.php | 92 + src/Testing/TypeInferenceTestCase.php | 368 + src/Testing/functions.php | 49 + src/TrinaryLogic.php | 255 + src/Type/AcceptsResult.php | 131 + src/Type/Accessory/AccessoryArrayListType.php | 503 ++ .../Accessory/AccessoryLiteralStringType.php | 375 + .../AccessoryLowercaseStringType.php | 380 + .../Accessory/AccessoryNonEmptyStringType.php | 385 + .../Accessory/AccessoryNonFalsyStringType.php | 374 + .../Accessory/AccessoryNumericStringType.php | 387 + src/Type/Accessory/AccessoryType.php | 11 + .../AccessoryUppercaseStringType.php | 380 + src/Type/Accessory/HasMethodType.php | 200 + src/Type/Accessory/HasOffsetType.php | 427 ++ src/Type/Accessory/HasOffsetValueType.php | 487 ++ src/Type/Accessory/HasPropertyType.php | 159 + src/Type/Accessory/NonEmptyArrayType.php | 479 ++ src/Type/Accessory/OversizedArrayType.php | 463 ++ src/Type/ArrayType.php | 572 ++ src/Type/BenevolentUnionType.php | 182 + src/Type/BitwiseFlagHelper.php | 108 + src/Type/BooleanType.php | 194 + src/Type/CallableType.php | 682 ++ src/Type/CallableTypeHelper.php | 119 + .../CircularTypeAliasDefinitionException.php | 11 + src/Type/CircularTypeAliasErrorType.php | 10 + src/Type/ClassStringType.php | 98 + src/Type/ClosureType.php | 805 ++ src/Type/ClosureTypeFactory.php | 120 + src/Type/CompoundType.php | 21 + src/Type/ConditionalType.php | 221 + src/Type/ConditionalTypeForParameter.php | 178 + src/Type/Constant/ConstantArrayType.php | 1692 +++++ .../Constant/ConstantArrayTypeAndMethod.php | 69 + .../Constant/ConstantArrayTypeBuilder.php | 322 + src/Type/Constant/ConstantBooleanType.php | 145 + src/Type/Constant/ConstantFloatType.php | 104 + src/Type/Constant/ConstantIntegerType.php | 109 + .../Constant/ConstantScalarToBooleanTrait.php | 16 + src/Type/Constant/ConstantStringType.php | 572 ++ src/Type/Constant/OversizedArrayBuilder.php | 102 + src/Type/ConstantScalarType.php | 15 + src/Type/ConstantTypeHelper.php | 72 + src/Type/DirectTypeAliasResolverProvider.php | 18 + .../DynamicFunctionReturnTypeExtension.php | 34 + .../DynamicFunctionThrowTypeExtension.php | 34 + src/Type/DynamicMethodReturnTypeExtension.php | 37 + src/Type/DynamicMethodThrowTypeExtension.php | 34 + .../DynamicReturnTypeExtensionRegistry.php | 97 + ...DynamicStaticMethodReturnTypeExtension.php | 37 + .../DynamicStaticMethodThrowTypeExtension.php | 34 + src/Type/Enum/EnumCaseObjectType.php | 205 + src/Type/ErrorType.php | 45 + src/Type/ExponentiateHelper.php | 132 + src/Type/ExpressionTypeResolverExtension.php | 29 + ...xpressionTypeResolverExtensionRegistry.php | 26 + src/Type/FileTypeMapper.php | 833 +++ src/Type/FloatType.php | 297 + .../FunctionParameterClosureTypeExtension.php | 33 + .../FunctionParameterOutTypeExtension.php | 33 + src/Type/FunctionTypeSpecifyingExtension.php | 36 + src/Type/GeneralizePrecision.php | 59 + src/Type/Generic/GenericClassStringType.php | 226 + src/Type/Generic/GenericObjectType.php | 394 + src/Type/Generic/GenericStaticType.php | 273 + src/Type/Generic/TemplateArrayType.php | 44 + .../Generic/TemplateBenevolentUnionType.php | 68 + src/Type/Generic/TemplateBooleanType.php | 44 + .../Generic/TemplateConstantArrayType.php | 44 + .../Generic/TemplateConstantIntegerType.php | 44 + .../Generic/TemplateConstantStringType.php | 44 + src/Type/Generic/TemplateFloatType.php | 44 + .../Generic/TemplateGenericObjectType.php | 51 + src/Type/Generic/TemplateIntegerType.php | 44 + src/Type/Generic/TemplateIntersectionType.php | 38 + src/Type/Generic/TemplateKeyOfType.php | 58 + src/Type/Generic/TemplateMixedType.php | 67 + src/Type/Generic/TemplateObjectShapeType.php | 44 + src/Type/Generic/TemplateObjectType.php | 40 + .../TemplateObjectWithoutClassType.php | 40 + src/Type/Generic/TemplateStrictMixedType.php | 49 + src/Type/Generic/TemplateStringType.php | 44 + src/Type/Generic/TemplateType.php | 33 + .../Generic/TemplateTypeArgumentStrategy.php | 47 + src/Type/Generic/TemplateTypeFactory.php | 119 + src/Type/Generic/TemplateTypeHelper.php | 152 + src/Type/Generic/TemplateTypeMap.php | 221 + .../Generic/TemplateTypeParameterStrategy.php | 30 + src/Type/Generic/TemplateTypeReference.php | 23 + src/Type/Generic/TemplateTypeScope.php | 72 + src/Type/Generic/TemplateTypeStrategy.php | 16 + src/Type/Generic/TemplateTypeTrait.php | 370 + src/Type/Generic/TemplateTypeVariance.php | 240 + src/Type/Generic/TemplateTypeVarianceMap.php | 54 + src/Type/Generic/TemplateUnionType.php | 55 + src/Type/Generic/TypeProjectionHelper.php | 32 + src/Type/Helper/GetTemplateTypeType.php | 107 + src/Type/IntegerRangeType.php | 758 ++ src/Type/IntegerType.php | 200 + src/Type/IntersectionType.php | 1363 ++++ src/Type/IsSuperTypeOfResult.php | 165 + src/Type/IterableType.php | 518 ++ src/Type/JustNullableTypeTrait.php | 173 + src/Type/KeyOfType.php | 95 + src/Type/LateResolvableType.php | 14 + src/Type/LazyTypeAliasResolverProvider.php | 20 + src/Type/LooseComparisonHelper.php | 51 + .../MethodParameterClosureTypeExtension.php | 33 + src/Type/MethodParameterOutTypeExtension.php | 33 + src/Type/MethodTypeSpecifyingExtension.php | 39 + src/Type/MixedType.php | 1056 +++ src/Type/NeverType.php | 537 ++ src/Type/NewObjectType.php | 95 + src/Type/NonAcceptingNeverType.php | 42 + src/Type/NonexistentParentClassType.php | 201 + src/Type/NullType.php | 405 + src/Type/ObjectShapePropertyReflection.php | 148 + src/Type/ObjectShapeType.php | 526 ++ src/Type/ObjectType.php | 1621 ++++ src/Type/ObjectWithoutClassType.php | 226 + src/Type/OffsetAccessType.php | 118 + src/Type/OperatorTypeSpecifyingExtension.php | 32 + ...peratorTypeSpecifyingExtensionRegistry.php | 29 + src/Type/ParserNodeTypeToPHPStanType.php | 103 + .../AbsFunctionDynamicReturnTypeExtension.php | 44 + ...gumentBasedFunctionReturnTypeExtension.php | 72 + ...angeKeyCaseFunctionReturnTypeExtension.php | 160 + .../ArrayChunkFunctionReturnTypeExtension.php | 52 + ...ArrayColumnFunctionReturnTypeExtension.php | 213 + ...rrayCombineFunctionReturnTypeExtension.php | 140 + ...ArrayCurrentDynamicReturnTypeExtension.php | 42 + .../ArrayFillFunctionReturnTypeExtension.php | 95 + ...rayFillKeysFunctionReturnTypeExtension.php | 42 + ...ArrayFilterFunctionReturnTypeExtension.php | 33 + .../ArrayFilterFunctionReturnTypeHelper.php | 341 + .../ArrayFindFunctionReturnTypeExtension.php | 47 + ...rrayFindKeyFunctionReturnTypeExtension.php | 37 + .../ArrayFlipFunctionReturnTypeExtension.php | 42 + ...ntersectKeyFunctionReturnTypeExtension.php | 63 + .../ArrayKeyDynamicReturnTypeExtension.php | 42 + ...yExistsFunctionTypeSpecifyingExtension.php | 124 + ...rrayKeyFirstDynamicReturnTypeExtension.php | 42 + ...ArrayKeyLastDynamicReturnTypeExtension.php | 42 + ...KeysFunctionDynamicReturnTypeExtension.php | 43 + .../ArrayMapFunctionReturnTypeExtension.php | 185 + ...ergeFunctionDynamicReturnTypeExtension.php | 138 + .../ArrayNextDynamicReturnTypeExtension.php | 40 + ...terFunctionsDynamicReturnTypeExtension.php | 56 + .../ArrayPopFunctionReturnTypeExtension.php | 42 + .../ArrayRandFunctionReturnTypeExtension.php | 66 + ...ArrayReduceFunctionReturnTypeExtension.php | 70 + ...rrayReplaceFunctionReturnTypeExtension.php | 77 + ...rrayReverseFunctionReturnTypeExtension.php | 44 + ...archFunctionDynamicReturnTypeExtension.php | 59 + ...ySearchFunctionTypeSpecifyingExtension.php | 57 + .../ArrayShiftFunctionReturnTypeExtension.php | 42 + .../ArraySliceFunctionReturnTypeExtension.php | 48 + ...ArraySpliceFunctionReturnTypeExtension.php | 36 + ...ySumFunctionDynamicReturnTypeExtension.php | 66 + ...luesFunctionDynamicReturnTypeExtension.php | 43 + .../AssertFunctionTypeSpecifyingExtension.php | 36 + src/Type/Php/AssertThrowTypeExtension.php | 37 + ...umFromMethodDynamicReturnTypeExtension.php | 101 + ...codeDynamicFunctionReturnTypeExtension.php | 59 + .../BcMathStringOrNullReturnTypeExtension.php | 275 + ...sExistsFunctionTypeSpecifyingExtension.php | 76 + ...sImplementsFunctionReturnTypeExtension.php | 68 + .../ClosureBindDynamicReturnTypeExtension.php | 37 + ...losureBindToDynamicReturnTypeExtension.php | 37 + ...FromCallableDynamicReturnTypeExtension.php | 64 + .../CompactFunctionReturnTypeExtension.php | 89 + .../ConstantFunctionReturnTypeExtension.php | 56 + src/Type/Php/ConstantHelper.php | 43 + ...harsFunctionDynamicReturnTypeExtension.php | 63 + .../Php/CountFunctionReturnTypeExtension.php | 44 + .../CountFunctionTypeSpecifyingExtension.php | 53 + ...peDigitFunctionTypeSpecifyingExtension.php | 87 + ...infoFunctionDynamicReturnTypeExtension.php | 201 + .../DateFormatFunctionReturnTypeExtension.php | 42 + .../DateFormatMethodReturnTypeExtension.php | 44 + .../Php/DateFunctionReturnTypeExtension.php | 41 + src/Type/Php/DateFunctionReturnTypeHelper.php | 115 + ...eIntervalConstructorThrowTypeExtension.php | 65 + ...DateIntervalDynamicReturnTypeExtension.php | 69 + ...tePeriodConstructorReturnTypeExtension.php | 85 + .../DateTimeConstructorThrowTypeExtension.php | 67 + ...teTimeCreateDynamicReturnTypeExtension.php | 50 + .../DateTimeDynamicReturnTypeExtension.php | 52 + ...DateTimeModifyMethodThrowTypeExtension.php | 72 + .../Php/DateTimeModifyReturnTypeExtension.php | 91 + .../DateTimeSubMethodThrowTypeExtension.php | 44 + ...eTimeZoneConstructorThrowTypeExtension.php | 65 + .../DefineConstantTypeSpecifyingExtension.php | 64 + ...DefinedConstantTypeSpecifyingExtension.php | 71 + ...StatDynamicFunctionReturnTypeExtension.php | 51 + .../DsMapDynamicMethodThrowTypeExtension.php | 32 + .../Php/DsMapDynamicReturnTypeExtension.php | 62 + ...lodeFunctionDynamicReturnTypeExtension.php | 95 + .../Php/FilterFunctionReturnTypeHelper.php | 451 ++ .../FilterInputDynamicReturnTypeExtension.php | 39 + ...lterVarArrayDynamicReturnTypeExtension.php | 199 + .../FilterVarDynamicReturnTypeExtension.php | 39 + ...nExistsFunctionTypeSpecifyingExtension.php | 63 + ...tCalledClassDynamicReturnTypeExtension.php | 31 + .../GetClassDynamicReturnTypeExtension.php | 96 + ...etDebugTypeFunctionReturnTypeExtension.php | 103 + ...DefinedVarsFunctionReturnTypeExtension.php | 47 + ...lassDynamicFunctionReturnTypeExtension.php | 105 + ...fdayDynamicFunctionReturnTypeExtension.php | 64 + .../GettypeFunctionReturnTypeExtension.php | 91 + .../Php/HashFunctionsReturnTypeExtension.php | 158 + ...hlightStringDynamicReturnTypeExtension.php | 52 + .../Php/HrtimeFunctionReturnTypeExtension.php | 51 + .../ImplodeFunctionReturnTypeExtension.php | 146 + ...InArrayFunctionTypeSpecifyingExtension.php | 168 + src/Type/Php/IniGetReturnTypeExtension.php | 65 + src/Type/Php/IntdivThrowTypeExtension.php | 51 + .../IsAFunctionTypeSpecifyingExtension.php | 64 + .../Php/IsAFunctionTypeSpecifyingHelper.php | 79 + ...IsArrayFunctionTypeSpecifyingExtension.php | 47 + ...allableFunctionTypeSpecifyingExtension.php | 70 + ...terableFunctionTypeSpecifyingExtension.php | 48 + ...classOfFunctionTypeSpecifyingExtension.php | 65 + ...atorToArrayFunctionReturnTypeExtension.php | 60 + ...ThrowOnErrorDynamicReturnTypeExtension.php | 132 + src/Type/Php/JsonThrowTypeExtension.php | 65 + .../Php/LtrimFunctionReturnTypeExtension.php | 44 + ...ertEncodingFunctionReturnTypeExtension.php | 46 + .../Php/MbFunctionsReturnTypeExtension.php | 83 + .../MbFunctionsReturnTypeExtensionTrait.php | 58 + .../MbStrlenFunctionReturnTypeExtension.php | 156 + ...uteCharacterDynamicReturnTypeExtension.php | 136 + .../MethodExistsTypeSpecifyingExtension.php | 86 + .../MicrotimeFunctionReturnTypeExtension.php | 50 + .../Php/MinMaxFunctionReturnTypeExtension.php | 243 + ...mptyStringFunctionsReturnTypeExtension.php | 96 + ...rmatFunctionDynamicReturnTypeExtension.php | 50 + ...penSslEncryptParameterOutTypeExtension.php | 71 + .../Php/ParseStrParameterOutTypeExtension.php | 61 + ...eUrlFunctionDynamicReturnTypeExtension.php | 229 + ...infoFunctionDynamicReturnTypeExtension.php | 99 + .../Php/PowFunctionReturnTypeExtension.php | 31 + .../PregFilterFunctionReturnTypeExtension.php | 52 + .../PregMatchParameterOutTypeExtension.php | 56 + .../Php/PregMatchTypeSpecifyingExtension.php | 85 + ...regReplaceCallbackClosureTypeExtension.php | 61 + .../PregSplitDynamicReturnTypeExtension.php | 53 + .../PropertyExistsTypeSpecifyingExtension.php | 90 + .../RandomIntFunctionReturnTypeExtension.php | 82 + .../Php/RangeFunctionReturnTypeExtension.php | 173 + ...tionClassConstructorThrowTypeExtension.php | 43 + ...assIsSubclassOfTypeSpecifyingExtension.php | 72 + ...nFunctionConstructorThrowTypeExtension.php | 56 + ...GetAttributesMethodReturnTypeExtension.php | 51 + ...ionMethodConstructorThrowTypeExtension.php | 80 + ...nPropertyConstructorThrowTypeExtension.php | 68 + src/Type/Php/RegexArrayShapeMatcher.php | 540 ++ ...aceFunctionsDynamicReturnTypeExtension.php | 202 + .../Php/RoundFunctionReturnTypeExtension.php | 100 + ...SetTypeFunctionTypeSpecifyingExtension.php | 91 + ...LElementAsXMLMethodReturnTypeExtension.php | 39 + ...lementClassPropertyReflectionExtension.php | 27 + ...MLElementConstructorThrowTypeExtension.php | 60 + ...LElementXpathMethodReturnTypeExtension.php | 58 + ...intfFunctionDynamicReturnTypeExtension.php | 374 + ...canfFunctionDynamicReturnTypeExtension.php | 90 + .../Php/StatDynamicReturnTypeExtension.php | 81 + .../StrCaseFunctionsReturnTypeExtension.php | 170 + .../StrContainingTypeSpecifyingExtension.php | 110 + ...ntDecrementFunctionReturnTypeExtension.php | 144 + .../Php/StrPadFunctionReturnTypeExtension.php | 74 + .../StrRepeatFunctionReturnTypeExtension.php | 114 + .../StrSplitFunctionReturnTypeExtension.php | 140 + .../Php/StrTokFunctionReturnTypeExtension.php | 46 + ...ountFunctionDynamicReturnTypeExtension.php | 66 + .../Php/StrlenFunctionReturnTypeExtension.php | 85 + .../StrtotimeFunctionReturnTypeExtension.php | 71 + ...trvalFamilyFunctionReturnTypeExtension.php | 64 + .../Php/SubstrDynamicReturnTypeExtension.php | 131 + src/Type/Php/ThrowableReturnTypeExtension.php | 78 + ...TriggerErrorDynamicReturnTypeExtension.php | 69 + ...TrimFunctionDynamicReturnTypeExtension.php | 53 + ...ingFunctionsDynamicReturnTypeExtension.php | 78 + ...pareFunctionDynamicReturnTypeExtension.php | 125 + .../Php/XMLReaderOpenReturnTypeExtension.php | 48 + src/Type/RecursionGuard.php | 32 + src/Type/Regex/RegexAlternation.php | 48 + src/Type/Regex/RegexAstWalkResult.php | 106 + src/Type/Regex/RegexCapturingGroup.php | 132 + src/Type/Regex/RegexExpressionHelper.php | 163 + src/Type/Regex/RegexGroupParser.php | 689 ++ src/Type/Regex/RegexGroupWalkResult.php | 122 + src/Type/Regex/RegexNonCapturingGroup.php | 56 + src/Type/ResourceType.php | 130 + src/Type/SimultaneousTypeTraverser.php | 40 + ...ticMethodParameterClosureTypeExtension.php | 33 + .../StaticMethodParameterOutTypeExtension.php | 33 + .../StaticMethodTypeSpecifyingExtension.php | 39 + src/Type/StaticType.php | 747 ++ src/Type/StaticTypeFactory.php | 45 + src/Type/StrictMixedType.php | 444 ++ ...gAlwaysAcceptingObjectWithToStringType.php | 57 + src/Type/StringType.php | 320 + src/Type/SubtractableType.php | 17 + src/Type/ThisType.php | 89 + src/Type/Traits/ArrayTypeTrait.php | 212 + .../ConstantNumericComparisonTypeTrait.php | 79 + src/Type/Traits/ConstantScalarTypeTrait.php | 129 + src/Type/Traits/FalseyBooleanTypeTrait.php | 17 + src/Type/Traits/LateResolvableTypeTrait.php | 597 ++ src/Type/Traits/MaybeArrayTypeTrait.php | 103 + src/Type/Traits/MaybeCallableTypeTrait.php | 23 + src/Type/Traits/MaybeIterableTypeTrait.php | 68 + src/Type/Traits/MaybeObjectTypeTrait.php | 111 + .../Traits/MaybeOffsetAccessibleTypeTrait.php | 43 + src/Type/Traits/NonArrayTypeTrait.php | 103 + src/Type/Traits/NonCallableTypeTrait.php | 23 + src/Type/Traits/NonGeneralizableTypeTrait.php | 17 + src/Type/Traits/NonGenericTypeTrait.php | 23 + src/Type/Traits/NonIterableTypeTrait.php | 58 + src/Type/Traits/NonObjectTypeTrait.php | 105 + .../Traits/NonOffsetAccessibleTypeTrait.php | 43 + src/Type/Traits/NonRemoveableTypeTrait.php | 16 + src/Type/Traits/ObjectTypeTrait.php | 282 + src/Type/Traits/TruthyBooleanTypeTrait.php | 17 + src/Type/Traits/UndecidedBooleanTypeTrait.php | 16 + .../UndecidedComparisonCompoundTypeTrait.php | 25 + .../Traits/UndecidedComparisonTypeTrait.php | 44 + src/Type/Type.php | 359 + src/Type/TypeAlias.php | 42 + src/Type/TypeAliasResolver.php | 15 + src/Type/TypeAliasResolverProvider.php | 11 + src/Type/TypeCombinator.php | 1399 ++++ src/Type/TypeResult.php | 30 + src/Type/TypeTraverser.php | 62 + src/Type/TypeUtils.php | 239 + src/Type/TypeWithClassName.php | 18 + src/Type/TypehintHelper.php | 156 + src/Type/UnionType.php | 1198 +++ src/Type/UnionTypeHelper.php | 139 + src/Type/UsefulTypeAliasResolver.php | 154 + src/Type/ValueOfType.php | 104 + src/Type/VerbosityLevel.php | 236 + src/Type/VoidType.php | 273 + src/autoloadFunctions.php | 12 + src/debugScope.php | 15 + src/dumpType.php | 28 + 1337 files changed, 162689 insertions(+), 4 deletions(-) create mode 100644 src/AnalysedCodeException.php create mode 100644 src/Analyser/Analyser.php create mode 100644 src/Analyser/AnalyserResult.php create mode 100644 src/Analyser/AnalyserResultFinalizer.php create mode 100644 src/Analyser/ArgumentsNormalizer.php create mode 100644 src/Analyser/ConditionalExpressionHolder.php create mode 100644 src/Analyser/ConstantResolver.php create mode 100644 src/Analyser/ConstantResolverFactory.php create mode 100644 src/Analyser/DirectInternalScopeFactory.php create mode 100644 src/Analyser/EndStatementResult.php create mode 100644 src/Analyser/EnsuredNonNullabilityResult.php create mode 100644 src/Analyser/EnsuredNonNullabilityResultExpression.php create mode 100644 src/Analyser/Error.php create mode 100644 src/Analyser/ExpressionContext.php create mode 100644 src/Analyser/ExpressionResult.php create mode 100644 src/Analyser/ExpressionTypeHolder.php create mode 100644 src/Analyser/FileAnalyser.php create mode 100644 src/Analyser/FileAnalyserResult.php create mode 100644 src/Analyser/FinalizerResult.php create mode 100644 src/Analyser/Ignore/IgnoreLexer.php create mode 100644 src/Analyser/Ignore/IgnoreParseException.php create mode 100644 src/Analyser/Ignore/IgnoredError.php create mode 100644 src/Analyser/Ignore/IgnoredErrorHelper.php create mode 100644 src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php create mode 100644 src/Analyser/Ignore/IgnoredErrorHelperResult.php create mode 100644 src/Analyser/ImpurePoint.php create mode 100644 src/Analyser/InternalError.php create mode 100644 src/Analyser/InternalScopeFactory.php create mode 100644 src/Analyser/LazyInternalScopeFactory.php create mode 100644 src/Analyser/LocalIgnoresProcessor.php create mode 100644 src/Analyser/LocalIgnoresProcessorResult.php create mode 100644 src/Analyser/MutatingScope.php create mode 100644 src/Analyser/NameScope.php create mode 100644 src/Analyser/NodeScopeResolver.php create mode 100644 src/Analyser/NullsafeOperatorHelper.php create mode 100644 src/Analyser/OutOfClassScope.php create mode 100644 src/Analyser/ProcessClosureResult.php create mode 100644 src/Analyser/ResultCache/ResultCache.php create mode 100644 src/Analyser/ResultCache/ResultCacheClearer.php create mode 100644 src/Analyser/ResultCache/ResultCacheManager.php create mode 100644 src/Analyser/ResultCache/ResultCacheManagerFactory.php create mode 100644 src/Analyser/ResultCache/ResultCacheMetaExtension.php create mode 100644 src/Analyser/ResultCache/ResultCacheProcessResult.php create mode 100644 src/Analyser/RicherScopeGetTypeHelper.php create mode 100644 src/Analyser/RuleErrorTransformer.php create mode 100644 src/Analyser/Scope.php create mode 100644 src/Analyser/ScopeContext.php create mode 100644 src/Analyser/ScopeFactory.php create mode 100644 src/Analyser/SpecifiedTypes.php create mode 100644 src/Analyser/StatementContext.php create mode 100644 src/Analyser/StatementExitPoint.php create mode 100644 src/Analyser/StatementResult.php create mode 100644 src/Analyser/ThrowPoint.php create mode 100644 src/Analyser/TypeSpecifier.php create mode 100644 src/Analyser/TypeSpecifierAwareExtension.php create mode 100644 src/Analyser/TypeSpecifierContext.php create mode 100644 src/Analyser/TypeSpecifierFactory.php create mode 100644 src/Analyser/UndefinedVariableException.php create mode 100644 src/Broker/AnonymousClassNameHelper.php create mode 100644 src/Broker/BrokerFactory.php create mode 100644 src/Broker/ClassAutoloadingException.php create mode 100644 src/Broker/ClassNotFoundException.php create mode 100644 src/Broker/ConstantNotFoundException.php create mode 100644 src/Broker/FunctionNotFoundException.php create mode 100644 src/Cache/Cache.php create mode 100644 src/Cache/CacheItem.php create mode 100644 src/Cache/CacheStorage.php create mode 100644 src/Cache/FileCacheStorage.php create mode 100644 src/Cache/MemoryCacheStorage.php create mode 100644 src/Classes/ForbiddenClassNameExtension.php create mode 100644 src/Collectors/CollectedData.php create mode 100644 src/Collectors/Collector.php create mode 100644 src/Collectors/Registry.php create mode 100644 src/Collectors/RegistryFactory.php create mode 100644 src/Command/AnalyseApplication.php create mode 100644 src/Command/AnalyseCommand.php create mode 100644 src/Command/AnalyserRunner.php create mode 100644 src/Command/AnalysisResult.php create mode 100644 src/Command/ClearResultCacheCommand.php create mode 100644 src/Command/CommandHelper.php create mode 100644 src/Command/DiagnoseCommand.php create mode 100644 src/Command/DumpParametersCommand.php create mode 100644 src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/CheckstyleErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/CiDetectedErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/ErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/GithubErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/GitlabErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/JsonErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/JunitErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/RawErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/TableErrorFormatter.php create mode 100644 src/Command/ErrorFormatter/TeamcityErrorFormatter.php create mode 100644 src/Command/ErrorsConsoleStyle.php create mode 100644 src/Command/FixerApplication.php create mode 100644 src/Command/FixerProcessException.php create mode 100644 src/Command/FixerWorkerCommand.php create mode 100644 src/Command/IgnoredRegexValidator.php create mode 100644 src/Command/IgnoredRegexValidatorResult.php create mode 100644 src/Command/InceptionNotSuccessfulException.php create mode 100644 src/Command/InceptionResult.php create mode 100644 src/Command/Output.php create mode 100644 src/Command/OutputStyle.php create mode 100644 src/Command/Symfony/SymfonyOutput.php create mode 100644 src/Command/Symfony/SymfonyStyle.php create mode 100644 src/Command/WorkerCommand.php create mode 100644 src/Dependency/DependencyResolver.php create mode 100644 src/Dependency/ExportedNode.php create mode 100644 src/Dependency/ExportedNode/ExportedAttributeNode.php create mode 100644 src/Dependency/ExportedNode/ExportedClassConstantNode.php create mode 100644 src/Dependency/ExportedNode/ExportedClassConstantsNode.php create mode 100644 src/Dependency/ExportedNode/ExportedClassNode.php create mode 100644 src/Dependency/ExportedNode/ExportedEnumCaseNode.php create mode 100644 src/Dependency/ExportedNode/ExportedEnumNode.php create mode 100644 src/Dependency/ExportedNode/ExportedFunctionNode.php create mode 100644 src/Dependency/ExportedNode/ExportedInterfaceNode.php create mode 100644 src/Dependency/ExportedNode/ExportedMethodNode.php create mode 100644 src/Dependency/ExportedNode/ExportedParameterNode.php create mode 100644 src/Dependency/ExportedNode/ExportedPhpDocNode.php create mode 100644 src/Dependency/ExportedNode/ExportedPropertiesNode.php create mode 100644 src/Dependency/ExportedNode/ExportedTraitNode.php create mode 100644 src/Dependency/ExportedNode/ExportedTraitUseAdaptation.php create mode 100644 src/Dependency/ExportedNodeFetcher.php create mode 100644 src/Dependency/ExportedNodeResolver.php create mode 100644 src/Dependency/ExportedNodeVisitor.php create mode 100644 src/Dependency/NodeDependencies.php create mode 100644 src/Dependency/RootExportedNode.php create mode 100644 src/DependencyInjection/BleedingEdgeToggle.php create mode 100644 src/DependencyInjection/ConditionalTagsExtension.php create mode 100644 src/DependencyInjection/Configurator.php create mode 100644 src/DependencyInjection/Container.php create mode 100644 src/DependencyInjection/ContainerFactory.php create mode 100644 src/DependencyInjection/DerivativeContainerFactory.php create mode 100644 src/DependencyInjection/DuplicateIncludedFilesException.php create mode 100644 src/DependencyInjection/InvalidExcludePathsException.php create mode 100644 src/DependencyInjection/InvalidIgnoredErrorPatternsException.php create mode 100644 src/DependencyInjection/InvalidPhpVersionException.php create mode 100644 src/DependencyInjection/LoaderFactory.php create mode 100644 src/DependencyInjection/MemoizingContainer.php create mode 100644 src/DependencyInjection/Neon/OptionalPath.php create mode 100644 src/DependencyInjection/NeonAdapter.php create mode 100644 src/DependencyInjection/NeonLoader.php create mode 100644 src/DependencyInjection/Nette/NetteContainer.php create mode 100644 src/DependencyInjection/ParameterNotFoundException.php create mode 100644 src/DependencyInjection/ParametersSchemaExtension.php create mode 100644 src/DependencyInjection/ProjectConfigHelper.php create mode 100644 src/DependencyInjection/Reflection/ClassReflectionExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Reflection/LazyClassReflectionExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/RulesExtension.php create mode 100644 src/DependencyInjection/Type/DynamicReturnTypeExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/DynamicThrowTypeExtensionProvider.php create mode 100644 src/DependencyInjection/Type/ExpressionTypeResolverExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/LazyDynamicReturnTypeExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/LazyDynamicThrowTypeExtensionProvider.php create mode 100644 src/DependencyInjection/Type/LazyExpressionTypeResolverExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/LazyOperatorTypeSpecifyingExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/LazyParameterClosureTypeExtensionProvider.php create mode 100644 src/DependencyInjection/Type/LazyParameterOutTypeExtensionProvider.php create mode 100644 src/DependencyInjection/Type/OperatorTypeSpecifyingExtensionRegistryProvider.php create mode 100644 src/DependencyInjection/Type/ParameterClosureTypeExtensionProvider.php create mode 100644 src/DependencyInjection/Type/ParameterOutTypeExtensionProvider.php create mode 100644 src/DependencyInjection/ValidateExcludePathsExtension.php create mode 100644 src/DependencyInjection/ValidateIgnoredErrorsExtension.php create mode 100644 src/Diagnose/DiagnoseExtension.php create mode 100644 src/Diagnose/PHPStanDiagnoseExtension.php create mode 100644 src/File/CouldNotReadFileException.php create mode 100644 src/File/CouldNotWriteFileException.php create mode 100644 src/File/FileExcluder.php create mode 100644 src/File/FileExcluderFactory.php create mode 100644 src/File/FileExcluderRawFactory.php create mode 100644 src/File/FileFinder.php create mode 100644 src/File/FileFinderResult.php create mode 100644 src/File/FileHelper.php create mode 100644 src/File/FileMonitor.php create mode 100644 src/File/FileMonitorResult.php create mode 100644 src/File/FileReader.php create mode 100644 src/File/FileWriter.php create mode 100644 src/File/FuzzyRelativePathHelper.php create mode 100644 src/File/NullRelativePathHelper.php create mode 100644 src/File/ParentDirectoryRelativePathHelper.php create mode 100644 src/File/PathNotFoundException.php create mode 100644 src/File/RelativePathHelper.php create mode 100644 src/File/SimpleRelativePathHelper.php create mode 100644 src/File/SystemAgnosticSimpleRelativePathHelper.php create mode 100644 src/Internal/BytesHelper.php create mode 100644 src/Internal/CombinationsHelper.php create mode 100644 src/Internal/ComposerHelper.php create mode 100644 src/Internal/DeprecatedAttributeHelper.php create mode 100644 src/Internal/DirectoryCreator.php create mode 100644 src/Internal/DirectoryCreatorException.php create mode 100644 src/Internal/SprintfHelper.php create mode 100644 src/Node/AnonymousClassNode.php create mode 100644 src/Node/BooleanAndNode.php create mode 100644 src/Node/BooleanOrNode.php create mode 100644 src/Node/BreaklessWhileLoopNode.php create mode 100644 src/Node/CatchWithUnthrownExceptionNode.php create mode 100644 src/Node/ClassConstantsNode.php create mode 100644 src/Node/ClassMethod.php create mode 100644 src/Node/ClassMethodsNode.php create mode 100644 src/Node/ClassPropertiesNode.php create mode 100644 src/Node/ClassPropertyNode.php create mode 100644 src/Node/ClassStatementsGatherer.php create mode 100644 src/Node/ClosureReturnStatementsNode.php create mode 100644 src/Node/CollectedDataNode.php create mode 100644 src/Node/Constant/ClassConstantFetch.php create mode 100644 src/Node/DoWhileLoopConditionNode.php create mode 100644 src/Node/ExecutionEndNode.php create mode 100644 src/Node/Expr/AlwaysRememberedExpr.php create mode 100644 src/Node/Expr/ExistingArrayDimFetch.php create mode 100644 src/Node/Expr/GetIterableKeyTypeExpr.php create mode 100644 src/Node/Expr/GetIterableValueTypeExpr.php create mode 100644 src/Node/Expr/GetOffsetValueTypeExpr.php create mode 100644 src/Node/Expr/OriginalPropertyTypeExpr.php create mode 100644 src/Node/Expr/ParameterVariableOriginalValueExpr.php create mode 100644 src/Node/Expr/PropertyInitializationExpr.php create mode 100644 src/Node/Expr/SetExistingOffsetValueTypeExpr.php create mode 100644 src/Node/Expr/SetOffsetValueTypeExpr.php create mode 100644 src/Node/Expr/TypeExpr.php create mode 100644 src/Node/Expr/UnsetOffsetExpr.php create mode 100644 src/Node/FileNode.php create mode 100644 src/Node/FinallyExitPointsNode.php create mode 100644 src/Node/FunctionCallableNode.php create mode 100644 src/Node/FunctionReturnStatementsNode.php create mode 100644 src/Node/InArrowFunctionNode.php create mode 100644 src/Node/InClassMethodNode.php create mode 100644 src/Node/InClassNode.php create mode 100644 src/Node/InClosureNode.php create mode 100644 src/Node/InForeachNode.php create mode 100644 src/Node/InFunctionNode.php create mode 100644 src/Node/InPropertyHookNode.php create mode 100644 src/Node/InTraitNode.php create mode 100644 src/Node/InstantiationCallableNode.php create mode 100644 src/Node/InvalidateExprNode.php create mode 100644 src/Node/IssetExpr.php create mode 100644 src/Node/LiteralArrayItem.php create mode 100644 src/Node/LiteralArrayNode.php create mode 100644 src/Node/MatchExpressionArm.php create mode 100644 src/Node/MatchExpressionArmBody.php create mode 100644 src/Node/MatchExpressionArmCondition.php create mode 100644 src/Node/MatchExpressionNode.php create mode 100644 src/Node/Method/MethodCall.php create mode 100644 src/Node/MethodCallableNode.php create mode 100644 src/Node/MethodReturnStatementsNode.php create mode 100644 src/Node/NoopExpressionNode.php create mode 100644 src/Node/Printer/ExprPrinter.php create mode 100644 src/Node/Printer/NodeTypePrinter.php create mode 100644 src/Node/Printer/Printer.php create mode 100644 src/Node/Property/PropertyAssign.php create mode 100644 src/Node/Property/PropertyRead.php create mode 100644 src/Node/Property/PropertyWrite.php create mode 100644 src/Node/PropertyAssignNode.php create mode 100644 src/Node/PropertyHookReturnStatementsNode.php create mode 100644 src/Node/PropertyHookStatementNode.php create mode 100644 src/Node/ReturnStatement.php create mode 100644 src/Node/ReturnStatementsNode.php create mode 100644 src/Node/StaticMethodCallableNode.php create mode 100644 src/Node/UnreachableStatementNode.php create mode 100644 src/Node/VarTagChangedExpressionTypeNode.php create mode 100644 src/Node/VariableAssignNode.php create mode 100644 src/Node/VirtualNode.php create mode 100644 src/Parallel/ParallelAnalyser.php create mode 100644 src/Parallel/Process.php create mode 100644 src/Parallel/ProcessPool.php create mode 100644 src/Parallel/ProcessTimedOutException.php create mode 100644 src/Parallel/Schedule.php create mode 100644 src/Parallel/Scheduler.php create mode 100644 src/Parser/AnonymousClassVisitor.php create mode 100644 src/Parser/ArrayFilterArgVisitor.php create mode 100644 src/Parser/ArrayFindArgVisitor.php create mode 100644 src/Parser/ArrayMapArgVisitor.php create mode 100644 src/Parser/ArrayWalkArgVisitor.php create mode 100644 src/Parser/ArrowFunctionArgVisitor.php create mode 100644 src/Parser/CachedParser.php create mode 100644 src/Parser/CleaningParser.php create mode 100644 src/Parser/CleaningVisitor.php create mode 100644 src/Parser/ClosureArgVisitor.php create mode 100644 src/Parser/ClosureBindArgVisitor.php create mode 100644 src/Parser/ClosureBindToVarVisitor.php create mode 100644 src/Parser/CurlSetOptArgVisitor.php create mode 100644 src/Parser/DeclarePositionVisitor.php create mode 100644 src/Parser/ImmediatelyInvokedClosureVisitor.php create mode 100644 src/Parser/LastConditionVisitor.php create mode 100644 src/Parser/LexerFactory.php create mode 100644 src/Parser/LineAttributesVisitor.php create mode 100644 src/Parser/MagicConstantParamDefaultVisitor.php create mode 100644 src/Parser/NewAssignedToPropertyVisitor.php create mode 100644 src/Parser/ParentStmtTypesVisitor.php create mode 100644 src/Parser/Parser.php create mode 100644 src/Parser/ParserErrorsException.php create mode 100644 src/Parser/PathRoutingParser.php create mode 100644 src/Parser/PhpParserDecorator.php create mode 100644 src/Parser/PhpParserFactory.php create mode 100644 src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php create mode 100644 src/Parser/RichParser.php create mode 100644 src/Parser/SimpleParser.php create mode 100644 src/Parser/StandaloneThrowExprVisitor.php create mode 100644 src/Parser/StubParser.php create mode 100644 src/Parser/TraitCollectingVisitor.php create mode 100644 src/Parser/TryCatchTypeVisitor.php create mode 100644 src/Parser/TypeTraverserInstanceofVisitor.php create mode 100644 src/Parser/VariadicFunctionsVisitor.php create mode 100644 src/Parser/VariadicMethodsVisitor.php create mode 100644 src/Php/ComposerPhpVersionFactory.php create mode 100644 src/Php/PhpVersion.php create mode 100644 src/Php/PhpVersionFactory.php create mode 100644 src/Php/PhpVersionFactoryFactory.php create mode 100644 src/Php/PhpVersions.php create mode 100644 src/PhpDoc/ConstExprNodeResolver.php create mode 100644 src/PhpDoc/DefaultStubFilesProvider.php create mode 100644 src/PhpDoc/DirectTypeNodeResolverExtensionRegistryProvider.php create mode 100644 src/PhpDoc/JsonValidateStubFilesExtension.php create mode 100644 src/PhpDoc/LazyTypeNodeResolverExtensionRegistryProvider.php create mode 100644 src/PhpDoc/PhpDocBlock.php create mode 100644 src/PhpDoc/PhpDocInheritanceResolver.php create mode 100644 src/PhpDoc/PhpDocNodeResolver.php create mode 100644 src/PhpDoc/PhpDocStringResolver.php create mode 100644 src/PhpDoc/ReflectionClassStubFilesExtension.php create mode 100644 src/PhpDoc/ReflectionEnumStubFilesExtension.php create mode 100644 src/PhpDoc/ResolvedPhpDocBlock.php create mode 100644 src/PhpDoc/SocketSelectStubFilesExtension.php create mode 100644 src/PhpDoc/StubFilesExtension.php create mode 100644 src/PhpDoc/StubFilesProvider.php create mode 100644 src/PhpDoc/StubPhpDocProvider.php create mode 100644 src/PhpDoc/StubSourceLocatorFactory.php create mode 100644 src/PhpDoc/StubValidator.php create mode 100644 src/PhpDoc/Tag/AssertTag.php create mode 100644 src/PhpDoc/Tag/AssertTagParameter.php create mode 100644 src/PhpDoc/Tag/DeprecatedTag.php create mode 100644 src/PhpDoc/Tag/ExtendsTag.php create mode 100644 src/PhpDoc/Tag/ImplementsTag.php create mode 100644 src/PhpDoc/Tag/MethodTag.php create mode 100644 src/PhpDoc/Tag/MethodTagParameter.php create mode 100644 src/PhpDoc/Tag/MixinTag.php create mode 100644 src/PhpDoc/Tag/ParamClosureThisTag.php create mode 100644 src/PhpDoc/Tag/ParamOutTag.php create mode 100644 src/PhpDoc/Tag/ParamTag.php create mode 100644 src/PhpDoc/Tag/PropertyTag.php create mode 100644 src/PhpDoc/Tag/RequireExtendsTag.php create mode 100644 src/PhpDoc/Tag/RequireImplementsTag.php create mode 100644 src/PhpDoc/Tag/ReturnTag.php create mode 100644 src/PhpDoc/Tag/SelfOutTypeTag.php create mode 100644 src/PhpDoc/Tag/TemplateTag.php create mode 100644 src/PhpDoc/Tag/ThrowsTag.php create mode 100644 src/PhpDoc/Tag/TypeAliasImportTag.php create mode 100644 src/PhpDoc/Tag/TypeAliasTag.php create mode 100644 src/PhpDoc/Tag/TypedTag.php create mode 100644 src/PhpDoc/Tag/UsesTag.php create mode 100644 src/PhpDoc/Tag/VarTag.php create mode 100644 src/PhpDoc/TypeNodeResolver.php create mode 100644 src/PhpDoc/TypeNodeResolverAwareExtension.php create mode 100644 src/PhpDoc/TypeNodeResolverExtension.php create mode 100644 src/PhpDoc/TypeNodeResolverExtensionAwareRegistry.php create mode 100644 src/PhpDoc/TypeNodeResolverExtensionRegistry.php create mode 100644 src/PhpDoc/TypeNodeResolverExtensionRegistryProvider.php create mode 100644 src/PhpDoc/TypeStringResolver.php create mode 100644 src/Process/CpuCoreCounter.php create mode 100644 src/Process/ProcessCanceledException.php create mode 100644 src/Process/ProcessCrashedException.php create mode 100644 src/Process/ProcessHelper.php create mode 100644 src/Process/ProcessPromise.php create mode 100644 src/Reflection/AdditionalConstructorsExtension.php create mode 100644 src/Reflection/AllowedSubTypesClassReflectionExtension.php create mode 100644 src/Reflection/Annotations/AnnotationMethodReflection.php create mode 100644 src/Reflection/Annotations/AnnotationPropertyReflection.php create mode 100644 src/Reflection/Annotations/AnnotationsMethodParameterReflection.php create mode 100644 src/Reflection/Annotations/AnnotationsMethodsClassReflectionExtension.php create mode 100644 src/Reflection/Annotations/AnnotationsPropertiesClassReflectionExtension.php create mode 100644 src/Reflection/Assertions.php create mode 100644 src/Reflection/AttributeReflection.php create mode 100644 src/Reflection/AttributeReflectionFactory.php create mode 100644 src/Reflection/BetterReflection/BetterReflectionProvider.php create mode 100644 src/Reflection/BetterReflection/BetterReflectionProviderFactory.php create mode 100644 src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php create mode 100644 src/Reflection/BetterReflection/Reflector/MemoizingReflector.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/CachingVisitor.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/ComposerJsonAndInstalledJsonSourceLocatorMaker.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/FetchedNode.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/FetchedNodesResult.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/FileNodesFetcher.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorRepository.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocatorFactory.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocatorFactory.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocatorRepository.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/PhpFileCleaner.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/PhpVersionBlacklistSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/ReflectionClassSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/RewriteClassAliasSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceLocator/SkipClassAliasSourceLocator.php create mode 100644 src/Reflection/BetterReflection/SourceStubber/PhpStormStubsSourceStubberFactory.php create mode 100644 src/Reflection/BetterReflection/SourceStubber/ReflectionSourceStubberFactory.php create mode 100644 src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php create mode 100644 src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php create mode 100644 src/Reflection/Callables/CallableParametersAcceptor.php create mode 100644 src/Reflection/Callables/FunctionCallableVariant.php create mode 100644 src/Reflection/Callables/SimpleImpurePoint.php create mode 100644 src/Reflection/Callables/SimpleThrowPoint.php create mode 100644 src/Reflection/ClassConstantReflection.php create mode 100644 src/Reflection/ClassMemberAccessAnswerer.php create mode 100644 src/Reflection/ClassMemberReflection.php create mode 100644 src/Reflection/ClassNameHelper.php create mode 100644 src/Reflection/ClassReflection.php create mode 100644 src/Reflection/ClassReflectionExtensionRegistry.php create mode 100644 src/Reflection/Constant/RuntimeConstantReflection.php create mode 100644 src/Reflection/ConstantNameHelper.php create mode 100644 src/Reflection/ConstantReflection.php create mode 100644 src/Reflection/ConstructorsHelper.php create mode 100644 src/Reflection/Dummy/ChangedTypeMethodReflection.php create mode 100644 src/Reflection/Dummy/ChangedTypePropertyReflection.php create mode 100644 src/Reflection/Dummy/DummyClassConstantReflection.php create mode 100644 src/Reflection/Dummy/DummyConstructorReflection.php create mode 100644 src/Reflection/Dummy/DummyMethodReflection.php create mode 100644 src/Reflection/Dummy/DummyPropertyReflection.php create mode 100644 src/Reflection/EnumCaseReflection.php create mode 100644 src/Reflection/ExtendedCallableFunctionVariant.php create mode 100644 src/Reflection/ExtendedFunctionVariant.php create mode 100644 src/Reflection/ExtendedMethodReflection.php create mode 100644 src/Reflection/ExtendedParameterReflection.php create mode 100644 src/Reflection/ExtendedParametersAcceptor.php create mode 100644 src/Reflection/ExtendedPropertyReflection.php create mode 100644 src/Reflection/FunctionReflection.php create mode 100644 src/Reflection/FunctionReflectionFactory.php create mode 100644 src/Reflection/FunctionVariant.php create mode 100644 src/Reflection/GenericParametersAcceptorResolver.php create mode 100644 src/Reflection/InaccessibleMethod.php create mode 100644 src/Reflection/InitializerExprContext.php create mode 100644 src/Reflection/InitializerExprTypeResolver.php create mode 100644 src/Reflection/MethodPrototypeReflection.php create mode 100644 src/Reflection/MethodReflection.php create mode 100644 src/Reflection/MethodsClassReflectionExtension.php create mode 100644 src/Reflection/MissingConstantFromReflectionException.php create mode 100644 src/Reflection/MissingMethodFromReflectionException.php create mode 100644 src/Reflection/MissingPropertyFromReflectionException.php create mode 100644 src/Reflection/Mixin/MixinMethodReflection.php create mode 100644 src/Reflection/Mixin/MixinMethodsClassReflectionExtension.php create mode 100644 src/Reflection/Mixin/MixinPropertiesClassReflectionExtension.php create mode 100644 src/Reflection/NamespaceAnswerer.php create mode 100644 src/Reflection/Native/ExtendedNativeParameterReflection.php create mode 100644 src/Reflection/Native/NativeFunctionReflection.php create mode 100644 src/Reflection/Native/NativeMethodReflection.php create mode 100644 src/Reflection/Native/NativeParameterReflection.php create mode 100644 src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php create mode 100644 src/Reflection/ParameterReflection.php create mode 100644 src/Reflection/ParametersAcceptor.php create mode 100644 src/Reflection/ParametersAcceptorSelector.php create mode 100644 src/Reflection/PassedByReference.php create mode 100644 src/Reflection/Php/ClosureCallMethodReflection.php create mode 100644 src/Reflection/Php/ClosureCallUnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Php/DummyParameter.php create mode 100644 src/Reflection/Php/EnumAllowedSubTypesClassReflectionExtension.php create mode 100644 src/Reflection/Php/EnumCasesMethodReflection.php create mode 100644 src/Reflection/Php/EnumPropertyReflection.php create mode 100644 src/Reflection/Php/EnumUnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/Php/ExitFunctionReflection.php create mode 100644 src/Reflection/Php/ExtendedDummyParameter.php create mode 100644 src/Reflection/Php/PhpClassReflectionExtension.php create mode 100644 src/Reflection/Php/PhpFunctionFromParserNodeReflection.php create mode 100644 src/Reflection/Php/PhpFunctionReflection.php create mode 100644 src/Reflection/Php/PhpMethodFromParserNodeReflection.php create mode 100644 src/Reflection/Php/PhpMethodReflection.php create mode 100644 src/Reflection/Php/PhpMethodReflectionFactory.php create mode 100644 src/Reflection/Php/PhpParameterFromParserNodeReflection.php create mode 100644 src/Reflection/Php/PhpParameterReflection.php create mode 100644 src/Reflection/Php/PhpPropertyReflection.php create mode 100644 src/Reflection/Php/SimpleXMLElementProperty.php create mode 100644 src/Reflection/Php/Soap/SoapClientMethodReflection.php create mode 100644 src/Reflection/Php/Soap/SoapClientMethodsClassReflectionExtension.php create mode 100644 src/Reflection/Php/UniversalObjectCrateProperty.php create mode 100644 src/Reflection/Php/UniversalObjectCratesClassReflectionExtension.php create mode 100644 src/Reflection/PhpVersionStaticAccessor.php create mode 100644 src/Reflection/PropertiesClassReflectionExtension.php create mode 100644 src/Reflection/PropertyReflection.php create mode 100644 src/Reflection/RealClassClassConstantReflection.php create mode 100644 src/Reflection/ReflectionProvider.php create mode 100644 src/Reflection/ReflectionProvider/DirectReflectionProviderProvider.php create mode 100644 src/Reflection/ReflectionProvider/DummyReflectionProvider.php create mode 100644 src/Reflection/ReflectionProvider/LazyReflectionProviderProvider.php create mode 100644 src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php create mode 100644 src/Reflection/ReflectionProvider/ReflectionProviderFactory.php create mode 100644 src/Reflection/ReflectionProvider/ReflectionProviderProvider.php create mode 100644 src/Reflection/ReflectionProvider/SetterReflectionProviderProvider.php create mode 100644 src/Reflection/ReflectionProviderStaticAccessor.php create mode 100644 src/Reflection/RequireExtension/RequireExtendsMethodsClassReflectionExtension.php create mode 100644 src/Reflection/RequireExtension/RequireExtendsPropertiesClassReflectionExtension.php create mode 100644 src/Reflection/ResolvedFunctionVariant.php create mode 100644 src/Reflection/ResolvedFunctionVariantWithCallable.php create mode 100644 src/Reflection/ResolvedFunctionVariantWithOriginal.php create mode 100644 src/Reflection/ResolvedMethodReflection.php create mode 100644 src/Reflection/ResolvedPropertyReflection.php create mode 100644 src/Reflection/SignatureMap/FunctionSignature.php create mode 100644 src/Reflection/SignatureMap/FunctionSignatureMapProvider.php create mode 100644 src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php create mode 100644 src/Reflection/SignatureMap/ParameterSignature.php create mode 100644 src/Reflection/SignatureMap/Php8SignatureMapProvider.php create mode 100644 src/Reflection/SignatureMap/SignatureMapParser.php create mode 100644 src/Reflection/SignatureMap/SignatureMapProvider.php create mode 100644 src/Reflection/SignatureMap/SignatureMapProviderFactory.php create mode 100644 src/Reflection/TrivialParametersAcceptor.php create mode 100644 src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Type/CallbackUnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/Type/IntersectionTypeMethodReflection.php create mode 100644 src/Reflection/Type/IntersectionTypePropertyReflection.php create mode 100644 src/Reflection/Type/IntersectionTypeUnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Type/IntersectionTypeUnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/Type/UnionTypeMethodReflection.php create mode 100644 src/Reflection/Type/UnionTypePropertyReflection.php create mode 100644 src/Reflection/Type/UnionTypeUnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Type/UnionTypeUnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/Type/UnresolvedMethodPrototypeReflection.php create mode 100644 src/Reflection/Type/UnresolvedPropertyPrototypeReflection.php create mode 100644 src/Reflection/WrappedExtendedMethodReflection.php create mode 100644 src/Reflection/WrappedExtendedPropertyReflection.php create mode 100644 src/Reflection/WrapperPropertyReflection.php create mode 100644 src/Rules/Api/ApiClassConstFetchRule.php create mode 100644 src/Rules/Api/ApiClassExtendsRule.php create mode 100644 src/Rules/Api/ApiClassImplementsRule.php create mode 100644 src/Rules/Api/ApiInstanceofRule.php create mode 100644 src/Rules/Api/ApiInstanceofTypeRule.php create mode 100644 src/Rules/Api/ApiInstantiationRule.php create mode 100644 src/Rules/Api/ApiInterfaceExtendsRule.php create mode 100644 src/Rules/Api/ApiMethodCallRule.php create mode 100644 src/Rules/Api/ApiRuleHelper.php create mode 100644 src/Rules/Api/ApiStaticCallRule.php create mode 100644 src/Rules/Api/ApiTraitUseRule.php create mode 100644 src/Rules/Api/BcUncoveredInterface.php create mode 100644 src/Rules/Api/GetTemplateTypeRule.php create mode 100644 src/Rules/Api/NodeConnectingVisitorAttributesRule.php create mode 100644 src/Rules/Api/OldPhpParser4ClassRule.php create mode 100644 src/Rules/Api/PhpStanNamespaceIn3rdPartyPackageRule.php create mode 100644 src/Rules/Api/RuntimeReflectionFunctionRule.php create mode 100644 src/Rules/Api/RuntimeReflectionInstantiationRule.php create mode 100644 src/Rules/Arrays/AllowedArrayKeysTypes.php create mode 100644 src/Rules/Arrays/ArrayDestructuringRule.php create mode 100644 src/Rules/Arrays/ArrayUnpackingRule.php create mode 100644 src/Rules/Arrays/DeadForeachRule.php create mode 100644 src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php create mode 100644 src/Rules/Arrays/InvalidKeyInArrayDimFetchRule.php create mode 100644 src/Rules/Arrays/InvalidKeyInArrayItemRule.php create mode 100644 src/Rules/Arrays/IterableInForeachRule.php create mode 100644 src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php create mode 100644 src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php create mode 100644 src/Rules/Arrays/OffsetAccessAssignOpRule.php create mode 100644 src/Rules/Arrays/OffsetAccessAssignmentRule.php create mode 100644 src/Rules/Arrays/OffsetAccessValueAssignmentRule.php create mode 100644 src/Rules/Arrays/OffsetAccessWithoutDimForReadingRule.php create mode 100644 src/Rules/Arrays/UnpackIterableInArrayRule.php create mode 100644 src/Rules/AttributesCheck.php create mode 100644 src/Rules/Cast/EchoRule.php create mode 100644 src/Rules/Cast/InvalidCastRule.php create mode 100644 src/Rules/Cast/InvalidPartOfEncapsedStringRule.php create mode 100644 src/Rules/Cast/PrintRule.php create mode 100644 src/Rules/Cast/UnsetCastRule.php create mode 100644 src/Rules/ClassCaseSensitivityCheck.php create mode 100644 src/Rules/ClassForbiddenNameCheck.php create mode 100644 src/Rules/ClassNameCheck.php create mode 100644 src/Rules/ClassNameNodePair.php create mode 100644 src/Rules/Classes/AccessPrivateConstantThroughStaticRule.php create mode 100644 src/Rules/Classes/AllowedSubTypesRule.php create mode 100644 src/Rules/Classes/ClassAttributesRule.php create mode 100644 src/Rules/Classes/ClassConstantAttributesRule.php create mode 100644 src/Rules/Classes/ClassConstantRule.php create mode 100644 src/Rules/Classes/DuplicateClassDeclarationRule.php create mode 100644 src/Rules/Classes/DuplicateDeclarationRule.php create mode 100644 src/Rules/Classes/EnumSanityRule.php create mode 100644 src/Rules/Classes/ExistingClassInClassExtendsRule.php create mode 100644 src/Rules/Classes/ExistingClassInInstanceOfRule.php create mode 100644 src/Rules/Classes/ExistingClassInTraitUseRule.php create mode 100644 src/Rules/Classes/ExistingClassesInClassImplementsRule.php create mode 100644 src/Rules/Classes/ExistingClassesInEnumImplementsRule.php create mode 100644 src/Rules/Classes/ExistingClassesInInterfaceExtendsRule.php create mode 100644 src/Rules/Classes/ImpossibleInstanceOfRule.php create mode 100644 src/Rules/Classes/InstantiationCallableRule.php create mode 100644 src/Rules/Classes/InstantiationRule.php create mode 100644 src/Rules/Classes/InvalidPromotedPropertiesRule.php create mode 100644 src/Rules/Classes/LocalTypeAliasesCheck.php create mode 100644 src/Rules/Classes/LocalTypeAliasesRule.php create mode 100644 src/Rules/Classes/LocalTypeTraitAliasesRule.php create mode 100644 src/Rules/Classes/LocalTypeTraitUseAliasesRule.php create mode 100644 src/Rules/Classes/MethodTagCheck.php create mode 100644 src/Rules/Classes/MethodTagRule.php create mode 100644 src/Rules/Classes/MethodTagTraitRule.php create mode 100644 src/Rules/Classes/MethodTagTraitUseRule.php create mode 100644 src/Rules/Classes/MixinCheck.php create mode 100644 src/Rules/Classes/MixinRule.php create mode 100644 src/Rules/Classes/MixinTraitRule.php create mode 100644 src/Rules/Classes/MixinTraitUseRule.php create mode 100644 src/Rules/Classes/NewStaticRule.php create mode 100644 src/Rules/Classes/NonClassAttributeClassRule.php create mode 100644 src/Rules/Classes/PropertyTagCheck.php create mode 100644 src/Rules/Classes/PropertyTagRule.php create mode 100644 src/Rules/Classes/PropertyTagTraitRule.php create mode 100644 src/Rules/Classes/PropertyTagTraitUseRule.php create mode 100644 src/Rules/Classes/ReadOnlyClassRule.php create mode 100644 src/Rules/Classes/RequireExtendsRule.php create mode 100644 src/Rules/Classes/RequireImplementsRule.php create mode 100644 src/Rules/Classes/TraitAttributeClassRule.php create mode 100644 src/Rules/Classes/UnusedConstructorParametersRule.php create mode 100644 src/Rules/Comparison/BooleanAndConstantConditionRule.php create mode 100644 src/Rules/Comparison/BooleanNotConstantConditionRule.php create mode 100644 src/Rules/Comparison/BooleanOrConstantConditionRule.php create mode 100644 src/Rules/Comparison/ConstantConditionRuleHelper.php create mode 100644 src/Rules/Comparison/ConstantLooseComparisonRule.php create mode 100644 src/Rules/Comparison/DoWhileLoopConstantConditionRule.php create mode 100644 src/Rules/Comparison/ElseIfConstantConditionRule.php create mode 100644 src/Rules/Comparison/IfConstantConditionRule.php create mode 100644 src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php create mode 100644 src/Rules/Comparison/ImpossibleCheckTypeHelper.php create mode 100644 src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php create mode 100644 src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php create mode 100644 src/Rules/Comparison/LogicalXorConstantConditionRule.php create mode 100644 src/Rules/Comparison/MatchExpressionRule.php create mode 100644 src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php create mode 100644 src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php create mode 100644 src/Rules/Comparison/TernaryOperatorConstantConditionRule.php create mode 100644 src/Rules/Comparison/UsageOfVoidMatchExpressionRule.php create mode 100644 src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php create mode 100644 src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php create mode 100644 src/Rules/Constants/AlwaysUsedClassConstantsExtension.php create mode 100644 src/Rules/Constants/AlwaysUsedClassConstantsExtensionProvider.php create mode 100644 src/Rules/Constants/ClassAsClassConstantRule.php create mode 100644 src/Rules/Constants/ConstantRule.php create mode 100644 src/Rules/Constants/DynamicClassConstantFetchRule.php create mode 100644 src/Rules/Constants/FinalConstantRule.php create mode 100644 src/Rules/Constants/LazyAlwaysUsedClassConstantsExtensionProvider.php create mode 100644 src/Rules/Constants/MagicConstantContextRule.php create mode 100644 src/Rules/Constants/MissingClassConstantTypehintRule.php create mode 100644 src/Rules/Constants/NativeTypedClassConstantRule.php create mode 100644 src/Rules/Constants/OverridingConstantRule.php create mode 100644 src/Rules/Constants/ValueAssignedToClassConstantRule.php create mode 100644 src/Rules/DateTimeInstantiationRule.php create mode 100644 src/Rules/DeadCode/CallToConstructorStatementWithoutImpurePointsRule.php create mode 100644 src/Rules/DeadCode/CallToFunctionStatementWithoutImpurePointsRule.php create mode 100644 src/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRule.php create mode 100644 src/Rules/DeadCode/CallToStaticMethodStatementWithoutImpurePointsRule.php create mode 100644 src/Rules/DeadCode/ConstructorWithoutImpurePointsCollector.php create mode 100644 src/Rules/DeadCode/FunctionWithoutImpurePointsCollector.php create mode 100644 src/Rules/DeadCode/MethodWithoutImpurePointsCollector.php create mode 100644 src/Rules/DeadCode/NoopRule.php create mode 100644 src/Rules/DeadCode/PossiblyPureFuncCallCollector.php create mode 100644 src/Rules/DeadCode/PossiblyPureMethodCallCollector.php create mode 100644 src/Rules/DeadCode/PossiblyPureNewCollector.php create mode 100644 src/Rules/DeadCode/PossiblyPureStaticCallCollector.php create mode 100644 src/Rules/DeadCode/UnreachableStatementRule.php create mode 100644 src/Rules/DeadCode/UnusedPrivateConstantRule.php create mode 100644 src/Rules/DeadCode/UnusedPrivateMethodRule.php create mode 100644 src/Rules/DeadCode/UnusedPrivatePropertyRule.php create mode 100644 src/Rules/Debug/DebugScopeRule.php create mode 100644 src/Rules/Debug/DumpPhpDocTypeRule.php create mode 100644 src/Rules/Debug/DumpTypeRule.php create mode 100644 src/Rules/Debug/FileAssertRule.php create mode 100644 src/Rules/DirectRegistry.php create mode 100644 src/Rules/EnumCases/EnumCaseAttributesRule.php create mode 100644 src/Rules/Exceptions/CatchWithUnthrownExceptionRule.php create mode 100644 src/Rules/Exceptions/CaughtExceptionExistenceRule.php create mode 100644 src/Rules/Exceptions/DefaultExceptionTypeResolver.php create mode 100644 src/Rules/Exceptions/ExceptionTypeResolver.php create mode 100644 src/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRule.php create mode 100644 src/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRule.php create mode 100644 src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php create mode 100644 src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php create mode 100644 src/Rules/Exceptions/NoncapturingCatchRule.php create mode 100644 src/Rules/Exceptions/OverwrittenExitPointByFinallyRule.php create mode 100644 src/Rules/Exceptions/ThrowExprTypeRule.php create mode 100644 src/Rules/Exceptions/ThrowExpressionRule.php create mode 100644 src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php create mode 100644 src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php create mode 100644 src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php create mode 100644 src/Rules/Exceptions/TooWideFunctionThrowTypeRule.php create mode 100644 src/Rules/Exceptions/TooWideMethodThrowTypeRule.php create mode 100644 src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php create mode 100644 src/Rules/Exceptions/TooWideThrowTypeCheck.php create mode 100644 src/Rules/FileRuleError.php create mode 100644 src/Rules/FoundTypeResult.php create mode 100644 src/Rules/FunctionCallParametersCheck.php create mode 100644 src/Rules/FunctionDefinitionCheck.php create mode 100644 src/Rules/FunctionReturnTypeCheck.php create mode 100644 src/Rules/Functions/ArrayFilterRule.php create mode 100644 src/Rules/Functions/ArrayValuesRule.php create mode 100644 src/Rules/Functions/ArrowFunctionAttributesRule.php create mode 100644 src/Rules/Functions/ArrowFunctionReturnNullsafeByRefRule.php create mode 100644 src/Rules/Functions/ArrowFunctionReturnTypeRule.php create mode 100644 src/Rules/Functions/CallCallablesRule.php create mode 100644 src/Rules/Functions/CallToFunctionParametersRule.php create mode 100644 src/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRule.php create mode 100644 src/Rules/Functions/CallToNonExistentFunctionRule.php create mode 100644 src/Rules/Functions/CallUserFuncRule.php create mode 100644 src/Rules/Functions/ClosureAttributesRule.php create mode 100644 src/Rules/Functions/ClosureReturnTypeRule.php create mode 100644 src/Rules/Functions/DefineParametersRule.php create mode 100644 src/Rules/Functions/DuplicateFunctionDeclarationRule.php create mode 100644 src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.php create mode 100644 src/Rules/Functions/ExistingClassesInClosureTypehintsRule.php create mode 100644 src/Rules/Functions/ExistingClassesInTypehintsRule.php create mode 100644 src/Rules/Functions/FunctionAttributesRule.php create mode 100644 src/Rules/Functions/FunctionCallableRule.php create mode 100644 src/Rules/Functions/ImplodeParameterCastableToStringRule.php create mode 100644 src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php create mode 100644 src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php create mode 100644 src/Rules/Functions/IncompatibleDefaultParameterTypeRule.php create mode 100644 src/Rules/Functions/InnerFunctionRule.php create mode 100644 src/Rules/Functions/InvalidLexicalVariablesInClosureUseRule.php create mode 100644 src/Rules/Functions/MissingFunctionParameterTypehintRule.php create mode 100644 src/Rules/Functions/MissingFunctionReturnTypehintRule.php create mode 100644 src/Rules/Functions/ParamAttributesRule.php create mode 100644 src/Rules/Functions/ParameterCastableToNumberRule.php create mode 100644 src/Rules/Functions/ParameterCastableToStringRule.php create mode 100644 src/Rules/Functions/PrintfArrayParametersRule.php create mode 100644 src/Rules/Functions/PrintfHelper.php create mode 100644 src/Rules/Functions/PrintfParametersRule.php create mode 100644 src/Rules/Functions/RandomIntParametersRule.php create mode 100644 src/Rules/Functions/RedefinedParametersRule.php create mode 100644 src/Rules/Functions/ReturnNullsafeByRefRule.php create mode 100644 src/Rules/Functions/ReturnTypeRule.php create mode 100644 src/Rules/Functions/SortParameterCastableToStringRule.php create mode 100644 src/Rules/Functions/UnusedClosureUsesRule.php create mode 100644 src/Rules/Functions/UselessFunctionReturnValueRule.php create mode 100644 src/Rules/Functions/VariadicParametersDeclarationRule.php create mode 100644 src/Rules/Generators/YieldFromTypeRule.php create mode 100644 src/Rules/Generators/YieldInGeneratorRule.php create mode 100644 src/Rules/Generators/YieldTypeRule.php create mode 100644 src/Rules/Generics/ClassAncestorsRule.php create mode 100644 src/Rules/Generics/ClassTemplateTypeRule.php create mode 100644 src/Rules/Generics/CrossCheckInterfacesHelper.php create mode 100644 src/Rules/Generics/EnumAncestorsRule.php create mode 100644 src/Rules/Generics/EnumTemplateTypeRule.php create mode 100644 src/Rules/Generics/FunctionSignatureVarianceRule.php create mode 100644 src/Rules/Generics/FunctionTemplateTypeRule.php create mode 100644 src/Rules/Generics/GenericAncestorsCheck.php create mode 100644 src/Rules/Generics/GenericObjectTypeCheck.php create mode 100644 src/Rules/Generics/InterfaceAncestorsRule.php create mode 100644 src/Rules/Generics/InterfaceTemplateTypeRule.php create mode 100644 src/Rules/Generics/MethodSignatureVarianceRule.php create mode 100644 src/Rules/Generics/MethodTagTemplateTypeCheck.php create mode 100644 src/Rules/Generics/MethodTagTemplateTypeRule.php create mode 100644 src/Rules/Generics/MethodTagTemplateTypeTraitRule.php create mode 100644 src/Rules/Generics/MethodTemplateTypeRule.php create mode 100644 src/Rules/Generics/PropertyVarianceRule.php create mode 100644 src/Rules/Generics/TemplateTypeCheck.php create mode 100644 src/Rules/Generics/TraitTemplateTypeRule.php create mode 100644 src/Rules/Generics/UsedTraitsRule.php create mode 100644 src/Rules/Generics/VarianceCheck.php create mode 100644 src/Rules/IdentifierRuleError.php create mode 100644 src/Rules/Ignore/IgnoreParseErrorRule.php create mode 100644 src/Rules/IssetCheck.php create mode 100644 src/Rules/Keywords/ContinueBreakInLoopRule.php create mode 100644 src/Rules/Keywords/DeclareStrictTypesRule.php create mode 100644 src/Rules/Keywords/RequireFileExistsRule.php create mode 100644 src/Rules/LazyRegistry.php create mode 100644 src/Rules/LineRuleError.php create mode 100644 src/Rules/MetadataRuleError.php create mode 100644 src/Rules/Methods/AbstractMethodInNonAbstractClassRule.php create mode 100644 src/Rules/Methods/AbstractPrivateMethodRule.php create mode 100644 src/Rules/Methods/AlwaysUsedMethodExtension.php create mode 100644 src/Rules/Methods/AlwaysUsedMethodExtensionProvider.php create mode 100644 src/Rules/Methods/CallMethodsRule.php create mode 100644 src/Rules/Methods/CallPrivateMethodThroughStaticRule.php create mode 100644 src/Rules/Methods/CallStaticMethodsRule.php create mode 100644 src/Rules/Methods/CallToConstructorStatementWithoutSideEffectsRule.php create mode 100644 src/Rules/Methods/CallToMethodStatementWithoutSideEffectsRule.php create mode 100644 src/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRule.php create mode 100644 src/Rules/Methods/ConsistentConstructorRule.php create mode 100644 src/Rules/Methods/ConstructorReturnTypeRule.php create mode 100644 src/Rules/Methods/DirectAlwaysUsedMethodExtensionProvider.php create mode 100644 src/Rules/Methods/ExistingClassesInTypehintsRule.php create mode 100644 src/Rules/Methods/FinalPrivateMethodRule.php create mode 100644 src/Rules/Methods/IncompatibleDefaultParameterTypeRule.php create mode 100644 src/Rules/Methods/LazyAlwaysUsedMethodExtensionProvider.php create mode 100644 src/Rules/Methods/MethodAttributesRule.php create mode 100644 src/Rules/Methods/MethodCallCheck.php create mode 100644 src/Rules/Methods/MethodCallableRule.php create mode 100644 src/Rules/Methods/MethodParameterComparisonHelper.php create mode 100644 src/Rules/Methods/MethodSignatureRule.php create mode 100644 src/Rules/Methods/MethodVisibilityComparisonHelper.php create mode 100644 src/Rules/Methods/MethodVisibilityInInterfaceRule.php create mode 100644 src/Rules/Methods/MissingMagicSerializationMethodsRule.php create mode 100644 src/Rules/Methods/MissingMethodImplementationRule.php create mode 100644 src/Rules/Methods/MissingMethodParameterTypehintRule.php create mode 100644 src/Rules/Methods/MissingMethodReturnTypehintRule.php create mode 100644 src/Rules/Methods/MissingMethodSelfOutTypeRule.php create mode 100644 src/Rules/Methods/NullsafeMethodCallRule.php create mode 100644 src/Rules/Methods/OverridingMethodRule.php create mode 100644 src/Rules/Methods/ReturnTypeRule.php create mode 100644 src/Rules/Methods/StaticMethodCallCheck.php create mode 100644 src/Rules/Methods/StaticMethodCallableRule.php create mode 100644 src/Rules/Missing/MissingReturnRule.php create mode 100644 src/Rules/MissingTypehintCheck.php create mode 100644 src/Rules/Names/UsedNamesRule.php create mode 100644 src/Rules/Namespaces/ExistingNamesInGroupUseRule.php create mode 100644 src/Rules/Namespaces/ExistingNamesInUseRule.php create mode 100644 src/Rules/NonIgnorableRuleError.php create mode 100644 src/Rules/NullsafeCheck.php create mode 100644 src/Rules/Operators/InvalidAssignVarRule.php create mode 100644 src/Rules/Operators/InvalidBinaryOperationRule.php create mode 100644 src/Rules/Operators/InvalidComparisonOperationRule.php create mode 100644 src/Rules/Operators/InvalidIncDecOperationRule.php create mode 100644 src/Rules/Operators/InvalidUnaryOperationRule.php create mode 100644 src/Rules/ParameterCastableToStringCheck.php create mode 100644 src/Rules/PhpDoc/AssertRuleHelper.php create mode 100644 src/Rules/PhpDoc/ConditionalReturnTypeRuleHelper.php create mode 100644 src/Rules/PhpDoc/FunctionAssertRule.php create mode 100644 src/Rules/PhpDoc/FunctionConditionalReturnTypeRule.php create mode 100644 src/Rules/PhpDoc/GenericCallableRuleHelper.php create mode 100644 src/Rules/PhpDoc/IncompatibleClassConstantPhpDocTypeRule.php create mode 100644 src/Rules/PhpDoc/IncompatibleParamImmediatelyInvokedCallableRule.php create mode 100644 src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php create mode 100644 src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php create mode 100644 src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php create mode 100644 src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php create mode 100644 src/Rules/PhpDoc/IncompatibleSelfOutTypeRule.php create mode 100644 src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php create mode 100644 src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php create mode 100644 src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php create mode 100644 src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php create mode 100644 src/Rules/PhpDoc/MethodAssertRule.php create mode 100644 src/Rules/PhpDoc/MethodConditionalReturnTypeRule.php create mode 100644 src/Rules/PhpDoc/PhpDocLineHelper.php create mode 100644 src/Rules/PhpDoc/RequireExtendsCheck.php create mode 100644 src/Rules/PhpDoc/RequireExtendsDefinitionClassRule.php create mode 100644 src/Rules/PhpDoc/RequireExtendsDefinitionTraitRule.php create mode 100644 src/Rules/PhpDoc/RequireImplementsDefinitionClassRule.php create mode 100644 src/Rules/PhpDoc/RequireImplementsDefinitionTraitRule.php create mode 100644 src/Rules/PhpDoc/UnresolvableTypeHelper.php create mode 100644 src/Rules/PhpDoc/VarTagChangedExpressionTypeRule.php create mode 100644 src/Rules/PhpDoc/VarTagTypeRuleHelper.php create mode 100644 src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php create mode 100644 src/Rules/Playground/FunctionNeverRule.php create mode 100644 src/Rules/Playground/MethodNeverRule.php create mode 100644 src/Rules/Playground/NeverRuleHelper.php create mode 100644 src/Rules/Playground/NoPhpCodeRule.php create mode 100644 src/Rules/Playground/NotAnalysedTraitRule.php create mode 100644 src/Rules/Playground/PromoteParameterRule.php create mode 100644 src/Rules/Playground/StaticVarWithoutTypeRule.php create mode 100644 src/Rules/Properties/AccessPrivatePropertyThroughStaticRule.php create mode 100644 src/Rules/Properties/AccessPropertiesCheck.php create mode 100644 src/Rules/Properties/AccessPropertiesInAssignRule.php create mode 100644 src/Rules/Properties/AccessPropertiesRule.php create mode 100644 src/Rules/Properties/AccessStaticPropertiesInAssignRule.php create mode 100644 src/Rules/Properties/AccessStaticPropertiesRule.php create mode 100644 src/Rules/Properties/DefaultValueTypesAssignedToPropertiesRule.php create mode 100644 src/Rules/Properties/DirectReadWritePropertiesExtensionProvider.php create mode 100644 src/Rules/Properties/ExistingClassesInPropertiesRule.php create mode 100644 src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php create mode 100644 src/Rules/Properties/FoundPropertyReflection.php create mode 100644 src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php create mode 100644 src/Rules/Properties/InvalidCallablePropertyTypeRule.php create mode 100644 src/Rules/Properties/LazyReadWritePropertiesExtensionProvider.php create mode 100644 src/Rules/Properties/MissingPropertyTypehintRule.php create mode 100644 src/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRule.php create mode 100644 src/Rules/Properties/MissingReadOnlyPropertyAssignRule.php create mode 100644 src/Rules/Properties/NullsafePropertyFetchRule.php create mode 100644 src/Rules/Properties/OverridingPropertyRule.php create mode 100644 src/Rules/Properties/PropertiesInInterfaceRule.php create mode 100644 src/Rules/Properties/PropertyAssignRefRule.php create mode 100644 src/Rules/Properties/PropertyAttributesRule.php create mode 100644 src/Rules/Properties/PropertyDescriptor.php create mode 100644 src/Rules/Properties/PropertyHookAttributesRule.php create mode 100644 src/Rules/Properties/PropertyInClassRule.php create mode 100644 src/Rules/Properties/PropertyReflectionFinder.php create mode 100644 src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRefRule.php create mode 100644 src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php create mode 100644 src/Rules/Properties/ReadOnlyByPhpDocPropertyRule.php create mode 100644 src/Rules/Properties/ReadOnlyPropertyAssignRefRule.php create mode 100644 src/Rules/Properties/ReadOnlyPropertyAssignRule.php create mode 100644 src/Rules/Properties/ReadOnlyPropertyRule.php create mode 100644 src/Rules/Properties/ReadWritePropertiesExtension.php create mode 100644 src/Rules/Properties/ReadWritePropertiesExtensionProvider.php create mode 100644 src/Rules/Properties/ReadingWriteOnlyPropertiesRule.php create mode 100644 src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php create mode 100644 src/Rules/Properties/SetPropertyHookParameterRule.php create mode 100644 src/Rules/Properties/TypesAssignedToPropertiesRule.php create mode 100644 src/Rules/Properties/UninitializedPropertyRule.php create mode 100644 src/Rules/Properties/WritingToReadOnlyPropertiesRule.php create mode 100644 src/Rules/Pure/FunctionPurityCheck.php create mode 100644 src/Rules/Pure/PureFunctionRule.php create mode 100644 src/Rules/Pure/PureMethodRule.php create mode 100644 src/Rules/Regexp/RegularExpressionPatternRule.php create mode 100644 src/Rules/Regexp/RegularExpressionQuotingRule.php create mode 100644 src/Rules/Registry.php create mode 100644 src/Rules/Rule.php create mode 100644 src/Rules/RuleError.php create mode 100644 src/Rules/RuleErrorBuilder.php create mode 100644 src/Rules/RuleErrors/RuleError1.php create mode 100644 src/Rules/RuleErrors/RuleError101.php create mode 100644 src/Rules/RuleErrors/RuleError103.php create mode 100644 src/Rules/RuleErrors/RuleError105.php create mode 100644 src/Rules/RuleErrors/RuleError107.php create mode 100644 src/Rules/RuleErrors/RuleError109.php create mode 100644 src/Rules/RuleErrors/RuleError11.php create mode 100644 src/Rules/RuleErrors/RuleError111.php create mode 100644 src/Rules/RuleErrors/RuleError113.php create mode 100644 src/Rules/RuleErrors/RuleError115.php create mode 100644 src/Rules/RuleErrors/RuleError117.php create mode 100644 src/Rules/RuleErrors/RuleError119.php create mode 100644 src/Rules/RuleErrors/RuleError121.php create mode 100644 src/Rules/RuleErrors/RuleError123.php create mode 100644 src/Rules/RuleErrors/RuleError125.php create mode 100644 src/Rules/RuleErrors/RuleError127.php create mode 100644 src/Rules/RuleErrors/RuleError13.php create mode 100644 src/Rules/RuleErrors/RuleError15.php create mode 100644 src/Rules/RuleErrors/RuleError17.php create mode 100644 src/Rules/RuleErrors/RuleError19.php create mode 100644 src/Rules/RuleErrors/RuleError21.php create mode 100644 src/Rules/RuleErrors/RuleError23.php create mode 100644 src/Rules/RuleErrors/RuleError25.php create mode 100644 src/Rules/RuleErrors/RuleError27.php create mode 100644 src/Rules/RuleErrors/RuleError29.php create mode 100644 src/Rules/RuleErrors/RuleError3.php create mode 100644 src/Rules/RuleErrors/RuleError31.php create mode 100644 src/Rules/RuleErrors/RuleError33.php create mode 100644 src/Rules/RuleErrors/RuleError35.php create mode 100644 src/Rules/RuleErrors/RuleError37.php create mode 100644 src/Rules/RuleErrors/RuleError39.php create mode 100644 src/Rules/RuleErrors/RuleError41.php create mode 100644 src/Rules/RuleErrors/RuleError43.php create mode 100644 src/Rules/RuleErrors/RuleError45.php create mode 100644 src/Rules/RuleErrors/RuleError47.php create mode 100644 src/Rules/RuleErrors/RuleError49.php create mode 100644 src/Rules/RuleErrors/RuleError5.php create mode 100644 src/Rules/RuleErrors/RuleError51.php create mode 100644 src/Rules/RuleErrors/RuleError53.php create mode 100644 src/Rules/RuleErrors/RuleError55.php create mode 100644 src/Rules/RuleErrors/RuleError57.php create mode 100644 src/Rules/RuleErrors/RuleError59.php create mode 100644 src/Rules/RuleErrors/RuleError61.php create mode 100644 src/Rules/RuleErrors/RuleError63.php create mode 100644 src/Rules/RuleErrors/RuleError65.php create mode 100644 src/Rules/RuleErrors/RuleError67.php create mode 100644 src/Rules/RuleErrors/RuleError69.php create mode 100644 src/Rules/RuleErrors/RuleError7.php create mode 100644 src/Rules/RuleErrors/RuleError71.php create mode 100644 src/Rules/RuleErrors/RuleError73.php create mode 100644 src/Rules/RuleErrors/RuleError75.php create mode 100644 src/Rules/RuleErrors/RuleError77.php create mode 100644 src/Rules/RuleErrors/RuleError79.php create mode 100644 src/Rules/RuleErrors/RuleError81.php create mode 100644 src/Rules/RuleErrors/RuleError83.php create mode 100644 src/Rules/RuleErrors/RuleError85.php create mode 100644 src/Rules/RuleErrors/RuleError87.php create mode 100644 src/Rules/RuleErrors/RuleError89.php create mode 100644 src/Rules/RuleErrors/RuleError9.php create mode 100644 src/Rules/RuleErrors/RuleError91.php create mode 100644 src/Rules/RuleErrors/RuleError93.php create mode 100644 src/Rules/RuleErrors/RuleError95.php create mode 100644 src/Rules/RuleErrors/RuleError97.php create mode 100644 src/Rules/RuleErrors/RuleError99.php create mode 100644 src/Rules/RuleLevelHelper.php create mode 100644 src/Rules/RuleLevelHelperAcceptsResult.php create mode 100644 src/Rules/TipRuleError.php create mode 100644 src/Rules/TooWideTypehints/TooWideArrowFunctionReturnTypehintRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideClosureReturnTypehintRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideFunctionParameterOutTypeRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideFunctionReturnTypehintRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideMethodParameterOutTypeRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideMethodReturnTypehintRule.php create mode 100644 src/Rules/TooWideTypehints/TooWideParameterOutTypeCheck.php create mode 100644 src/Rules/TooWideTypehints/TooWidePropertyTypeRule.php create mode 100644 src/Rules/Traits/ConflictingTraitConstantsRule.php create mode 100644 src/Rules/Traits/ConstantsInTraitsRule.php create mode 100644 src/Rules/Traits/NotAnalysedTraitRule.php create mode 100644 src/Rules/Traits/TraitAttributesRule.php create mode 100644 src/Rules/Traits/TraitDeclarationCollector.php create mode 100644 src/Rules/Traits/TraitUseCollector.php create mode 100644 src/Rules/Types/InvalidTypesInUnionRule.php create mode 100644 src/Rules/UnusedFunctionParametersCheck.php create mode 100644 src/Rules/Variables/CompactVariablesRule.php create mode 100644 src/Rules/Variables/DefinedVariableRule.php create mode 100644 src/Rules/Variables/EmptyRule.php create mode 100644 src/Rules/Variables/IssetRule.php create mode 100644 src/Rules/Variables/NullCoalesceRule.php create mode 100644 src/Rules/Variables/ParameterOutAssignedTypeRule.php create mode 100644 src/Rules/Variables/ParameterOutExecutionEndTypeRule.php create mode 100644 src/Rules/Variables/UnsetRule.php create mode 100644 src/Rules/Variables/VariableCloningRule.php create mode 100644 src/Rules/Whitespace/FileWhitespaceRule.php create mode 100644 src/ShouldNotHappenException.php create mode 100644 src/Testing/ErrorFormatterTestCase.php create mode 100644 src/Testing/LevelsTestCase.php create mode 100644 src/Testing/PHPStanTestCase.php create mode 100644 src/Testing/RuleTestCase.php create mode 100644 src/Testing/TestCaseSourceLocatorFactory.php create mode 100644 src/Testing/TypeInferenceTestCase.php create mode 100644 src/Testing/functions.php create mode 100644 src/TrinaryLogic.php create mode 100644 src/Type/AcceptsResult.php create mode 100644 src/Type/Accessory/AccessoryArrayListType.php create mode 100644 src/Type/Accessory/AccessoryLiteralStringType.php create mode 100644 src/Type/Accessory/AccessoryLowercaseStringType.php create mode 100644 src/Type/Accessory/AccessoryNonEmptyStringType.php create mode 100644 src/Type/Accessory/AccessoryNonFalsyStringType.php create mode 100644 src/Type/Accessory/AccessoryNumericStringType.php create mode 100644 src/Type/Accessory/AccessoryType.php create mode 100644 src/Type/Accessory/AccessoryUppercaseStringType.php create mode 100644 src/Type/Accessory/HasMethodType.php create mode 100644 src/Type/Accessory/HasOffsetType.php create mode 100644 src/Type/Accessory/HasOffsetValueType.php create mode 100644 src/Type/Accessory/HasPropertyType.php create mode 100644 src/Type/Accessory/NonEmptyArrayType.php create mode 100644 src/Type/Accessory/OversizedArrayType.php create mode 100644 src/Type/ArrayType.php create mode 100644 src/Type/BenevolentUnionType.php create mode 100644 src/Type/BitwiseFlagHelper.php create mode 100644 src/Type/BooleanType.php create mode 100644 src/Type/CallableType.php create mode 100644 src/Type/CallableTypeHelper.php create mode 100644 src/Type/CircularTypeAliasDefinitionException.php create mode 100644 src/Type/CircularTypeAliasErrorType.php create mode 100644 src/Type/ClassStringType.php create mode 100644 src/Type/ClosureType.php create mode 100644 src/Type/ClosureTypeFactory.php create mode 100644 src/Type/CompoundType.php create mode 100644 src/Type/ConditionalType.php create mode 100644 src/Type/ConditionalTypeForParameter.php create mode 100644 src/Type/Constant/ConstantArrayType.php create mode 100644 src/Type/Constant/ConstantArrayTypeAndMethod.php create mode 100644 src/Type/Constant/ConstantArrayTypeBuilder.php create mode 100644 src/Type/Constant/ConstantBooleanType.php create mode 100644 src/Type/Constant/ConstantFloatType.php create mode 100644 src/Type/Constant/ConstantIntegerType.php create mode 100644 src/Type/Constant/ConstantScalarToBooleanTrait.php create mode 100644 src/Type/Constant/ConstantStringType.php create mode 100644 src/Type/Constant/OversizedArrayBuilder.php create mode 100644 src/Type/ConstantScalarType.php create mode 100644 src/Type/ConstantTypeHelper.php create mode 100644 src/Type/DirectTypeAliasResolverProvider.php create mode 100644 src/Type/DynamicFunctionReturnTypeExtension.php create mode 100644 src/Type/DynamicFunctionThrowTypeExtension.php create mode 100644 src/Type/DynamicMethodReturnTypeExtension.php create mode 100644 src/Type/DynamicMethodThrowTypeExtension.php create mode 100644 src/Type/DynamicReturnTypeExtensionRegistry.php create mode 100644 src/Type/DynamicStaticMethodReturnTypeExtension.php create mode 100644 src/Type/DynamicStaticMethodThrowTypeExtension.php create mode 100644 src/Type/Enum/EnumCaseObjectType.php create mode 100644 src/Type/ErrorType.php create mode 100644 src/Type/ExponentiateHelper.php create mode 100644 src/Type/ExpressionTypeResolverExtension.php create mode 100644 src/Type/ExpressionTypeResolverExtensionRegistry.php create mode 100644 src/Type/FileTypeMapper.php create mode 100644 src/Type/FloatType.php create mode 100644 src/Type/FunctionParameterClosureTypeExtension.php create mode 100644 src/Type/FunctionParameterOutTypeExtension.php create mode 100644 src/Type/FunctionTypeSpecifyingExtension.php create mode 100644 src/Type/GeneralizePrecision.php create mode 100644 src/Type/Generic/GenericClassStringType.php create mode 100644 src/Type/Generic/GenericObjectType.php create mode 100644 src/Type/Generic/GenericStaticType.php create mode 100644 src/Type/Generic/TemplateArrayType.php create mode 100644 src/Type/Generic/TemplateBenevolentUnionType.php create mode 100644 src/Type/Generic/TemplateBooleanType.php create mode 100644 src/Type/Generic/TemplateConstantArrayType.php create mode 100644 src/Type/Generic/TemplateConstantIntegerType.php create mode 100644 src/Type/Generic/TemplateConstantStringType.php create mode 100644 src/Type/Generic/TemplateFloatType.php create mode 100644 src/Type/Generic/TemplateGenericObjectType.php create mode 100644 src/Type/Generic/TemplateIntegerType.php create mode 100644 src/Type/Generic/TemplateIntersectionType.php create mode 100644 src/Type/Generic/TemplateKeyOfType.php create mode 100644 src/Type/Generic/TemplateMixedType.php create mode 100644 src/Type/Generic/TemplateObjectShapeType.php create mode 100644 src/Type/Generic/TemplateObjectType.php create mode 100644 src/Type/Generic/TemplateObjectWithoutClassType.php create mode 100644 src/Type/Generic/TemplateStrictMixedType.php create mode 100644 src/Type/Generic/TemplateStringType.php create mode 100644 src/Type/Generic/TemplateType.php create mode 100644 src/Type/Generic/TemplateTypeArgumentStrategy.php create mode 100644 src/Type/Generic/TemplateTypeFactory.php create mode 100644 src/Type/Generic/TemplateTypeHelper.php create mode 100644 src/Type/Generic/TemplateTypeMap.php create mode 100644 src/Type/Generic/TemplateTypeParameterStrategy.php create mode 100644 src/Type/Generic/TemplateTypeReference.php create mode 100644 src/Type/Generic/TemplateTypeScope.php create mode 100644 src/Type/Generic/TemplateTypeStrategy.php create mode 100644 src/Type/Generic/TemplateTypeTrait.php create mode 100644 src/Type/Generic/TemplateTypeVariance.php create mode 100644 src/Type/Generic/TemplateTypeVarianceMap.php create mode 100644 src/Type/Generic/TemplateUnionType.php create mode 100644 src/Type/Generic/TypeProjectionHelper.php create mode 100644 src/Type/Helper/GetTemplateTypeType.php create mode 100644 src/Type/IntegerRangeType.php create mode 100644 src/Type/IntegerType.php create mode 100644 src/Type/IntersectionType.php create mode 100644 src/Type/IsSuperTypeOfResult.php create mode 100644 src/Type/IterableType.php create mode 100644 src/Type/JustNullableTypeTrait.php create mode 100644 src/Type/KeyOfType.php create mode 100644 src/Type/LateResolvableType.php create mode 100644 src/Type/LazyTypeAliasResolverProvider.php create mode 100644 src/Type/LooseComparisonHelper.php create mode 100644 src/Type/MethodParameterClosureTypeExtension.php create mode 100644 src/Type/MethodParameterOutTypeExtension.php create mode 100644 src/Type/MethodTypeSpecifyingExtension.php create mode 100644 src/Type/MixedType.php create mode 100644 src/Type/NeverType.php create mode 100644 src/Type/NewObjectType.php create mode 100644 src/Type/NonAcceptingNeverType.php create mode 100644 src/Type/NonexistentParentClassType.php create mode 100644 src/Type/NullType.php create mode 100644 src/Type/ObjectShapePropertyReflection.php create mode 100644 src/Type/ObjectShapeType.php create mode 100644 src/Type/ObjectType.php create mode 100644 src/Type/ObjectWithoutClassType.php create mode 100644 src/Type/OffsetAccessType.php create mode 100644 src/Type/OperatorTypeSpecifyingExtension.php create mode 100644 src/Type/OperatorTypeSpecifyingExtensionRegistry.php create mode 100644 src/Type/ParserNodeTypeToPHPStanType.php create mode 100644 src/Type/Php/AbsFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayChunkFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayCurrentDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFillFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php create mode 100644 src/Type/Php/ArrayFindFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayFlipFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayIntersectKeyFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/ArrayKeyFirstDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayKeyLastDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayKeysFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayMapFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayNextDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayPointerFunctionsDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayPopFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayRandFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayReverseFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArraySearchFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArraySearchFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/ArrayShiftFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArraySliceFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArraySpliceFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ArrayValuesFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/AssertFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/AssertThrowTypeExtension.php create mode 100644 src/Type/Php/BackedEnumFromMethodDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/Base64DecodeDynamicFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/BcMathStringOrNullReturnTypeExtension.php create mode 100644 src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/ClassImplementsFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ClosureBindDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/CompactFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ConstantFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ConstantHelper.php create mode 100644 src/Type/Php/CountCharsFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/CountFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/CountFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/CurlGetinfoFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/DateFormatFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/DateFormatMethodReturnTypeExtension.php create mode 100644 src/Type/Php/DateFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/DateFunctionReturnTypeHelper.php create mode 100644 src/Type/Php/DateIntervalConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/DateIntervalDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/DatePeriodConstructorReturnTypeExtension.php create mode 100644 src/Type/Php/DateTimeConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/DateTimeCreateDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/DateTimeDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php create mode 100644 src/Type/Php/DateTimeModifyReturnTypeExtension.php create mode 100644 src/Type/Php/DateTimeSubMethodThrowTypeExtension.php create mode 100644 src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/DefineConstantTypeSpecifyingExtension.php create mode 100644 src/Type/Php/DefinedConstantTypeSpecifyingExtension.php create mode 100644 src/Type/Php/DioStatDynamicFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/DsMapDynamicMethodThrowTypeExtension.php create mode 100644 src/Type/Php/DsMapDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/FilterFunctionReturnTypeHelper.php create mode 100644 src/Type/Php/FilterInputDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/FilterVarDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/FunctionExistsFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/GetCalledClassDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/GetClassDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/GetDebugTypeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/GetDefinedVarsFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/GettimeofdayDynamicFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/GettypeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/HashFunctionsReturnTypeExtension.php create mode 100644 src/Type/Php/HighlightStringDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/HrtimeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ImplodeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IniGetReturnTypeExtension.php create mode 100644 src/Type/Php/IntdivThrowTypeExtension.php create mode 100644 src/Type/Php/IsAFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IsAFunctionTypeSpecifyingHelper.php create mode 100644 src/Type/Php/IsArrayFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IsIterableFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IsSubclassOfFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/IteratorToArrayFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/JsonThrowTypeExtension.php create mode 100644 src/Type/Php/LtrimFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/MbFunctionsReturnTypeExtension.php create mode 100644 src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php create mode 100644 src/Type/Php/MbStrlenFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/MethodExistsTypeSpecifyingExtension.php create mode 100644 src/Type/Php/MicrotimeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/MinMaxFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/NonEmptyStringFunctionsReturnTypeExtension.php create mode 100644 src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/OpenSslEncryptParameterOutTypeExtension.php create mode 100644 src/Type/Php/ParseStrParameterOutTypeExtension.php create mode 100644 src/Type/Php/ParseUrlFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/PathinfoFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/PowFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/PregFilterFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/PregMatchParameterOutTypeExtension.php create mode 100644 src/Type/Php/PregMatchTypeSpecifyingExtension.php create mode 100644 src/Type/Php/PregReplaceCallbackClosureTypeExtension.php create mode 100644 src/Type/Php/PregSplitDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/PropertyExistsTypeSpecifyingExtension.php create mode 100644 src/Type/Php/RandomIntFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/RangeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/ReflectionClassConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/ReflectionClassIsSubclassOfTypeSpecifyingExtension.php create mode 100644 src/Type/Php/ReflectionFunctionConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php create mode 100644 src/Type/Php/ReflectionMethodConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/ReflectionPropertyConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/RegexArrayShapeMatcher.php create mode 100644 src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/RoundFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/SetTypeFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/SimpleXMLElementAsXMLMethodReturnTypeExtension.php create mode 100644 src/Type/Php/SimpleXMLElementClassPropertyReflectionExtension.php create mode 100644 src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php create mode 100644 src/Type/Php/SimpleXMLElementXpathMethodReturnTypeExtension.php create mode 100644 src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/StatDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/StrCaseFunctionsReturnTypeExtension.php create mode 100644 src/Type/Php/StrContainingTypeSpecifyingExtension.php create mode 100644 src/Type/Php/StrIncrementDecrementFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrPadFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrRepeatFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrSplitFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrTokFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrWordCountFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/StrlenFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrtotimeFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StrvalFamilyFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/SubstrDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/ThrowableReturnTypeExtension.php create mode 100644 src/Type/Php/TriggerErrorDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/TrimFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php create mode 100644 src/Type/Php/XMLReaderOpenReturnTypeExtension.php create mode 100644 src/Type/RecursionGuard.php create mode 100644 src/Type/Regex/RegexAlternation.php create mode 100644 src/Type/Regex/RegexAstWalkResult.php create mode 100644 src/Type/Regex/RegexCapturingGroup.php create mode 100644 src/Type/Regex/RegexExpressionHelper.php create mode 100644 src/Type/Regex/RegexGroupParser.php create mode 100644 src/Type/Regex/RegexGroupWalkResult.php create mode 100644 src/Type/Regex/RegexNonCapturingGroup.php create mode 100644 src/Type/ResourceType.php create mode 100644 src/Type/SimultaneousTypeTraverser.php create mode 100644 src/Type/StaticMethodParameterClosureTypeExtension.php create mode 100644 src/Type/StaticMethodParameterOutTypeExtension.php create mode 100644 src/Type/StaticMethodTypeSpecifyingExtension.php create mode 100644 src/Type/StaticType.php create mode 100644 src/Type/StaticTypeFactory.php create mode 100644 src/Type/StrictMixedType.php create mode 100644 src/Type/StringAlwaysAcceptingObjectWithToStringType.php create mode 100644 src/Type/StringType.php create mode 100644 src/Type/SubtractableType.php create mode 100644 src/Type/ThisType.php create mode 100644 src/Type/Traits/ArrayTypeTrait.php create mode 100644 src/Type/Traits/ConstantNumericComparisonTypeTrait.php create mode 100644 src/Type/Traits/ConstantScalarTypeTrait.php create mode 100644 src/Type/Traits/FalseyBooleanTypeTrait.php create mode 100644 src/Type/Traits/LateResolvableTypeTrait.php create mode 100644 src/Type/Traits/MaybeArrayTypeTrait.php create mode 100644 src/Type/Traits/MaybeCallableTypeTrait.php create mode 100644 src/Type/Traits/MaybeIterableTypeTrait.php create mode 100644 src/Type/Traits/MaybeObjectTypeTrait.php create mode 100644 src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php create mode 100644 src/Type/Traits/NonArrayTypeTrait.php create mode 100644 src/Type/Traits/NonCallableTypeTrait.php create mode 100644 src/Type/Traits/NonGeneralizableTypeTrait.php create mode 100644 src/Type/Traits/NonGenericTypeTrait.php create mode 100644 src/Type/Traits/NonIterableTypeTrait.php create mode 100644 src/Type/Traits/NonObjectTypeTrait.php create mode 100644 src/Type/Traits/NonOffsetAccessibleTypeTrait.php create mode 100644 src/Type/Traits/NonRemoveableTypeTrait.php create mode 100644 src/Type/Traits/ObjectTypeTrait.php create mode 100644 src/Type/Traits/TruthyBooleanTypeTrait.php create mode 100644 src/Type/Traits/UndecidedBooleanTypeTrait.php create mode 100644 src/Type/Traits/UndecidedComparisonCompoundTypeTrait.php create mode 100644 src/Type/Traits/UndecidedComparisonTypeTrait.php create mode 100644 src/Type/Type.php create mode 100644 src/Type/TypeAlias.php create mode 100644 src/Type/TypeAliasResolver.php create mode 100644 src/Type/TypeAliasResolverProvider.php create mode 100644 src/Type/TypeCombinator.php create mode 100644 src/Type/TypeResult.php create mode 100644 src/Type/TypeTraverser.php create mode 100644 src/Type/TypeUtils.php create mode 100644 src/Type/TypeWithClassName.php create mode 100644 src/Type/TypehintHelper.php create mode 100644 src/Type/UnionType.php create mode 100644 src/Type/UnionTypeHelper.php create mode 100644 src/Type/UsefulTypeAliasResolver.php create mode 100644 src/Type/ValueOfType.php create mode 100644 src/Type/VerbosityLevel.php create mode 100644 src/Type/VoidType.php create mode 100644 src/autoloadFunctions.php create mode 100644 src/debugScope.php create mode 100644 src/dumpType.php diff --git a/composer.json b/composer.json index 2e6e9801..6eb35ba7 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,15 @@ { - "name": "headercat/phpstan-extension-ide-helper", - "description": "PHPStan extension IDE helper, provides dummy PHPStan namespace classes and functions.", - "license": "MIT" -} + "name": "headercat/phpstan-extension-ide-helper", + "description": "PHPStan extension IDE helper, provides dummy PHPStan namespace classes and functions.", + "license": "MIT", + "autoload-dev": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "require": { + "phpstan/phpstan": "^1.12.16", + "phpstan/php-8-stubs": "0.4.11", + "phpstan/phpdoc-parser": "2.0.1" + } +} \ No newline at end of file diff --git a/src/AnalysedCodeException.php b/src/AnalysedCodeException.php new file mode 100644 index 00000000..fa2d0dee --- /dev/null +++ b/src/AnalysedCodeException.php @@ -0,0 +1,13 @@ +nodeScopeResolver->setAnalysedFiles($allAnalysedFiles); + $allAnalysedFiles = array_fill_keys($allAnalysedFiles, true); + + /** @var list $errors */ + $errors = []; + /** @var list $filteredPhpErrors */ + $filteredPhpErrors = []; + /** @var list $allPhpErrors */ + $allPhpErrors = []; + + /** @var list $locallyIgnoredErrors */ + $locallyIgnoredErrors = []; + + $linesToIgnore = []; + $unmatchedLineIgnores = []; + + /** @var list $collectedData */ + $collectedData = []; + + $internalErrorsCount = 0; + $reachedInternalErrorsCountLimit = false; + $dependencies = []; + $exportedNodes = []; + foreach ($files as $file) { + if ($preFileCallback !== null) { + $preFileCallback($file); + } + + try { + $fileAnalyserResult = $this->fileAnalyser->analyseFile( + $file, + $allAnalysedFiles, + $this->ruleRegistry, + $this->collectorRegistry, + null, + ); + $errors = array_merge($errors, $fileAnalyserResult->getErrors()); + $filteredPhpErrors = array_merge($filteredPhpErrors, $fileAnalyserResult->getFilteredPhpErrors()); + $allPhpErrors = array_merge($allPhpErrors, $fileAnalyserResult->getAllPhpErrors()); + + $locallyIgnoredErrors = array_merge($locallyIgnoredErrors, $fileAnalyserResult->getLocallyIgnoredErrors()); + $linesToIgnore[$file] = $fileAnalyserResult->getLinesToIgnore(); + $unmatchedLineIgnores[$file] = $fileAnalyserResult->getUnmatchedLineIgnores(); + $collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData()); + $dependencies[$file] = $fileAnalyserResult->getDependencies(); + + $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); + if (count($fileExportedNodes) > 0) { + $exportedNodes[$file] = $fileExportedNodes; + } + } catch (Throwable $t) { + if ($debug) { + throw $t; + } + $internalErrorsCount++; + $errors[] = (new Error($t->getMessage(), $file, null, $t)) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($t), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $t->getTraceAsString(), + ]); + if ($internalErrorsCount >= $this->internalErrorsCountLimit) { + $reachedInternalErrorsCountLimit = true; + break; + } + } + + if ($postFileCallback === null) { + continue; + } + + $postFileCallback(1); + } + + return new AnalyserResult( + $errors, + $filteredPhpErrors, + $allPhpErrors, + $locallyIgnoredErrors, + $linesToIgnore, + $unmatchedLineIgnores, + [], + $collectedData, + $internalErrorsCount === 0 ? $dependencies : null, + $exportedNodes, + $reachedInternalErrorsCountLimit, + memory_get_peak_usage(true), + ); + } + +} diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php new file mode 100644 index 00000000..b5b00847 --- /dev/null +++ b/src/Analyser/AnalyserResult.php @@ -0,0 +1,162 @@ +|null */ + private ?array $errors = null; + + /** + * @param list $unorderedErrors + * @param list $filteredPhpErrors + * @param list $allPhpErrors + * @param list $locallyIgnoredErrors + * @param array $linesToIgnore + * @param array $unmatchedLineIgnores + * @param list $collectedData + * @param list $internalErrors + * @param array>|null $dependencies + * @param array> $exportedNodes + */ + public function __construct( + private array $unorderedErrors, + private array $filteredPhpErrors, + private array $allPhpErrors, + private array $locallyIgnoredErrors, + private array $linesToIgnore, + private array $unmatchedLineIgnores, + private array $internalErrors, + private array $collectedData, + private ?array $dependencies, + private array $exportedNodes, + private bool $reachedInternalErrorsCountLimit, + private int $peakMemoryUsageBytes, + ) + { + } + + /** + * @return list + */ + public function getUnorderedErrors(): array + { + return $this->unorderedErrors; + } + + /** + * @return list + */ + public function getErrors(): array + { + if (!isset($this->errors)) { + $this->errors = $this->unorderedErrors; + usort( + $this->errors, + static fn (Error $a, Error $b): int => [ + $a->getFile(), + $a->getLine(), + $a->getMessage(), + ] <=> [ + $b->getFile(), + $b->getLine(), + $b->getMessage(), + ], + ); + } + + return $this->errors; + } + + /** + * @return list + */ + public function getFilteredPhpErrors(): array + { + return $this->filteredPhpErrors; + } + + /** + * @return list + */ + public function getAllPhpErrors(): array + { + return $this->allPhpErrors; + } + + /** + * @return list + */ + public function getLocallyIgnoredErrors(): array + { + return $this->locallyIgnoredErrors; + } + + /** + * @return array + */ + public function getLinesToIgnore(): array + { + return $this->linesToIgnore; + } + + /** + * @return array + */ + public function getUnmatchedLineIgnores(): array + { + return $this->unmatchedLineIgnores; + } + + /** + * @return list + */ + public function getInternalErrors(): array + { + return $this->internalErrors; + } + + /** + * @return list + */ + public function getCollectedData(): array + { + return $this->collectedData; + } + + /** + * @return array>|null + */ + public function getDependencies(): ?array + { + return $this->dependencies; + } + + /** + * @return array> + */ + public function getExportedNodes(): array + { + return $this->exportedNodes; + } + + public function hasReachedInternalErrorsCountLimit(): bool + { + return $this->reachedInternalErrorsCountLimit; + } + + public function getPeakMemoryUsageBytes(): int + { + return $this->peakMemoryUsageBytes; + } + +} diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php new file mode 100644 index 00000000..16191cc1 --- /dev/null +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -0,0 +1,223 @@ +getCollectedData()) === 0) { + return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); + } + + $hasInternalErrors = count($analyserResult->getInternalErrors()) > 0 || $analyserResult->hasReachedInternalErrorsCountLimit(); + if ($hasInternalErrors) { + return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); + } + + $nodeType = CollectedDataNode::class; + $node = new CollectedDataNode($analyserResult->getCollectedData(), $onlyFiles); + + $file = 'N/A'; + $scope = $this->scopeFactory->create(ScopeContext::create($file)); + $tempCollectorErrors = []; + $internalErrors = $analyserResult->getInternalErrors(); + foreach ($this->ruleRegistry->getRules($nodeType) as $rule) { + try { + $ruleErrors = $rule->processNode($node, $scope); + } catch (AnalysedCodeException $e) { + $tempCollectorErrors[] = (new Error($e->getMessage(), $file, $node->getStartLine(), $e, null, null, $e->getTip())) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (IdentifierNotFound $e) { + $tempCollectorErrors[] = (new Error(sprintf('Reflection error: %s not found.', $e->getIdentifier()->getName()), $file, $node->getStartLine(), $e, null, null, 'Learn more at https://phpstan.org/user-guide/discovering-symbols')) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (UnableToCompileNode | CircularReference $e) { + $tempCollectorErrors[] = (new Error(sprintf('Reflection error: %s', $e->getMessage()), $file, $node->getStartLine(), $e)) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (Throwable $t) { + if ($debug) { + throw $t; + } + + $internalErrors[] = new InternalError( + $t->getMessage(), + sprintf('running CollectedDataNode rule %s', get_class($rule)), + InternalError::prepareTrace($t), + $t->getTraceAsString(), + true, + ); + continue; + } + + foreach ($ruleErrors as $ruleError) { + $tempCollectorErrors[] = $this->ruleErrorTransformer->transform($ruleError, $scope, $nodeType, $node->getStartLine()); + } + } + + $errors = $analyserResult->getUnorderedErrors(); + $locallyIgnoredErrors = $analyserResult->getLocallyIgnoredErrors(); + $allLinesToIgnore = $analyserResult->getLinesToIgnore(); + $allUnmatchedLineIgnores = $analyserResult->getUnmatchedLineIgnores(); + $collectorErrors = []; + $locallyIgnoredCollectorErrors = []; + foreach ($tempCollectorErrors as $tempCollectorError) { + $file = $tempCollectorError->getFilePath(); + $linesToIgnore = $allLinesToIgnore[$file] ?? []; + $unmatchedLineIgnores = $allUnmatchedLineIgnores[$file] ?? []; + $localIgnoresProcessorResult = $this->localIgnoresProcessor->process( + [$tempCollectorError], + $linesToIgnore, + $unmatchedLineIgnores, + ); + foreach ($localIgnoresProcessorResult->getFileErrors() as $error) { + $errors[] = $error; + $collectorErrors[] = $error; + } + foreach ($localIgnoresProcessorResult->getLocallyIgnoredErrors() as $locallyIgnoredError) { + $locallyIgnoredErrors[] = $locallyIgnoredError; + $locallyIgnoredCollectorErrors[] = $locallyIgnoredError; + } + $allLinesToIgnore[$file] = $localIgnoresProcessorResult->getLinesToIgnore(); + $allUnmatchedLineIgnores[$file] = $localIgnoresProcessorResult->getUnmatchedLineIgnores(); + } + + return $this->addUnmatchedIgnoredErrors(new AnalyserResult( + array_merge($errors, $analyserResult->getFilteredPhpErrors()), + [], + $analyserResult->getAllPhpErrors(), + $locallyIgnoredErrors, + $allLinesToIgnore, + $allUnmatchedLineIgnores, + $internalErrors, + $analyserResult->getCollectedData(), + $analyserResult->getDependencies(), + $analyserResult->getExportedNodes(), + $analyserResult->hasReachedInternalErrorsCountLimit(), + $analyserResult->getPeakMemoryUsageBytes(), + ), $collectorErrors, $locallyIgnoredCollectorErrors); + } + + private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): AnalyserResult + { + return new AnalyserResult( + array_merge($analyserResult->getUnorderedErrors(), $analyserResult->getFilteredPhpErrors()), + [], + $analyserResult->getAllPhpErrors(), + $analyserResult->getLocallyIgnoredErrors(), + $analyserResult->getLinesToIgnore(), + $analyserResult->getUnmatchedLineIgnores(), + $analyserResult->getInternalErrors(), + $analyserResult->getCollectedData(), + $analyserResult->getDependencies(), + $analyserResult->getExportedNodes(), + $analyserResult->hasReachedInternalErrorsCountLimit(), + $analyserResult->getPeakMemoryUsageBytes(), + ); + } + + /** + * @param list $collectorErrors + * @param list $locallyIgnoredCollectorErrors + */ + private function addUnmatchedIgnoredErrors( + AnalyserResult $analyserResult, + array $collectorErrors, + array $locallyIgnoredCollectorErrors, + ): FinalizerResult + { + if (!$this->reportUnmatchedIgnoredErrors) { + return new FinalizerResult($analyserResult, $collectorErrors, $locallyIgnoredCollectorErrors); + } + + $errors = $analyserResult->getUnorderedErrors(); + foreach ($analyserResult->getUnmatchedLineIgnores() as $file => $data) { + foreach ($data as $ignoredFile => $lines) { + if ($ignoredFile !== $file) { + continue; + } + + foreach ($lines as $line => $identifiers) { + if ($identifiers === null) { + $errors[] = (new Error( + sprintf('No error to ignore is reported on line %d.', $line), + $file, + $line, + false, + $file, + ))->withIdentifier('ignore.unmatchedLine'); + continue; + } + + foreach ($identifiers as $identifier) { + $errors[] = (new Error( + sprintf('No error with identifier %s is reported on line %d.', $identifier, $line), + $file, + $line, + false, + $file, + ))->withIdentifier('ignore.unmatchedIdentifier'); + } + } + } + } + + return new FinalizerResult( + new AnalyserResult( + $errors, + $analyserResult->getFilteredPhpErrors(), + $analyserResult->getAllPhpErrors(), + $analyserResult->getLocallyIgnoredErrors(), + $analyserResult->getLinesToIgnore(), + $analyserResult->getUnmatchedLineIgnores(), + $analyserResult->getInternalErrors(), + $analyserResult->getCollectedData(), + $analyserResult->getDependencies(), + $analyserResult->getExportedNodes(), + $analyserResult->hasReachedInternalErrorsCountLimit(), + $analyserResult->getPeakMemoryUsageBytes(), + ), + $collectorErrors, + $locallyIgnoredCollectorErrors, + ); + } + +} diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php new file mode 100644 index 00000000..61a589d6 --- /dev/null +++ b/src/Analyser/ArgumentsNormalizer.php @@ -0,0 +1,299 @@ +getArgs(); + if (count($args) < 1) { + return null; + } + + $passThruArgs = []; + $callbackArg = null; + foreach ($args as $i => $arg) { + if ($callbackArg === null) { + if ($arg->name === null && $i === 0) { + $callbackArg = $arg; + continue; + } + if ($arg->name !== null && $arg->name->toString() === 'callback') { + $callbackArg = $arg; + continue; + } + } + + $passThruArgs[] = $arg; + } + + if ($callbackArg === null) { + return null; + } + + $calledOnType = $scope->getType($callbackArg->value); + if (!$calledOnType->isCallable()->yes()) { + return null; + } + + $callableParametersAcceptors = $calledOnType->getCallableParametersAcceptors($scope); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $passThruArgs, + $callableParametersAcceptors, + null, + ); + + $acceptsNamedArguments = TrinaryLogic::createYes(); + foreach ($callableParametersAcceptors as $callableParametersAcceptor) { + $acceptsNamedArguments = $acceptsNamedArguments->and($callableParametersAcceptor->acceptsNamedArguments()); + } + + return [$parametersAcceptor, new FuncCall( + $callbackArg->value, + $passThruArgs, + $callUserFuncCall->getAttributes(), + ), $acceptsNamedArguments]; + } + + public static function reorderFuncArguments( + ParametersAcceptor $parametersAcceptor, + FuncCall $functionCall, + ): ?FuncCall + { + $reorderedArgs = self::reorderArgs($parametersAcceptor, $functionCall->getArgs()); + + if ($reorderedArgs === null) { + return null; + } + + return new FuncCall( + $functionCall->name, + $reorderedArgs, + $functionCall->getAttributes(), + ); + } + + public static function reorderMethodArguments( + ParametersAcceptor $parametersAcceptor, + MethodCall $methodCall, + ): ?MethodCall + { + $reorderedArgs = self::reorderArgs($parametersAcceptor, $methodCall->getArgs()); + + if ($reorderedArgs === null) { + return null; + } + + return new MethodCall( + $methodCall->var, + $methodCall->name, + $reorderedArgs, + $methodCall->getAttributes(), + ); + } + + public static function reorderStaticCallArguments( + ParametersAcceptor $parametersAcceptor, + StaticCall $staticCall, + ): ?StaticCall + { + $reorderedArgs = self::reorderArgs($parametersAcceptor, $staticCall->getArgs()); + + if ($reorderedArgs === null) { + return null; + } + + return new StaticCall( + $staticCall->class, + $staticCall->name, + $reorderedArgs, + $staticCall->getAttributes(), + ); + } + + public static function reorderNewArguments( + ParametersAcceptor $parametersAcceptor, + New_ $new, + ): ?New_ + { + $reorderedArgs = self::reorderArgs($parametersAcceptor, $new->getArgs()); + + if ($reorderedArgs === null) { + return null; + } + + return new New_( + $new->class, + $reorderedArgs, + $new->getAttributes(), + ); + } + + /** + * @param Arg[] $callArgs + * @return ?array + */ + public static function reorderArgs(ParametersAcceptor $parametersAcceptor, array $callArgs): ?array + { + if (count($callArgs) === 0) { + return []; + } + + $signatureParameters = $parametersAcceptor->getParameters(); + + $hasNamedArgs = false; + foreach ($callArgs as $arg) { + if ($arg->name !== null) { + $hasNamedArgs = true; + break; + } + } + if (!$hasNamedArgs) { + return $callArgs; + } + + $hasVariadic = false; + $argumentPositions = []; + foreach ($signatureParameters as $i => $parameter) { + if ($hasVariadic) { + // variadic parameter must be last + return null; + } + + $hasVariadic = $parameter->isVariadic(); + $argumentPositions[$parameter->getName()] = $i; + } + + $reorderedArgs = []; + $additionalNamedArgs = []; + $appendArgs = []; + foreach ($callArgs as $i => $arg) { + if ($arg->name === null) { + // add regular args as is + $reorderedArgs[$i] = $arg; + } elseif (array_key_exists($arg->name->toString(), $argumentPositions)) { + $argName = $arg->name->toString(); + // order named args into the position the signature expects them + $attributes = $arg->getAttributes(); + $attributes[self::ORIGINAL_ARG_ATTRIBUTE] = $arg; + $reorderedArgs[$argumentPositions[$argName]] = new Arg( + $arg->value, + $arg->byRef, + $arg->unpack, + $attributes, + null, + ); + } else { + if (!$hasVariadic) { + $attributes = $arg->getAttributes(); + $attributes[self::ORIGINAL_ARG_ATTRIBUTE] = $arg; + $appendArgs[] = new Arg( + $arg->value, + $arg->byRef, + $arg->unpack, + $attributes, + null, + ); + continue; + } + + $attributes = $arg->getAttributes(); + $attributes[self::ORIGINAL_ARG_ATTRIBUTE] = $arg; + $additionalNamedArgs[] = new Arg( + $arg->value, + $arg->byRef, + $arg->unpack, + $attributes, + null, + ); + } + } + + // replace variadic parameter with additional named args, except if it is already set + $additionalNamedArgsOffset = count($argumentPositions) - 1; + if (array_key_exists($additionalNamedArgsOffset, $reorderedArgs)) { + $additionalNamedArgsOffset++; + } + + foreach ($additionalNamedArgs as $i => $additionalNamedArg) { + $reorderedArgs[$additionalNamedArgsOffset + $i] = $additionalNamedArg; + } + + if (count($reorderedArgs) === 0) { + foreach ($appendArgs as $arg) { + $reorderedArgs[] = $arg; + } + return $reorderedArgs; + } + + // fill up all holes with default values until the last given argument + for ($j = 0; $j < max(array_keys($reorderedArgs)); $j++) { + if (array_key_exists($j, $reorderedArgs)) { + continue; + } + if (!array_key_exists($j, $signatureParameters)) { + throw new ShouldNotHappenException('Parameter signatures cannot have holes'); + } + + $parameter = $signatureParameters[$j]; + + // we can only fill up optional parameters with default values + if (!$parameter->isOptional()) { + return null; + } + + $defaultValue = $parameter->getDefaultValue(); + if ($defaultValue === null) { + if (!$parameter->isVariadic()) { + throw new ShouldNotHappenException('An optional parameter must have a default value'); + } + $defaultValue = new ConstantArrayType([], []); + } + + $reorderedArgs[$j] = new Arg( + new TypeExpr($defaultValue), + ); + } + + ksort($reorderedArgs); + + foreach ($appendArgs as $arg) { + $reorderedArgs[] = $arg; + } + + return $reorderedArgs; + } + +} diff --git a/src/Analyser/ConditionalExpressionHolder.php b/src/Analyser/ConditionalExpressionHolder.php new file mode 100644 index 00000000..909cb1a9 --- /dev/null +++ b/src/Analyser/ConditionalExpressionHolder.php @@ -0,0 +1,56 @@ + $conditionExpressionTypeHolders + */ + public function __construct( + private array $conditionExpressionTypeHolders, + private ExpressionTypeHolder $typeHolder, + ) + { + if (count($conditionExpressionTypeHolders) === 0) { + throw new ShouldNotHappenException(); + } + } + + /** + * @return array + */ + public function getConditionExpressionTypeHolders(): array + { + return $this->conditionExpressionTypeHolders; + } + + public function getTypeHolder(): ExpressionTypeHolder + { + return $this->typeHolder; + } + + public function getKey(): string + { + $parts = []; + foreach ($this->conditionExpressionTypeHolders as $exprString => $typeHolder) { + $parts[] = $exprString . '=' . $typeHolder->getType()->describe(VerbosityLevel::precise()); + } + + return sprintf( + '%s => %s (%s)', + implode(' && ', $parts), + $this->typeHolder->getType()->describe(VerbosityLevel::precise()), + $this->typeHolder->getCertainty()->describe(), + ); + } + +} diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php new file mode 100644 index 00000000..8f469081 --- /dev/null +++ b/src/Analyser/ConstantResolver.php @@ -0,0 +1,440 @@ + */ + private array $currentlyResolving = []; + + /** + * @param string[] $dynamicConstantNames + * @param int|array{min: int, max: int}|null $phpVersion + */ + public function __construct( + private ReflectionProviderProvider $reflectionProviderProvider, + private array $dynamicConstantNames, + private int|array|null $phpVersion, + private ComposerPhpVersionFactory $composerPhpVersionFactory, + ) + { + } + + public function resolveConstant(Name $name, ?NamespaceAnswerer $scope): ?Type + { + if (!$this->getReflectionProvider()->hasConstant($name, $scope)) { + return null; + } + + /** @var string $resolvedConstantName */ + $resolvedConstantName = $this->getReflectionProvider()->resolveConstantName($name, $scope); + + $constantType = $this->resolvePredefinedConstant($resolvedConstantName); + if ($constantType !== null) { + return $constantType; + } + + if (array_key_exists($resolvedConstantName, $this->currentlyResolving)) { + return new MixedType(); + } + + $this->currentlyResolving[$resolvedConstantName] = true; + + $constantReflection = $this->getReflectionProvider()->getConstant($name, $scope); + $constantType = $constantReflection->getValueType(); + + $type = $this->resolveConstantType($resolvedConstantName, $constantType); + unset($this->currentlyResolving[$resolvedConstantName]); + + return $type; + } + + public function resolvePredefinedConstant(string $resolvedConstantName): ?Type + { + // core, https://www.php.net/manual/en/reserved.constants.php + if ($resolvedConstantName === 'PHP_VERSION') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + + $minPhpVersion = null; + $maxPhpVersion = null; + if (in_array($resolvedConstantName, ['PHP_VERSION_ID', 'PHP_MAJOR_VERSION', 'PHP_MINOR_VERSION', 'PHP_RELEASE_VERSION'], true)) { + $minPhpVersion = $this->getMinPhpVersion(); + $maxPhpVersion = $this->getMaxPhpVersion(); + } + + if ($resolvedConstantName === 'PHP_MAJOR_VERSION') { + $minMajor = 5; + $maxMajor = null; + + if ($minPhpVersion !== null) { + $minMajor = max($minMajor, $minPhpVersion->getMajorVersionId()); + } + if ($maxPhpVersion !== null) { + $maxMajor = $maxPhpVersion->getMajorVersionId(); + } + + return $this->createInteger($minMajor, $maxMajor); + } + if ($resolvedConstantName === 'PHP_MINOR_VERSION') { + $minMinor = 0; + $maxMinor = null; + + if ( + $minPhpVersion !== null + && $maxPhpVersion !== null + && $maxPhpVersion->getMajorVersionId() === $minPhpVersion->getMajorVersionId() + ) { + $minMinor = $minPhpVersion->getMinorVersionId(); + $maxMinor = $maxPhpVersion->getMinorVersionId(); + } + + return $this->createInteger($minMinor, $maxMinor); + } + if ($resolvedConstantName === 'PHP_RELEASE_VERSION') { + $minRelease = 0; + $maxRelease = null; + + if ( + $minPhpVersion !== null + && $maxPhpVersion !== null + && $maxPhpVersion->getMajorVersionId() === $minPhpVersion->getMajorVersionId() + && $maxPhpVersion->getMinorVersionId() === $minPhpVersion->getMinorVersionId() + ) { + $minRelease = $minPhpVersion->getPatchVersionId(); + $maxRelease = $maxPhpVersion->getPatchVersionId(); + } + + return $this->createInteger($minRelease, $maxRelease); + } + if ($resolvedConstantName === 'PHP_VERSION_ID') { + $minVersion = 50207; + $maxVersion = null; + if ($minPhpVersion !== null) { + $minVersion = max($minVersion, $minPhpVersion->getVersionId()); + } + if ($maxPhpVersion !== null) { + $maxVersion = $maxPhpVersion->getVersionId(); + } + + return $this->createInteger($minVersion, $maxVersion); + } + if ($resolvedConstantName === 'PHP_ZTS') { + return new UnionType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ]); + } + if ($resolvedConstantName === 'PHP_DEBUG') { + return new UnionType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ]); + } + if ($resolvedConstantName === 'PHP_MAXPATHLEN') { + return IntegerRangeType::fromInterval(1, null); + } + if ($resolvedConstantName === 'PHP_OS') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_OS_FAMILY') { + return new UnionType([ + new ConstantStringType('Windows'), + new ConstantStringType('BSD'), + new ConstantStringType('Darwin'), + new ConstantStringType('Solaris'), + new ConstantStringType('Linux'), + new ConstantStringType('Unknown'), + ]); + } + if ($resolvedConstantName === 'PHP_SAPI') { + return new UnionType([ + new ConstantStringType('apache'), + new ConstantStringType('apache2handler'), + new ConstantStringType('cgi'), + new ConstantStringType('cli'), + new ConstantStringType('cli-server'), + new ConstantStringType('embed'), + new ConstantStringType('fpm-fcgi'), + new ConstantStringType('litespeed'), + new ConstantStringType('phpdbg'), + new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]), + ]); + } + if ($resolvedConstantName === 'PHP_EOL') { + return new UnionType([ + new ConstantStringType("\n"), + new ConstantStringType("\r\n"), + ]); + } + if ($resolvedConstantName === 'PHP_INT_MAX') { + return PHP_INT_SIZE === 8 + ? new UnionType([new ConstantIntegerType(2147483647), new ConstantIntegerType(9223372036854775807)]) + : new ConstantIntegerType(2147483647); + } + if ($resolvedConstantName === 'PHP_INT_MIN') { + // Why the -1 you might wonder, the answer is to fit it into an int :/ see https://3v4l.org/4SHIQ + return PHP_INT_SIZE === 8 + ? new UnionType([new ConstantIntegerType(-9223372036854775807 - 1), new ConstantIntegerType(-2147483647 - 1)]) + : new ConstantIntegerType(-2147483647 - 1); + } + if ($resolvedConstantName === 'PHP_INT_SIZE') { + return new UnionType([ + new ConstantIntegerType(4), + new ConstantIntegerType(8), + ]); + } + if ($resolvedConstantName === 'PHP_FLOAT_DIG') { + return IntegerRangeType::fromInterval(1, null); + } + if ($resolvedConstantName === 'PHP_EXTENSION_DIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_PREFIX') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_BINDIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_BINARY') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_MANDIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_LIBDIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_DATADIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_SYSCONFDIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_LOCALSTATEDIR') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_CONFIG_FILE_PATH') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($resolvedConstantName === 'PHP_SHLIB_SUFFIX') { + return new UnionType([ + new ConstantStringType('so'), + new ConstantStringType('dll'), + ]); + } + if ($resolvedConstantName === 'PHP_FD_SETSIZE') { + return IntegerRangeType::fromInterval(1, null); + } + if ($resolvedConstantName === '__COMPILER_HALT_OFFSET__') { + return IntegerRangeType::fromInterval(1, null); + } + // core other, https://www.php.net/manual/en/info.constants.php + if ($resolvedConstantName === 'PHP_WINDOWS_VERSION_MAJOR') { + return IntegerRangeType::fromInterval(4, null); + } + if ($resolvedConstantName === 'PHP_WINDOWS_VERSION_MINOR') { + return IntegerRangeType::fromInterval(0, null); + } + if ($resolvedConstantName === 'PHP_WINDOWS_VERSION_BUILD') { + return IntegerRangeType::fromInterval(1, null); + } + // dir, https://www.php.net/manual/en/dir.constants.php + if ($resolvedConstantName === 'DIRECTORY_SEPARATOR') { + return new UnionType([ + new ConstantStringType('/'), + new ConstantStringType('\\'), + ]); + } + if ($resolvedConstantName === 'PATH_SEPARATOR') { + return new UnionType([ + new ConstantStringType(':'), + new ConstantStringType(';'), + ]); + } + // iconv, https://www.php.net/manual/en/iconv.constants.php + if ($resolvedConstantName === 'ICONV_IMPL') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + // libxml, https://www.php.net/manual/en/libxml.constants.php + if ($resolvedConstantName === 'LIBXML_VERSION') { + return IntegerRangeType::fromInterval(1, null); + } + if ($resolvedConstantName === 'LIBXML_DOTTED_VERSION') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + // openssl, https://www.php.net/manual/en/openssl.constants.php + if ($resolvedConstantName === 'OPENSSL_VERSION_NUMBER') { + return IntegerRangeType::fromInterval(1, null); + } + + // pcre, https://www.php.net/manual/en/pcre.constants.php + if ($resolvedConstantName === 'PCRE_VERSION') { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + + if (in_array($resolvedConstantName, ['STDIN', 'STDOUT', 'STDERR'], true)) { + return new ResourceType(); + } + if ($resolvedConstantName === 'NAN') { + return new ConstantFloatType(NAN); + } + if ($resolvedConstantName === 'INF') { + return new ConstantFloatType(INF); + } + + return null; + } + + private function getMinPhpVersion(): ?PhpVersion + { + if (is_int($this->phpVersion)) { + return null; + } + + if (is_array($this->phpVersion)) { + if ($this->phpVersion['max'] < $this->phpVersion['min']) { + throw new ShouldNotHappenException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.'); + } + + return new PhpVersion($this->phpVersion['min']); + } + + return $this->composerPhpVersionFactory->getMinVersion(); + } + + private function getMaxPhpVersion(): ?PhpVersion + { + if (is_int($this->phpVersion)) { + return null; + } + + if (is_array($this->phpVersion)) { + if ($this->phpVersion['max'] < $this->phpVersion['min']) { + throw new ShouldNotHappenException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.'); + } + + return new PhpVersion($this->phpVersion['max']); + } + + return $this->composerPhpVersionFactory->getMaxVersion(); + } + + public function resolveConstantType(string $constantName, Type $constantType): Type + { + if ($constantType->isConstantValue()->yes() && in_array($constantName, $this->dynamicConstantNames, true)) { + return $constantType->generalize(GeneralizePrecision::lessSpecific()); + } + + return $constantType; + } + + public function resolveClassConstantType(string $className, string $constantName, Type $constantType, ?Type $nativeType): Type + { + $lookupConstantName = sprintf('%s::%s', $className, $constantName); + if (in_array($lookupConstantName, $this->dynamicConstantNames, true)) { + if ($nativeType !== null) { + return $nativeType; + } + + if ($constantType->isConstantValue()->yes()) { + return $constantType->generalize(GeneralizePrecision::lessSpecific()); + } + } + + return $constantType; + } + + private function createInteger(?int $min, ?int $max): Type + { + if ($min !== null && $min === $max) { + return new ConstantIntegerType($min); + } + return IntegerRangeType::fromInterval($min, $max); + } + + private function getReflectionProvider(): ReflectionProvider + { + return $this->reflectionProviderProvider->getReflectionProvider(); + } + +} diff --git a/src/Analyser/ConstantResolverFactory.php b/src/Analyser/ConstantResolverFactory.php new file mode 100644 index 00000000..85e6248c --- /dev/null +++ b/src/Analyser/ConstantResolverFactory.php @@ -0,0 +1,32 @@ +container->getByType(ComposerPhpVersionFactory::class); + + return new ConstantResolver( + $this->reflectionProviderProvider, + $this->container->getParameter('dynamicConstantNames'), + $this->container->getParameter('phpVersion'), + $composerFactory, + ); + } + +} diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php new file mode 100644 index 00000000..2ccaf76a --- /dev/null +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -0,0 +1,97 @@ +reflectionProvider, + $this->initializerExprTypeResolver, + $this->dynamicReturnTypeExtensionRegistryProvider->getRegistry(), + $this->expressionTypeResolverExtensionRegistryProvider->getRegistry(), + $this->exprPrinter, + $this->typeSpecifier, + $this->propertyReflectionFinder, + $this->parser, + $this->nodeScopeResolver, + $this->richerScopeGetTypeHelper, + $this->constantResolver, + $context, + $this->phpVersion, + $this->attributeReflectionFactory, + $this->configPhpVersion, + $declareStrictTypes, + $function, + $namespace, + $expressionTypes, + $nativeExpressionTypes, + $conditionalExpressions, + $inClosureBindScopeClasses, + $anonymousFunctionReflection, + $inFirstLevelStatement, + $currentlyAssignedExpressions, + $currentlyAllowedUndefinedExpressions, + $inFunctionCallsStack, + $afterExtractCall, + $parentScope, + $nativeTypesPromoted, + ); + } + +} diff --git a/src/Analyser/EndStatementResult.php b/src/Analyser/EndStatementResult.php new file mode 100644 index 00000000..b9b88445 --- /dev/null +++ b/src/Analyser/EndStatementResult.php @@ -0,0 +1,28 @@ +statement; + } + + public function getResult(): StatementResult + { + return $this->result; + } + +} diff --git a/src/Analyser/EnsuredNonNullabilityResult.php b/src/Analyser/EnsuredNonNullabilityResult.php new file mode 100644 index 00000000..cb5eca54 --- /dev/null +++ b/src/Analyser/EnsuredNonNullabilityResult.php @@ -0,0 +1,29 @@ +scope; + } + + /** + * @return EnsuredNonNullabilityResultExpression[] + */ + public function getSpecifiedExpressions(): array + { + return $this->specifiedExpressions; + } + +} diff --git a/src/Analyser/EnsuredNonNullabilityResultExpression.php b/src/Analyser/EnsuredNonNullabilityResultExpression.php new file mode 100644 index 00000000..743df288 --- /dev/null +++ b/src/Analyser/EnsuredNonNullabilityResultExpression.php @@ -0,0 +1,42 @@ +expression; + } + + public function getOriginalType(): Type + { + return $this->originalType; + } + + public function getOriginalNativeType(): Type + { + return $this->originalNativeType; + } + + public function getCertainty(): TrinaryLogic + { + return $this->certainty; + } + +} diff --git a/src/Analyser/Error.php b/src/Analyser/Error.php new file mode 100644 index 00000000..2b834b8f --- /dev/null +++ b/src/Analyser/Error.php @@ -0,0 +1,311 @@ +|null $nodeType + * @param mixed[] $metadata + */ + public function __construct( + private string $message, + private string $file, + private ?int $line = null, + private bool|Throwable $canBeIgnored = true, + private ?string $filePath = null, + private ?string $traitFilePath = null, + private ?string $tip = null, + private ?int $nodeLine = null, + private ?string $nodeType = null, + private ?string $identifier = null, + private array $metadata = [], + ) + { + if ($this->identifier !== null && !self::validateIdentifier($this->identifier)) { + throw new ShouldNotHappenException(sprintf('Invalid identifier: %s', $this->identifier)); + } + } + + public function getMessage(): string + { + return $this->message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFilePath(): string + { + if ($this->filePath === null) { + return $this->file; + } + + return $this->filePath; + } + + public function changeFilePath(string $newFilePath): self + { + if ($this->traitFilePath !== null) { + throw new ShouldNotHappenException('Errors in traits not yet supported'); + } + + return new self( + $this->message, + $newFilePath, + $this->line, + $this->canBeIgnored, + $newFilePath, + null, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + ); + } + + public function changeTraitFilePath(string $newFilePath): self + { + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $newFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + ); + } + + public function getTraitFilePath(): ?string + { + return $this->traitFilePath; + } + + public function getLine(): ?int + { + return $this->line; + } + + public function canBeIgnored(): bool + { + return $this->canBeIgnored === true; + } + + public function hasNonIgnorableException(): bool + { + return $this->canBeIgnored instanceof Throwable; + } + + public function getTip(): ?string + { + return $this->tip; + } + + public function withoutTip(): self + { + if ($this->tip === null) { + return $this; + } + + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $this->traitFilePath, + null, + $this->nodeLine, + $this->nodeType, + ); + } + + public function doNotIgnore(): self + { + if (!$this->canBeIgnored()) { + return $this; + } + + return new self( + $this->message, + $this->file, + $this->line, + false, + $this->filePath, + $this->traitFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + ); + } + + public function withIdentifier(string $identifier): self + { + if ($this->identifier !== null) { + throw new ShouldNotHappenException(sprintf('Error already has an identifier: %s', $this->identifier)); + } + + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $this->traitFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + $identifier, + $this->metadata, + ); + } + + /** + * @param mixed[] $metadata + */ + public function withMetadata(array $metadata): self + { + if ($this->metadata !== []) { + throw new ShouldNotHappenException('Error already has metadata'); + } + + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $this->traitFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $metadata, + ); + } + + public function getNodeLine(): ?int + { + return $this->nodeLine; + } + + /** + * @return class-string|null + */ + public function getNodeType(): ?string + { + return $this->nodeType; + } + + /** + * Error identifier set via `RuleErrorBuilder::identifier()`. + * + * List of all current error identifiers in PHPStan: https://phpstan.org/error-identifiers + */ + public function getIdentifier(): ?string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'message' => $this->message, + 'file' => $this->file, + 'line' => $this->line, + 'canBeIgnored' => is_bool($this->canBeIgnored) ? $this->canBeIgnored : 'exception', + 'filePath' => $this->filePath, + 'traitFilePath' => $this->traitFilePath, + 'tip' => $this->tip, + 'nodeLine' => $this->nodeLine, + 'nodeType' => $this->nodeType, + 'identifier' => $this->identifier, + 'metadata' => $this->metadata, + ]; + } + + /** + * @param mixed[] $json + */ + public static function decode(array $json): self + { + return new self( + $json['message'], + $json['file'], + $json['line'], + $json['canBeIgnored'] === 'exception' ? new Exception() : $json['canBeIgnored'], + $json['filePath'], + $json['traitFilePath'], + $json['tip'], + $json['nodeLine'] ?? null, + $json['nodeType'] ?? null, + $json['identifier'] ?? null, + $json['metadata'] ?? [], + ); + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['message'], + $properties['file'], + $properties['line'], + $properties['canBeIgnored'], + $properties['filePath'], + $properties['traitFilePath'], + $properties['tip'], + $properties['nodeLine'] ?? null, + $properties['nodeType'] ?? null, + $properties['identifier'] ?? null, + $properties['metadata'] ?? [], + ); + } + + public static function validateIdentifier(string $identifier): bool + { + return Strings::match($identifier, '~^' . self::PATTERN_IDENTIFIER . '$~') !== null; + } + +} diff --git a/src/Analyser/ExpressionContext.php b/src/Analyser/ExpressionContext.php new file mode 100644 index 00000000..53ea1604 --- /dev/null +++ b/src/Analyser/ExpressionContext.php @@ -0,0 +1,64 @@ +isDeep) { + return $this; + } + + return new self(true, $this->inAssignRightSideVariableName, $this->inAssignRightSideType, $this->inAssignRightSideNativeType); + } + + public function isDeep(): bool + { + return $this->isDeep; + } + + public function enterRightSideAssign(string $variableName, Type $type, Type $nativeType): self + { + return new self($this->isDeep, $variableName, $type, $nativeType); + } + + public function getInAssignRightSideVariableName(): ?string + { + return $this->inAssignRightSideVariableName; + } + + public function getInAssignRightSideType(): ?Type + { + return $this->inAssignRightSideType; + } + + public function getInAssignRightSideNativeType(): ?Type + { + return $this->inAssignRightSideNativeType; + } + +} diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php new file mode 100644 index 00000000..a92b8e78 --- /dev/null +++ b/src/Analyser/ExpressionResult.php @@ -0,0 +1,94 @@ +truthyScopeCallback = $truthyScopeCallback; + $this->falseyScopeCallback = $falseyScopeCallback; + } + + public function getScope(): MutatingScope + { + return $this->scope; + } + + public function hasYield(): bool + { + return $this->hasYield; + } + + /** + * @return ThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getTruthyScope(): MutatingScope + { + if ($this->truthyScopeCallback === null) { + return $this->scope; + } + + if ($this->truthyScope !== null) { + return $this->truthyScope; + } + + $callback = $this->truthyScopeCallback; + $this->truthyScope = $callback(); + return $this->truthyScope; + } + + public function getFalseyScope(): MutatingScope + { + if ($this->falseyScopeCallback === null) { + return $this->scope; + } + + if ($this->falseyScope !== null) { + return $this->falseyScope; + } + + $callback = $this->falseyScopeCallback; + $this->falseyScope = $callback(); + return $this->falseyScope; + } + +} diff --git a/src/Analyser/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php new file mode 100644 index 00000000..3dbc6d8a --- /dev/null +++ b/src/Analyser/ExpressionTypeHolder.php @@ -0,0 +1,66 @@ +certainty->equals($other->certainty)) { + return false; + } + + return $this->type->equals($other->type); + } + + public function and(self $other): self + { + if ($this->getType()->equals($other->getType())) { + $type = $this->getType(); + } else { + $type = TypeCombinator::union($this->getType(), $other->getType()); + } + return new self( + $this->expr, + $type, + $this->getCertainty()->and($other->getCertainty()), + ); + } + + public function getExpr(): Expr + { + return $this->expr; + } + + public function getType(): Type + { + return $this->type; + } + + public function getCertainty(): TrinaryLogic + { + return $this->certainty; + } + +} diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php new file mode 100644 index 00000000..80799c6d --- /dev/null +++ b/src/Analyser/FileAnalyser.php @@ -0,0 +1,376 @@ + */ + private array $allPhpErrors = []; + + /** @var list */ + private array $filteredPhpErrors = []; + + public function __construct( + private ScopeFactory $scopeFactory, + private NodeScopeResolver $nodeScopeResolver, + private Parser $parser, + private DependencyResolver $dependencyResolver, + private RuleErrorTransformer $ruleErrorTransformer, + private LocalIgnoresProcessor $localIgnoresProcessor, + ) + { + } + + /** + * @param array $analysedFiles + * @param callable(Node $node, Scope $scope): void|null $outerNodeCallback + */ + public function analyseFile( + string $file, + array $analysedFiles, + RuleRegistry $ruleRegistry, + CollectorRegistry $collectorRegistry, + ?callable $outerNodeCallback, + ): FileAnalyserResult + { + /** @var list $fileErrors */ + $fileErrors = []; + + /** @var list $locallyIgnoredErrors */ + $locallyIgnoredErrors = []; + + /** @var list $fileCollectedData */ + $fileCollectedData = []; + + $fileDependencies = []; + $exportedNodes = []; + $linesToIgnore = []; + $unmatchedLineIgnores = []; + if (is_file($file)) { + try { + $this->collectErrors($analysedFiles); + $parserNodes = $this->parser->parseFile($file); + $linesToIgnore = $unmatchedLineIgnores = [$file => $this->getLinesToIgnoreFromTokens($parserNodes)]; + $temporaryFileErrors = []; + $nodeCallback = function (Node $node, Scope $scope) use (&$fileErrors, &$fileCollectedData, &$fileDependencies, &$exportedNodes, $file, $ruleRegistry, $collectorRegistry, $outerNodeCallback, $analysedFiles, &$linesToIgnore, &$unmatchedLineIgnores, &$temporaryFileErrors): void { + if ($node instanceof Node\Stmt\Trait_) { + foreach (array_keys($linesToIgnore[$file] ?? []) as $lineToIgnore) { + if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) { + continue; + } + + unset($unmatchedLineIgnores[$file][$lineToIgnore]); + } + } + if ($node instanceof InTraitNode) { + $traitNode = $node->getOriginalNode(); + $linesToIgnore[$scope->getFileDescription()] = $this->getLinesToIgnoreFromTokens([$traitNode]); + } + if ($outerNodeCallback !== null) { + $outerNodeCallback($node, $scope); + } + $uniquedAnalysedCodeExceptionMessages = []; + $nodeType = get_class($node); + foreach ($ruleRegistry->getRules($nodeType) as $rule) { + try { + $ruleErrors = $rule->processNode($node, $scope); + } catch (AnalysedCodeException $e) { + if (isset($uniquedAnalysedCodeExceptionMessages[$e->getMessage()])) { + continue; + } + + $uniquedAnalysedCodeExceptionMessages[$e->getMessage()] = true; + $fileErrors[] = (new Error($e->getMessage(), $file, $node->getStartLine(), $e, null, null, $e->getTip())) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (IdentifierNotFound $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s not found.', $e->getIdentifier()->getName()), $file, $node->getStartLine(), $e, null, null, 'Learn more at https://phpstan.org/user-guide/discovering-symbols')) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (UnableToCompileNode | CircularReference $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s', $e->getMessage()), $file, $node->getStartLine(), $e)) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } + + foreach ($ruleErrors as $ruleError) { + $temporaryFileErrors[] = $this->ruleErrorTransformer->transform($ruleError, $scope, $nodeType, $node->getStartLine()); + } + } + + foreach ($collectorRegistry->getCollectors($nodeType) as $collector) { + try { + $collectedData = $collector->processNode($node, $scope); + } catch (AnalysedCodeException $e) { + if (isset($uniquedAnalysedCodeExceptionMessages[$e->getMessage()])) { + continue; + } + + $uniquedAnalysedCodeExceptionMessages[$e->getMessage()] = true; + $fileErrors[] = (new Error($e->getMessage(), $file, $node->getStartLine(), $e, null, null, $e->getTip())) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (IdentifierNotFound $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s not found.', $e->getIdentifier()->getName()), $file, $node->getStartLine(), $e, null, null, 'Learn more at https://phpstan.org/user-guide/discovering-symbols')) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } catch (UnableToCompileNode | CircularReference $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s', $e->getMessage()), $file, $node->getStartLine(), $e)) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + continue; + } + + if ($collectedData === null) { + continue; + } + + $fileCollectedData[] = new CollectedData( + $collectedData, + $scope->getFile(), + get_class($collector), + ); + } + + try { + $dependencies = $this->dependencyResolver->resolveDependencies($node, $scope); + foreach ($dependencies->getFileDependencies($scope->getFile(), $analysedFiles) as $dependentFile) { + $fileDependencies[] = $dependentFile; + } + if ($dependencies->getExportedNode() !== null) { + $exportedNodes[] = $dependencies->getExportedNode(); + } + } catch (AnalysedCodeException) { + // pass + } catch (IdentifierNotFound) { + // pass + } catch (UnableToCompileNode) { + // pass + } + }; + + $scope = $this->scopeFactory->create(ScopeContext::create($file)); + $nodeCallback(new FileNode($parserNodes), $scope); + $this->nodeScopeResolver->processNodes( + $parserNodes, + $scope, + $nodeCallback, + ); + + $localIgnoresProcessorResult = $this->localIgnoresProcessor->process( + $temporaryFileErrors, + $linesToIgnore, + $unmatchedLineIgnores, + ); + foreach ($localIgnoresProcessorResult->getFileErrors() as $fileError) { + $fileErrors[] = $fileError; + } + foreach ($localIgnoresProcessorResult->getLocallyIgnoredErrors() as $locallyIgnoredError) { + $locallyIgnoredErrors[] = $locallyIgnoredError; + } + $linesToIgnore = $localIgnoresProcessorResult->getLinesToIgnore(); + $unmatchedLineIgnores = $localIgnoresProcessorResult->getUnmatchedLineIgnores(); + } catch (\PhpParser\Error $e) { + $fileErrors[] = (new Error($e->getRawMessage(), $file, $e->getStartLine() !== -1 ? $e->getStartLine() : null, $e))->withIdentifier('phpstan.parse'); + } catch (ParserErrorsException $e) { + foreach ($e->getErrors() as $error) { + $fileErrors[] = (new Error($error->getMessage(), $e->getParsedFile() ?? $file, $error->getLine() !== -1 ? $error->getStartLine() : null, $e))->withIdentifier('phpstan.parse'); + } + } catch (AnalysedCodeException $e) { + $fileErrors[] = (new Error($e->getMessage(), $file, null, $e, null, null, $e->getTip())) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + } catch (IdentifierNotFound $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s not found.', $e->getIdentifier()->getName()), $file, null, $e, null, null, 'Learn more at https://phpstan.org/user-guide/discovering-symbols')) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + } catch (UnableToCompileNode | CircularReference $e) { + $fileErrors[] = (new Error(sprintf('Reflection error: %s', $e->getMessage()), $file, null, $e)) + ->withIdentifier('phpstan.reflection') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + } + } elseif (is_dir($file)) { + $fileErrors[] = (new Error(sprintf('File %s is a directory.', $file), $file, null, false))->withIdentifier('phpstan.path'); + } else { + $fileErrors[] = (new Error(sprintf('File %s does not exist.', $file), $file, null, false))->withIdentifier('phpstan.path'); + } + + $this->restoreCollectErrorsHandler(); + + foreach ($linesToIgnore as $fileKey => $lines) { + if (count($lines) > 0) { + continue; + } + + unset($linesToIgnore[$fileKey]); + } + + foreach ($unmatchedLineIgnores as $fileKey => $lines) { + if (count($lines) > 0) { + continue; + } + + unset($unmatchedLineIgnores[$fileKey]); + } + + return new FileAnalyserResult( + $fileErrors, + $this->filteredPhpErrors, + $this->allPhpErrors, + $locallyIgnoredErrors, + $fileCollectedData, + array_values(array_unique($fileDependencies)), + $exportedNodes, + $linesToIgnore, + $unmatchedLineIgnores, + ); + } + + /** + * @param Node[] $nodes + * @return array|null> + */ + private function getLinesToIgnoreFromTokens(array $nodes): array + { + if (!isset($nodes[0])) { + return []; + } + + /** @var array|null> */ + return $nodes[0]->getAttribute('linesToIgnore', []); + } + + /** + * @param array $analysedFiles + */ + private function collectErrors(array $analysedFiles): void + { + $this->filteredPhpErrors = []; + $this->allPhpErrors = []; + set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($analysedFiles): bool { + if ((error_reporting() & $errno) === 0) { + // silence @ operator + return true; + } + + $errorMessage = sprintf('%s: %s', $this->getErrorLabel($errno), $errstr); + + $this->allPhpErrors[] = (new Error($errorMessage, $errfile, $errline, false))->withIdentifier('phpstan.php'); + + if ($errno === E_DEPRECATED) { + return true; + } + + if (!isset($analysedFiles[$errfile])) { + return true; + } + + $this->filteredPhpErrors[] = (new Error($errorMessage, $errfile, $errline, $errno === E_USER_DEPRECATED))->withIdentifier('phpstan.php'); + + return true; + }); + } + + private function restoreCollectErrorsHandler(): void + { + restore_error_handler(); + } + + private function getErrorLabel(int $errno): string + { + switch ($errno) { + case E_ERROR: + return 'Fatal error'; + case E_WARNING: + return 'Warning'; + case E_PARSE: + return 'Parse error'; + case E_NOTICE: + return 'Notice'; + case E_DEPRECATED: + return 'Deprecated'; + case E_USER_ERROR: + return 'User error (E_USER_ERROR)'; + case E_USER_WARNING: + return 'User warning (E_USER_WARNING)'; + case E_USER_NOTICE: + return 'User notice (E_USER_NOTICE)'; + case E_USER_DEPRECATED: + return 'Deprecated (E_USER_DEPRECATED)'; + case E_STRICT: + return 'Strict error (E_STRICT)'; + } + + return 'Unknown PHP error'; + } + +} diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php new file mode 100644 index 00000000..427c3c53 --- /dev/null +++ b/src/Analyser/FileAnalyserResult.php @@ -0,0 +1,112 @@ +|null>> + */ +final class FileAnalyserResult +{ + + /** + * @param list $errors + * @param list $filteredPhpErrors + * @param list $allPhpErrors + * @param list $locallyIgnoredErrors + * @param list $collectedData + * @param list $dependencies + * @param list $exportedNodes + * @param LinesToIgnore $linesToIgnore + * @param LinesToIgnore $unmatchedLineIgnores + */ + public function __construct( + private array $errors, + private array $filteredPhpErrors, + private array $allPhpErrors, + private array $locallyIgnoredErrors, + private array $collectedData, + private array $dependencies, + private array $exportedNodes, + private array $linesToIgnore, + private array $unmatchedLineIgnores, + ) + { + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * @return list + */ + public function getFilteredPhpErrors(): array + { + return $this->filteredPhpErrors; + } + + /** + * @return list + */ + public function getAllPhpErrors(): array + { + return $this->allPhpErrors; + } + + /** + * @return list + */ + public function getLocallyIgnoredErrors(): array + { + return $this->locallyIgnoredErrors; + } + + /** + * @return list + */ + public function getCollectedData(): array + { + return $this->collectedData; + } + + /** + * @return list + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + /** + * @return list + */ + public function getExportedNodes(): array + { + return $this->exportedNodes; + } + + /** + * @return LinesToIgnore + */ + public function getLinesToIgnore(): array + { + return $this->linesToIgnore; + } + + /** + * @return LinesToIgnore + */ + public function getUnmatchedLineIgnores(): array + { + return $this->unmatchedLineIgnores; + } + +} diff --git a/src/Analyser/FinalizerResult.php b/src/Analyser/FinalizerResult.php new file mode 100644 index 00000000..4db6887a --- /dev/null +++ b/src/Analyser/FinalizerResult.php @@ -0,0 +1,50 @@ + $collectorErrors + * @param list $locallyIgnoredCollectorErrors + */ + public function __construct( + private AnalyserResult $analyserResult, + private array $collectorErrors, + private array $locallyIgnoredCollectorErrors, + ) + { + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->analyserResult->getErrors(); + } + + public function getAnalyserResult(): AnalyserResult + { + return $this->analyserResult; + } + + /** + * @return list + */ + public function getCollectorErrors(): array + { + return $this->collectorErrors; + } + + /** + * @return list + */ + public function getLocallyIgnoredCollectorErrors(): array + { + return $this->locallyIgnoredCollectorErrors; + } + +} diff --git a/src/Analyser/Ignore/IgnoreLexer.php b/src/Analyser/Ignore/IgnoreLexer.php new file mode 100644 index 00000000..420dbb08 --- /dev/null +++ b/src/Analyser/Ignore/IgnoreLexer.php @@ -0,0 +1,98 @@ + 'T_WHITESPACE', + self::TOKEN_END => 'end', + self::TOKEN_IDENTIFIER => 'identifier', + self::TOKEN_COMMA => 'comma (,)', + self::TOKEN_OPEN_PARENTHESIS => 'T_OPEN_PARENTHESIS', + self::TOKEN_CLOSE_PARENTHESIS => 'T_CLOSE_PARENTHESIS', + self::TOKEN_OTHER => 'T_OTHER', + ]; + + public const VALUE_OFFSET = 0; + public const TYPE_OFFSET = 1; + public const LINE_OFFSET = 2; + + private ?string $regexp = null; + + /** + * @return list + */ + public function tokenize(string $input): array + { + if ($this->regexp === null) { + $this->regexp = $this->generateRegexp(); + } + + $matches = Strings::matchAll($input, $this->regexp, PREG_SET_ORDER); + + $tokens = []; + $line = 1; + foreach ($matches as $match) { + /** @var self::TOKEN_* $type */ + $type = (int) $match['MARK']; + $tokens[] = [$match[0], $type, $line]; + if ($type !== self::TOKEN_END) { + continue; + } + + $line++; + } + + if (($type ?? null) !== self::TOKEN_END) { + $tokens[] = ['', self::TOKEN_END, $line]; // ensure ending token is present + } + + return $tokens; + } + + /** + * @param self::TOKEN_* $type + */ + public function getLabel(int $type): string + { + return self::LABELS[$type]; + } + + private function generateRegexp(): string + { + $patterns = [ + self::TOKEN_WHITESPACE => '[\\x09\\x20]++', + self::TOKEN_END => '(\\r?+\\n[\\x09\\x20]*+(?:\\*(?!/)\\x20?+)?|\\*/)', + self::TOKEN_IDENTIFIER => Error::PATTERN_IDENTIFIER, + self::TOKEN_COMMA => ',', + self::TOKEN_OPEN_PARENTHESIS => '\\(', + self::TOKEN_CLOSE_PARENTHESIS => '\\)', + + // everything except whitespaces and parentheses + self::TOKEN_OTHER => '([^\\s\\)\\(])++', + ]; + + foreach ($patterns as $type => &$pattern) { + $pattern = '(?:' . $pattern . ')(*MARK:' . $type . ')'; + } + + return '~' . implode('|', $patterns) . '~Asi'; + } + +} diff --git a/src/Analyser/Ignore/IgnoreParseException.php b/src/Analyser/Ignore/IgnoreParseException.php new file mode 100644 index 00000000..e03a220a --- /dev/null +++ b/src/Analyser/Ignore/IgnoreParseException.php @@ -0,0 +1,21 @@ +phpDocLine; + } + +} diff --git a/src/Analyser/Ignore/IgnoredError.php b/src/Analyser/Ignore/IgnoredError.php new file mode 100644 index 00000000..2f4fbb33 --- /dev/null +++ b/src/Analyser/Ignore/IgnoredError.php @@ -0,0 +1,101 @@ +getIdentifier() !== $identifier) { + return false; + } + } + + if ($ignoredErrorPattern !== null) { + // normalize newlines to allow working with ignore-patterns independent of used OS newline-format + $errorMessage = $error->getMessage(); + $errorMessage = str_replace(['\r\n', '\r'], '\n', $errorMessage); + $ignoredErrorPattern = str_replace([preg_quote('\r\n'), preg_quote('\r')], preg_quote('\n'), $ignoredErrorPattern); + if (Strings::match($errorMessage, $ignoredErrorPattern) === null) { + return false; + } + } + + if ($path !== null) { + $fileExcluder = new FileExcluder($fileHelper, [$path]); + $isExcluded = $fileExcluder->isExcludedFromAnalysing($error->getFilePath()); + if (!$isExcluded && $error->getTraitFilePath() !== null) { + return $fileExcluder->isExcludedFromAnalysing($error->getTraitFilePath()); + } + + return $isExcluded; + } + + return true; + } + +} diff --git a/src/Analyser/Ignore/IgnoredErrorHelper.php b/src/Analyser/Ignore/IgnoredErrorHelper.php new file mode 100644 index 00000000..27b5e927 --- /dev/null +++ b/src/Analyser/Ignore/IgnoredErrorHelper.php @@ -0,0 +1,135 @@ +ignoreErrors as $ignoreError) { + if (is_array($ignoreError)) { + if (!isset($ignoreError['message']) && !isset($ignoreError['messages']) && !isset($ignoreError['identifier'])) { + $errors[] = sprintf( + 'Ignored error %s is missing a message or an identifier.', + Json::encode($ignoreError), + ); + continue; + } + if (isset($ignoreError['messages'])) { + foreach ($ignoreError['messages'] as $message) { + $expandedIgnoreError = $ignoreError; + unset($expandedIgnoreError['messages']); + $expandedIgnoreError['message'] = $message; + $expandedIgnoreErrors[] = $expandedIgnoreError; + } + } else { + $expandedIgnoreErrors[] = $ignoreError; + } + } else { + $expandedIgnoreErrors[] = $ignoreError; + } + } + + $uniquedExpandedIgnoreErrors = []; + foreach ($expandedIgnoreErrors as $ignoreError) { + if (!isset($ignoreError['message']) && !isset($ignoreError['identifier'])) { + $uniquedExpandedIgnoreErrors[] = $ignoreError; + continue; + } + if (!isset($ignoreError['path'])) { + $uniquedExpandedIgnoreErrors[] = $ignoreError; + continue; + } + + $key = $ignoreError['path']; + if (isset($ignoreError['message'])) { + $key = sprintf("%s\n%s", $key, $ignoreError['message']); + } + if (isset($ignoreError['identifier'])) { + $key = sprintf("%s\n%s", $key, $ignoreError['identifier']); + } + if ($key === '') { + throw new ShouldNotHappenException(); + } + + if (!array_key_exists($key, $uniquedExpandedIgnoreErrors)) { + $uniquedExpandedIgnoreErrors[$key] = $ignoreError; + continue; + } + + $uniquedExpandedIgnoreErrors[$key] = [ + 'message' => $ignoreError['message'] ?? null, + 'path' => $ignoreError['path'], + 'identifier' => $ignoreError['identifier'] ?? null, + 'count' => ($uniquedExpandedIgnoreErrors[$key]['count'] ?? 1) + ($ignoreError['count'] ?? 1), + 'reportUnmatched' => ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors) || ($ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors), + ]; + } + + $expandedIgnoreErrors = array_values($uniquedExpandedIgnoreErrors); + + foreach ($expandedIgnoreErrors as $i => $ignoreError) { + $ignoreErrorEntry = [ + 'index' => $i, + 'ignoreError' => $ignoreError, + ]; + try { + if (is_array($ignoreError)) { + if (!isset($ignoreError['message']) && !isset($ignoreError['identifier'])) { + $errors[] = sprintf( + 'Ignored error %s is missing a message or an identifier.', + Json::encode($ignoreError), + ); + continue; + } + if (!isset($ignoreError['path'])) { + $otherIgnoreErrors[] = $ignoreErrorEntry; + } elseif (@is_file($ignoreError['path'])) { + $normalizedPath = $this->fileHelper->normalizePath($ignoreError['path']); + $ignoreError['path'] = $normalizedPath; + $ignoreErrorsByFile[$normalizedPath][] = $ignoreErrorEntry; + $ignoreError['realPath'] = $normalizedPath; + $expandedIgnoreErrors[$i] = $ignoreError; + } else { + $otherIgnoreErrors[] = $ignoreErrorEntry; + } + } else { + $otherIgnoreErrors[] = $ignoreErrorEntry; + } + } catch (JsonException $e) { + $errors[] = $e->getMessage(); + } + } + + return new IgnoredErrorHelperResult($this->fileHelper, $errors, $otherIgnoreErrors, $ignoreErrorsByFile, $expandedIgnoreErrors, $this->reportUnmatchedIgnoredErrors); + } + +} diff --git a/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php b/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php new file mode 100644 index 00000000..08e30796 --- /dev/null +++ b/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php @@ -0,0 +1,48 @@ + $notIgnoredErrors + * @param list $ignoredErrors + * @param list $otherIgnoreMessages + */ + public function __construct( + private array $notIgnoredErrors, + private array $ignoredErrors, + private array $otherIgnoreMessages, + ) + { + } + + /** + * @return list + */ + public function getNotIgnoredErrors(): array + { + return $this->notIgnoredErrors; + } + + /** + * @return list + */ + public function getIgnoredErrors(): array + { + return $this->ignoredErrors; + } + + /** + * @return list + */ + public function getOtherIgnoreMessages(): array + { + return $this->otherIgnoreMessages; + } + +} diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php new file mode 100644 index 00000000..88784d0c --- /dev/null +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -0,0 +1,243 @@ + $errors + * @param array> $otherIgnoreErrors + * @param array>> $ignoreErrorsByFile + * @param (string|mixed[])[] $ignoreErrors + */ + public function __construct( + private FileHelper $fileHelper, + private array $errors, + private array $otherIgnoreErrors, + private array $ignoreErrorsByFile, + private array $ignoreErrors, + private bool $reportUnmatchedIgnoredErrors, + ) + { + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * @param Error[] $errors + * @param string[] $analysedFiles + */ + public function process( + array $errors, + bool $onlyFiles, + array $analysedFiles, + bool $hasInternalErrors, + ): IgnoredErrorHelperProcessedResult + { + $unmatchedIgnoredErrors = $this->ignoreErrors; + $stringErrors = []; + + $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool { + $shouldBeIgnored = false; + if (is_string($ignore)) { + $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, $ignore, null, null); + if ($shouldBeIgnored) { + unset($unmatchedIgnoredErrors[$i]); + } + } else { + if (isset($ignore['path'])) { + $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, $ignore['path']); + if ($shouldBeIgnored) { + if (isset($ignore['count'])) { + $realCount = $unmatchedIgnoredErrors[$i]['realCount'] ?? 0; + $realCount++; + $unmatchedIgnoredErrors[$i]['realCount'] = $realCount; + + if (!isset($unmatchedIgnoredErrors[$i]['file'])) { + $unmatchedIgnoredErrors[$i]['file'] = $error->getFile(); + $unmatchedIgnoredErrors[$i]['line'] = $error->getLine(); + } + + if ($realCount > $ignore['count']) { + $shouldBeIgnored = false; + } + } else { + unset($unmatchedIgnoredErrors[$i]); + } + } + } elseif (isset($ignore['paths'])) { + foreach ($ignore['paths'] as $j => $ignorePath) { + $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, $ignorePath); + if (!$shouldBeIgnored) { + continue; + } + + if (isset($unmatchedIgnoredErrors[$i])) { + if (!is_array($unmatchedIgnoredErrors[$i])) { + throw new ShouldNotHappenException(); + } + unset($unmatchedIgnoredErrors[$i]['paths'][$j]); + if (isset($unmatchedIgnoredErrors[$i]['paths']) && count($unmatchedIgnoredErrors[$i]['paths']) === 0) { + unset($unmatchedIgnoredErrors[$i]); + } + } + break; + } + } else { + $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, null); + if ($shouldBeIgnored) { + unset($unmatchedIgnoredErrors[$i]); + } + } + } + + if ($shouldBeIgnored) { + if (!$error->canBeIgnored()) { + $stringErrors[] = sprintf( + 'Error message "%s" cannot be ignored, use excludePaths instead.', + $error->getMessage(), + ); + return true; + } + return false; + } + + return true; + }; + + $ignoredErrors = []; + foreach ($errors as $errorIndex => $error) { + $filePath = $this->fileHelper->normalizePath($error->getFilePath()); + if (isset($this->ignoreErrorsByFile[$filePath])) { + foreach ($this->ignoreErrorsByFile[$filePath] as $ignoreError) { + $i = $ignoreError['index']; + $ignore = $ignoreError['ignoreError']; + $result = $processIgnoreError($error, $i, $ignore); + if (!$result) { + unset($errors[$errorIndex]); + $ignoredErrors[] = [$error, $ignore]; + continue 2; + } + } + } + + $traitFilePath = $error->getTraitFilePath(); + if ($traitFilePath !== null) { + $normalizedTraitFilePath = $this->fileHelper->normalizePath($traitFilePath); + if (isset($this->ignoreErrorsByFile[$normalizedTraitFilePath])) { + foreach ($this->ignoreErrorsByFile[$normalizedTraitFilePath] as $ignoreError) { + $i = $ignoreError['index']; + $ignore = $ignoreError['ignoreError']; + $result = $processIgnoreError($error, $i, $ignore); + if (!$result) { + unset($errors[$errorIndex]); + $ignoredErrors[] = [$error, $ignore]; + continue 2; + } + } + } + } + + foreach ($this->otherIgnoreErrors as $ignoreError) { + $i = $ignoreError['index']; + $ignore = $ignoreError['ignoreError']; + + $result = $processIgnoreError($error, $i, $ignore); + if (!$result) { + unset($errors[$errorIndex]); + $ignoredErrors[] = [$error, $ignore]; + continue 2; + } + } + } + + $errors = array_values($errors); + + foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { + if (!isset($unmatchedIgnoredError['count']) || !isset($unmatchedIgnoredError['realCount'])) { + continue; + } + + if ($unmatchedIgnoredError['realCount'] <= $unmatchedIgnoredError['count']) { + continue; + } + + $errors[] = (new Error(sprintf( + 'Ignored error pattern %s is expected to occur %d %s, but occurred %d %s.', + IgnoredError::stringifyPattern($unmatchedIgnoredError), + $unmatchedIgnoredError['count'], + $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', + $unmatchedIgnoredError['realCount'], + $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', + ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + } + + $analysedFilesKeys = array_fill_keys($analysedFiles, true); + + if (!$hasInternalErrors) { + foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { + $reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; + if ($reportUnmatched === false) { + continue; + } + if ( + isset($unmatchedIgnoredError['count']) + && isset($unmatchedIgnoredError['realCount']) + && (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles) + ) { + if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) { + $errors[] = (new Error(sprintf( + 'Ignored error pattern %s is expected to occur %d %s, but occurred only %d %s.', + IgnoredError::stringifyPattern($unmatchedIgnoredError), + $unmatchedIgnoredError['count'], + $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', + $unmatchedIgnoredError['realCount'], + $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', + ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + } + } elseif (isset($unmatchedIgnoredError['realPath'])) { + if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) { + continue; + } + + $errors[] = (new Error( + sprintf( + 'Ignored error pattern %s was not matched in reported errors.', + IgnoredError::stringifyPattern($unmatchedIgnoredError), + ), + $unmatchedIgnoredError['realPath'], + null, + false, + ))->withIdentifier('ignore.unmatched'); + } elseif (!$onlyFiles) { + $stringErrors[] = sprintf( + 'Ignored error pattern %s was not matched in reported errors.', + IgnoredError::stringifyPattern($unmatchedIgnoredError), + ); + } + } + } + + return new IgnoredErrorHelperProcessedResult($errors, $ignoredErrors, $stringErrors); + } + +} diff --git a/src/Analyser/ImpurePoint.php b/src/Analyser/ImpurePoint.php new file mode 100644 index 00000000..7c7518da --- /dev/null +++ b/src/Analyser/ImpurePoint.php @@ -0,0 +1,61 @@ +scope; + } + + /** + * @return Node\Expr|Node\Stmt|VirtualNode + */ + public function getNode() + { + return $this->node; + } + + /** + * @return ImpurePointIdentifier + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getDescription(): string + { + return $this->description; + } + + public function isCertain(): bool + { + return $this->certain; + } + +} diff --git a/src/Analyser/InternalError.php b/src/Analyser/InternalError.php new file mode 100644 index 00000000..74e03354 --- /dev/null +++ b/src/Analyser/InternalError.php @@ -0,0 +1,105 @@ + + */ +final class InternalError implements JsonSerializable +{ + + public const STACK_TRACE_METADATA_KEY = 'stackTrace'; + + public const STACK_TRACE_AS_STRING_METADATA_KEY = 'stackTraceAsString'; + + /** + * @param Trace $trace + */ + public function __construct( + private string $message, + private string $contextDescription, + private array $trace, + private ?string $traceAsString, + private bool $shouldReportBug, + ) + { + } + + /** + * @return Trace + */ + public static function prepareTrace(Throwable $exception): array + { + $trace = array_map(static fn (array $trace) => [ + 'file' => $trace['file'] ?? null, + 'line' => $trace['line'] ?? null, + ], $exception->getTrace()); + + array_unshift($trace, [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]); + + return $trace; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getContextDescription(): string + { + return $this->contextDescription; + } + + /** + * @return Trace + */ + public function getTrace(): array + { + return $this->trace; + } + + public function getTraceAsString(): ?string + { + return $this->traceAsString; + } + + public function shouldReportBug(): bool + { + return $this->shouldReportBug; + } + + /** + * @param mixed[] $json + */ + public static function decode(array $json): self + { + return new self($json['message'], $json['contextDescription'], $json['trace'], $json['traceAsString'], $json['shouldReportBug']); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'message' => $this->message, + 'contextDescription' => $this->contextDescription, + 'trace' => $this->trace, + 'traceAsString' => $this->traceAsString, + 'shouldReportBug' => $this->shouldReportBug, + ]; + } + +} diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php new file mode 100644 index 00000000..a19b0178 --- /dev/null +++ b/src/Analyser/InternalScopeFactory.php @@ -0,0 +1,43 @@ + $expressionTypes + * @param array $nativeExpressionTypes + * @param array $conditionalExpressions + * @param list $inClosureBindScopeClasses + * @param array $currentlyAssignedExpressions + * @param array $currentlyAllowedUndefinedExpressions + * @param list $inFunctionCallsStack + */ + public function create( + ScopeContext $context, + bool $declareStrictTypes = false, + PhpFunctionFromParserNodeReflection|null $function = null, + ?string $namespace = null, + array $expressionTypes = [], + array $nativeExpressionTypes = [], + array $conditionalExpressions = [], + array $inClosureBindScopeClasses = [], + ?ParametersAcceptor $anonymousFunctionReflection = null, + bool $inFirstLevelStatement = true, + array $currentlyAssignedExpressions = [], + array $currentlyAllowedUndefinedExpressions = [], + array $inFunctionCallsStack = [], + bool $afterExtractCall = false, + ?Scope $parentScope = null, + bool $nativeTypesPromoted = false, + ): MutatingScope; + +} diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php new file mode 100644 index 00000000..a476a75d --- /dev/null +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -0,0 +1,81 @@ +container->getByType(ReflectionProvider::class), + $this->container->getByType(InitializerExprTypeResolver::class), + $this->container->getByType(DynamicReturnTypeExtensionRegistryProvider::class)->getRegistry(), + $this->container->getByType(ExpressionTypeResolverExtensionRegistryProvider::class)->getRegistry(), + $this->container->getByType(ExprPrinter::class), + $this->container->getByType(TypeSpecifier::class), + $this->container->getByType(PropertyReflectionFinder::class), + $this->container->getService('currentPhpVersionSimpleParser'), + $this->container->getByType(NodeScopeResolver::class), + $this->container->getByType(RicherScopeGetTypeHelper::class), + $this->container->getByType(ConstantResolver::class), + $context, + $this->container->getByType(PhpVersion::class), + $this->container->getByType(AttributeReflectionFactory::class), + $this->container->getParameter('phpVersion'), + $declareStrictTypes, + $function, + $namespace, + $expressionTypes, + $nativeExpressionTypes, + $conditionalExpressions, + $inClosureBindScopeClasses, + $anonymousFunctionReflection, + $inFirstLevelStatement, + $currentlyAssignedExpressions, + $currentlyAllowedUndefinedExpressions, + $inFunctionCallsStack, + $afterExtractCall, + $parentScope, + $nativeTypesPromoted, + ); + } + +} diff --git a/src/Analyser/LocalIgnoresProcessor.php b/src/Analyser/LocalIgnoresProcessor.php new file mode 100644 index 00000000..de5534f0 --- /dev/null +++ b/src/Analyser/LocalIgnoresProcessor.php @@ -0,0 +1,102 @@ + $temporaryFileErrors + * @param LinesToIgnore $linesToIgnore + * @param LinesToIgnore $unmatchedLineIgnores + */ + public function process( + array $temporaryFileErrors, + array $linesToIgnore, + array $unmatchedLineIgnores, + ): LocalIgnoresProcessorResult + { + $fileErrors = []; + $locallyIgnoredErrors = []; + foreach ($temporaryFileErrors as $tmpFileError) { + $line = $tmpFileError->getLine(); + if ( + $line !== null + && $tmpFileError->canBeIgnored() + && array_key_exists($tmpFileError->getFile(), $linesToIgnore) + && array_key_exists($line, $linesToIgnore[$tmpFileError->getFile()]) + ) { + $identifiers = $linesToIgnore[$tmpFileError->getFile()][$line]; + if ($identifiers === null) { + $locallyIgnoredErrors[] = $tmpFileError; + unset($unmatchedLineIgnores[$tmpFileError->getFile()][$line]); + continue; + } + + if ($tmpFileError->getIdentifier() === null) { + $fileErrors[] = $tmpFileError; + continue; + } + + foreach ($identifiers as $i => $ignoredIdentifier) { + if ($ignoredIdentifier !== $tmpFileError->getIdentifier()) { + continue; + } + + unset($identifiers[$i]); + + if (count($identifiers) > 0) { + $linesToIgnore[$tmpFileError->getFile()][$line] = array_values($identifiers); + } else { + unset($linesToIgnore[$tmpFileError->getFile()][$line]); + } + + if ( + array_key_exists($tmpFileError->getFile(), $unmatchedLineIgnores) + && array_key_exists($line, $unmatchedLineIgnores[$tmpFileError->getFile()]) + ) { + $unmatchedIgnoredIdentifiers = $unmatchedLineIgnores[$tmpFileError->getFile()][$line]; + if (is_array($unmatchedIgnoredIdentifiers)) { + foreach ($unmatchedIgnoredIdentifiers as $j => $unmatchedIgnoredIdentifier) { + if ($ignoredIdentifier !== $unmatchedIgnoredIdentifier) { + continue; + } + + unset($unmatchedIgnoredIdentifiers[$j]); + + if (count($unmatchedIgnoredIdentifiers) > 0) { + $unmatchedLineIgnores[$tmpFileError->getFile()][$line] = array_values($unmatchedIgnoredIdentifiers); + } else { + unset($unmatchedLineIgnores[$tmpFileError->getFile()][$line]); + } + break; + } + } + } + + $locallyIgnoredErrors[] = $tmpFileError; + continue 2; + } + } + + $fileErrors[] = $tmpFileError; + } + + return new LocalIgnoresProcessorResult( + $fileErrors, + $locallyIgnoredErrors, + $linesToIgnore, + $unmatchedLineIgnores, + ); + } + +} diff --git a/src/Analyser/LocalIgnoresProcessorResult.php b/src/Analyser/LocalIgnoresProcessorResult.php new file mode 100644 index 00000000..e0757bb7 --- /dev/null +++ b/src/Analyser/LocalIgnoresProcessorResult.php @@ -0,0 +1,59 @@ + $fileErrors + * @param list $locallyIgnoredErrors + * @param LinesToIgnore $linesToIgnore + * @param LinesToIgnore $unmatchedLineIgnores + */ + public function __construct( + private array $fileErrors, + private array $locallyIgnoredErrors, + private array $linesToIgnore, + private array $unmatchedLineIgnores, + ) + { + } + + /** + * @return list + */ + public function getFileErrors(): array + { + return $this->fileErrors; + } + + /** + * @return list + */ + public function getLocallyIgnoredErrors(): array + { + return $this->locallyIgnoredErrors; + } + + /** + * @return LinesToIgnore + */ + public function getLinesToIgnore(): array + { + return $this->linesToIgnore; + } + + /** + * @return LinesToIgnore + */ + public function getUnmatchedLineIgnores(): array + { + return $this->unmatchedLineIgnores; + } + +} diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php new file mode 100644 index 00000000..ccf644cd --- /dev/null +++ b/src/Analyser/MutatingScope.php @@ -0,0 +1,6079 @@ + */ + private array $truthyScopes = []; + + /** @var array */ + private array $falseyScopes = []; + + /** @var non-empty-string|null */ + private ?string $namespace; + + private ?self $scopeOutOfFirstLevelStatement = null; + + private ?self $scopeWithPromotedNativeTypes = null; + + private static int $resolveClosureTypeDepth = 0; + + /** + * @param int|array{min: int, max: int}|null $configPhpVersion + * @param array $expressionTypes + * @param array $conditionalExpressions + * @param list $inClosureBindScopeClasses + * @param array $currentlyAssignedExpressions + * @param array $currentlyAllowedUndefinedExpressions + * @param array $nativeExpressionTypes + * @param list $inFunctionCallsStack + */ + public function __construct( + private InternalScopeFactory $scopeFactory, + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DynamicReturnTypeExtensionRegistry $dynamicReturnTypeExtensionRegistry, + private ExpressionTypeResolverExtensionRegistry $expressionTypeResolverExtensionRegistry, + private ExprPrinter $exprPrinter, + private TypeSpecifier $typeSpecifier, + private PropertyReflectionFinder $propertyReflectionFinder, + private Parser $parser, + private NodeScopeResolver $nodeScopeResolver, + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, + private ConstantResolver $constantResolver, + private ScopeContext $context, + private PhpVersion $phpVersion, + private AttributeReflectionFactory $attributeReflectionFactory, + private int|array|null $configPhpVersion, + private bool $declareStrictTypes = false, + private PhpFunctionFromParserNodeReflection|null $function = null, + ?string $namespace = null, + private array $expressionTypes = [], + private array $nativeExpressionTypes = [], + private array $conditionalExpressions = [], + private array $inClosureBindScopeClasses = [], + private ?ParametersAcceptor $anonymousFunctionReflection = null, + private bool $inFirstLevelStatement = true, + private array $currentlyAssignedExpressions = [], + private array $currentlyAllowedUndefinedExpressions = [], + private array $inFunctionCallsStack = [], + private bool $afterExtractCall = false, + private ?Scope $parentScope = null, + private bool $nativeTypesPromoted = false, + ) + { + if ($namespace === '') { + $namespace = null; + } + + $this->namespace = $namespace; + } + + /** @api */ + public function getFile(): string + { + return $this->context->getFile(); + } + + /** @api */ + public function getFileDescription(): string + { + if ($this->context->getTraitReflection() === null) { + return $this->getFile(); + } + + /** @var ClassReflection $classReflection */ + $classReflection = $this->context->getClassReflection(); + + $className = $classReflection->getDisplayName(); + if (!$classReflection->isAnonymous()) { + $className = sprintf('class %s', $className); + } + + $traitReflection = $this->context->getTraitReflection(); + if ($traitReflection->getFileName() === null) { + throw new ShouldNotHappenException(); + } + + return sprintf( + '%s (in context of %s)', + $traitReflection->getFileName(), + $className, + ); + } + + /** @api */ + public function isDeclareStrictTypes(): bool + { + return $this->declareStrictTypes; + } + + public function enterDeclareStrictTypes(): self + { + return $this->scopeFactory->create( + $this->context, + true, + null, + null, + $this->expressionTypes, + $this->nativeExpressionTypes, + ); + } + + /** @api */ + public function isInClass(): bool + { + return $this->context->getClassReflection() !== null; + } + + /** @api */ + public function isInTrait(): bool + { + return $this->context->getTraitReflection() !== null; + } + + /** @api */ + public function getClassReflection(): ?ClassReflection + { + return $this->context->getClassReflection(); + } + + /** @api */ + public function getTraitReflection(): ?ClassReflection + { + return $this->context->getTraitReflection(); + } + + /** + * @api + */ + public function getFunction(): ?PhpFunctionFromParserNodeReflection + { + return $this->function; + } + + /** @api */ + public function getFunctionName(): ?string + { + return $this->function !== null ? $this->function->getName() : null; + } + + /** @api */ + public function getNamespace(): ?string + { + return $this->namespace; + } + + /** @api */ + public function getParentScope(): ?Scope + { + return $this->parentScope; + } + + /** @api */ + public function canAnyVariableExist(): bool + { + return ($this->function === null && !$this->isInAnonymousFunction()) || $this->afterExtractCall; + } + + public function afterExtractCall(): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + [], + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + true, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function afterClearstatcacheCall(): self + { + $expressionTypes = $this->expressionTypes; + foreach (array_keys($expressionTypes) as $exprString) { + // list from https://www.php.net/manual/en/function.clearstatcache.php + + // stat(), lstat(), file_exists(), is_writable(), is_readable(), is_executable(), is_file(), is_dir(), is_link(), filectime(), fileatime(), filemtime(), fileinode(), filegroup(), fileowner(), filesize(), filetype(), and fileperms(). + foreach ([ + 'stat', + 'lstat', + 'file_exists', + 'is_writable', + 'is_writeable', + 'is_readable', + 'is_executable', + 'is_file', + 'is_dir', + 'is_link', + 'filectime', + 'fileatime', + 'filemtime', + 'fileinode', + 'filegroup', + 'fileowner', + 'filesize', + 'filetype', + 'fileperms', + ] as $functionName) { + if (!str_starts_with($exprString, $functionName . '(') && !str_starts_with($exprString, '\\' . $functionName . '(')) { + continue; + } + + unset($expressionTypes[$exprString]); + continue 2; + } + } + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function afterOpenSslCall(string $openSslFunctionName): self + { + $expressionTypes = $this->expressionTypes; + + if (in_array($openSslFunctionName, [ + 'openssl_cipher_iv_length', + 'openssl_cms_decrypt', + 'openssl_cms_encrypt', + 'openssl_cms_read', + 'openssl_cms_sign', + 'openssl_cms_verify', + 'openssl_csr_export_to_file', + 'openssl_csr_export', + 'openssl_csr_get_public_key', + 'openssl_csr_get_subject', + 'openssl_csr_new', + 'openssl_csr_sign', + 'openssl_decrypt', + 'openssl_dh_compute_key', + 'openssl_digest', + 'openssl_encrypt', + 'openssl_get_curve_names', + 'openssl_get_privatekey', + 'openssl_get_publickey', + 'openssl_open', + 'openssl_pbkdf2', + 'openssl_pkcs12_export_to_file', + 'openssl_pkcs12_export', + 'openssl_pkcs12_read', + 'openssl_pkcs7_decrypt', + 'openssl_pkcs7_encrypt', + 'openssl_pkcs7_read', + 'openssl_pkcs7_sign', + 'openssl_pkcs7_verify', + 'openssl_pkey_derive', + 'openssl_pkey_export_to_file', + 'openssl_pkey_export', + 'openssl_pkey_get_private', + 'openssl_pkey_get_public', + 'openssl_pkey_new', + 'openssl_private_decrypt', + 'openssl_private_encrypt', + 'openssl_public_decrypt', + 'openssl_public_encrypt', + 'openssl_random_pseudo_bytes', + 'openssl_seal', + 'openssl_sign', + 'openssl_spki_export_challenge', + 'openssl_spki_export', + 'openssl_spki_new', + 'openssl_spki_verify', + 'openssl_verify', + 'openssl_x509_checkpurpose', + 'openssl_x509_export_to_file', + 'openssl_x509_export', + 'openssl_x509_fingerprint', + 'openssl_x509_read', + 'openssl_x509_verify', + ], true)) { + unset($expressionTypes['\openssl_error_string()']); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** @api */ + public function hasVariableType(string $variableName): TrinaryLogic + { + if ($this->isGlobalVariable($variableName)) { + return TrinaryLogic::createYes(); + } + + $varExprString = '$' . $variableName; + if (!isset($this->expressionTypes[$varExprString])) { + if ($this->canAnyVariableExist()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); + } + + return $this->expressionTypes[$varExprString]->getCertainty(); + } + + /** @api */ + public function getVariableType(string $variableName): Type + { + if ($this->hasVariableType($variableName)->maybe()) { + if ($variableName === 'argc') { + return IntegerRangeType::fromInterval(1, null); + } + if ($variableName === 'argv') { + return TypeCombinator::intersect( + new ArrayType(new IntegerType(), new StringType()), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + } + if ($this->canAnyVariableExist()) { + return new MixedType(); + } + } + + if ($this->isGlobalVariable($variableName)) { + return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true)); + } + + if ($this->hasVariableType($variableName)->no()) { + throw new UndefinedVariableException($this, $variableName); + } + + $varExprString = '$' . $variableName; + if (!array_key_exists($varExprString, $this->expressionTypes)) { + return new MixedType(); + } + + return TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$varExprString]->getType()); + } + + /** + * @api + * @return list + */ + public function getDefinedVariables(): array + { + $variables = []; + foreach ($this->expressionTypes as $exprString => $holder) { + if (!$holder->getExpr() instanceof Variable) { + continue; + } + if (!$holder->getCertainty()->yes()) { + continue; + } + + $variables[] = substr($exprString, 1); + } + + return $variables; + } + + /** + * @api + * @return list + */ + public function getMaybeDefinedVariables(): array + { + $variables = []; + foreach ($this->expressionTypes as $exprString => $holder) { + if (!$holder->getExpr() instanceof Variable) { + continue; + } + if (!$holder->getCertainty()->maybe()) { + continue; + } + + $variables[] = substr($exprString, 1); + } + + return $variables; + } + + private function isGlobalVariable(string $variableName): bool + { + return in_array($variableName, self::SUPERGLOBAL_VARIABLES, true); + } + + /** @api */ + public function hasConstant(Name $name): bool + { + $isCompilerHaltOffset = $name->toString() === '__COMPILER_HALT_OFFSET__'; + if ($isCompilerHaltOffset) { + return $this->fileHasCompilerHaltStatementCalls(); + } + + if ($this->getGlobalConstantType($name) !== null) { + return true; + } + + return $this->reflectionProvider->hasConstant($name, $this); + } + + private function fileHasCompilerHaltStatementCalls(): bool + { + $nodes = $this->parser->parseFile($this->getFile()); + foreach ($nodes as $node) { + if ($node instanceof Node\Stmt\HaltCompiler) { + return true; + } + } + + return false; + } + + /** @api */ + public function isInAnonymousFunction(): bool + { + return $this->anonymousFunctionReflection !== null; + } + + /** @api */ + public function getAnonymousFunctionReflection(): ?ParametersAcceptor + { + return $this->anonymousFunctionReflection; + } + + /** @api */ + public function getAnonymousFunctionReturnType(): ?Type + { + if ($this->anonymousFunctionReflection === null) { + return null; + } + + return $this->anonymousFunctionReflection->getReturnType(); + } + + /** @api */ + public function getType(Expr $node): Type + { + if ($node instanceof GetIterableKeyTypeExpr) { + return $this->getIterableKeyType($this->getType($node->getExpr())); + } + if ($node instanceof GetIterableValueTypeExpr) { + return $this->getIterableValueType($this->getType($node->getExpr())); + } + if ($node instanceof GetOffsetValueTypeExpr) { + return $this->getType($node->getVar())->getOffsetValueType($this->getType($node->getDim())); + } + if ($node instanceof ExistingArrayDimFetch) { + return $this->getType(new Expr\ArrayDimFetch($node->getVar(), $node->getDim())); + } + if ($node instanceof UnsetOffsetExpr) { + return $this->getType($node->getVar())->unsetOffset($this->getType($node->getDim())); + } + if ($node instanceof SetOffsetValueTypeExpr) { + return $this->getType($node->getVar())->setOffsetValueType( + $node->getDim() !== null ? $this->getType($node->getDim()) : null, + $this->getType($node->getValue()), + ); + } + if ($node instanceof SetExistingOffsetValueTypeExpr) { + return $this->getType($node->getVar())->setExistingOffsetValueType( + $this->getType($node->getDim()), + $this->getType($node->getValue()), + ); + } + if ($node instanceof TypeExpr) { + return $node->getExprType(); + } + + if ($node instanceof OriginalPropertyTypeExpr) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($node->getPropertyFetch(), $this); + if ($propertyReflection === null) { + return new ErrorType(); + } + + return $propertyReflection->getReadableType(); + } + + $key = $this->getNodeKey($node); + + if (!array_key_exists($key, $this->resolvedTypes)) { + $this->resolvedTypes[$key] = TypeUtils::resolveLateResolvableTypes($this->resolveType($key, $node)); + } + return $this->resolvedTypes[$key]; + } + + private function getNodeKey(Expr $node): string + { + $key = $this->exprPrinter->printExpr($node); + + $attributes = $node->getAttributes(); + if ( + $node instanceof Node\FunctionLike + && (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null) + && (($attributes['startFilePos'] ?? null) !== null) + ) { + $key .= '/*' . $attributes['startFilePos'] . '*/'; + } + + if (($attributes[self::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) { + $key .= '/*' . self::KEEP_VOID_ATTRIBUTE_NAME . '*/'; + } + + return $key; + } + + private function getClosureScopeCacheKey(): string + { + $parts = []; + foreach ($this->expressionTypes as $exprString => $expressionTypeHolder) { + $parts[] = sprintf('%s::%s', $exprString, $expressionTypeHolder->getType()->describe(VerbosityLevel::cache())); + } + $parts[] = '---'; + foreach ($this->nativeExpressionTypes as $exprString => $expressionTypeHolder) { + $parts[] = sprintf('%s::%s', $exprString, $expressionTypeHolder->getType()->describe(VerbosityLevel::cache())); + } + + $parts[] = sprintf(':%d', count($this->inFunctionCallsStack)); + foreach ($this->inFunctionCallsStack as [$method, $parameter]) { + if ($parameter === null) { + $parts[] = ',null'; + continue; + } + + $parts[] = sprintf(',%s', $parameter->getType()->describe(VerbosityLevel::cache())); + } + + return md5(implode("\n", $parts)); + } + + private function resolveType(string $exprString, Expr $node): Type + { + foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { + $type = $extension->getType($node, $this); + if ($type !== null) { + return $type; + } + } + + if ($node instanceof Expr\Exit_ || $node instanceof Expr\Throw_) { + return new NonAcceptingNeverType(); + } + + if (!$node instanceof Variable && $this->hasExpressionType($node)->yes()) { + return $this->expressionTypes[$exprString]->getType(); + } + + if ($node instanceof AlwaysRememberedExpr) { + return $node->getExprType(); + } + + if ($node instanceof Expr\BinaryOp\Smaller) { + return $this->getType($node->left)->isSmallerThan($this->getType($node->right), $this->phpVersion)->toBooleanType(); + } + + if ($node instanceof Expr\BinaryOp\SmallerOrEqual) { + return $this->getType($node->left)->isSmallerThanOrEqual($this->getType($node->right), $this->phpVersion)->toBooleanType(); + } + + if ($node instanceof Expr\BinaryOp\Greater) { + return $this->getType($node->right)->isSmallerThan($this->getType($node->left), $this->phpVersion)->toBooleanType(); + } + + if ($node instanceof Expr\BinaryOp\GreaterOrEqual) { + return $this->getType($node->right)->isSmallerThanOrEqual($this->getType($node->left), $this->phpVersion)->toBooleanType(); + } + + if ($node instanceof Expr\BinaryOp\Equal) { + if ( + $node->left instanceof Variable + && is_string($node->left->name) + && $node->right instanceof Variable + && is_string($node->right->name) + && $node->left->name === $node->right->name + ) { + return new ConstantBooleanType(true); + } + + $leftType = $this->getType($node->left); + $rightType = $this->getType($node->right); + + return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; + } + + if ($node instanceof Expr\BinaryOp\NotEqual) { + return $this->getType(new Expr\BooleanNot(new BinaryOp\Equal($node->left, $node->right))); + } + + if ($node instanceof Expr\Empty_) { + $result = $this->issetCheck($node->expr, static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + } + + if ($node instanceof Node\Expr\BooleanNot) { + $exprBooleanType = $this->getType($node->expr)->toBoolean(); + if ($exprBooleanType instanceof ConstantBooleanType) { + return new ConstantBooleanType(!$exprBooleanType->getValue()); + } + + return new BooleanType(); + } + + if ($node instanceof Node\Expr\BitwiseNot) { + return $this->initializerExprTypeResolver->getBitwiseNotType($node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ( + $node instanceof Node\Expr\BinaryOp\BooleanAnd + || $node instanceof Node\Expr\BinaryOp\LogicalAnd + ) { + $leftBooleanType = $this->getType($node->left)->toBoolean(); + if ($leftBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { + $noopCallback = static function (): void { + }; + $leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep()); + $rightBooleanType = $leftResult->getTruthyScope()->getType($node->right)->toBoolean(); + } else { + $rightBooleanType = $this->filterByTruthyValue($node->left)->getType($node->right)->toBoolean(); + } + + if ($rightBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $leftBooleanType->isTrue()->yes() + && $rightBooleanType->isTrue()->yes() + ) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + + if ( + $node instanceof Node\Expr\BinaryOp\BooleanOr + || $node instanceof Node\Expr\BinaryOp\LogicalOr + ) { + $leftBooleanType = $this->getType($node->left)->toBoolean(); + if ($leftBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { + $noopCallback = static function (): void { + }; + $leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep()); + $rightBooleanType = $leftResult->getFalseyScope()->getType($node->right)->toBoolean(); + } else { + $rightBooleanType = $this->filterByFalseyValue($node->left)->getType($node->right)->toBoolean(); + } + + if ($rightBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ( + $leftBooleanType->isFalse()->yes() + && $rightBooleanType->isFalse()->yes() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + if ($node instanceof Node\Expr\BinaryOp\LogicalXor) { + $leftBooleanType = $this->getType($node->left)->toBoolean(); + $rightBooleanType = $this->getType($node->right)->toBoolean(); + + if ( + $leftBooleanType instanceof ConstantBooleanType + && $rightBooleanType instanceof ConstantBooleanType + ) { + return new ConstantBooleanType( + $leftBooleanType->getValue() xor $rightBooleanType->getValue(), + ); + } + + return new BooleanType(); + } + + if ($node instanceof Expr\BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResult($this, $node)->type; + } + + if ($node instanceof Expr\BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResult($this, $node)->type; + } + + if ($node instanceof Expr\Instanceof_) { + $expressionType = $this->getType($node->expr); + if ( + $this->isInTrait() + && TypeUtils::findThisType($expressionType) !== null + ) { + return new BooleanType(); + } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } + + $uncertainty = false; + + if ($node->class instanceof Node\Name) { + $unresolvedClassName = $node->class->toString(); + if ( + strtolower($unresolvedClassName) === 'static' + && $this->isInClass() + ) { + $classType = new StaticType($this->getClassReflection()); + } else { + $className = $this->resolveName($node->class); + $classType = new ObjectType($className); + } + } else { + $classType = $this->getType($node->class); + $classType = TypeTraverser::map($classType, static function (Type $type, callable $traverse) use (&$uncertainty): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type->getObjectClassNames() !== []) { + $uncertainty = true; + return $type; + } + if ($type instanceof GenericClassStringType) { + $uncertainty = true; + return $type->getGenericType(); + } + if ($type instanceof ConstantStringType) { + return new ObjectType($type->getValue()); + } + return new MixedType(); + }); + } + + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } + + $isSuperType = $classType->isSuperTypeOf($expressionType); + + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + + if ($node instanceof Node\Expr\UnaryPlus) { + return $this->getType($node->expr)->toNumber(); + } + + if ($node instanceof Expr\ErrorSuppress + || $node instanceof Expr\Assign + ) { + return $this->getType($node->expr); + } + + if ($node instanceof Node\Expr\UnaryMinus) { + return $this->initializerExprTypeResolver->getUnaryMinusType($node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Div) { + return $this->initializerExprTypeResolver->getDivType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Mod) { + return $this->initializerExprTypeResolver->getModType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($node->left, $node->right, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\AssignOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($node->var, $node->expr, fn (Expr $expr): Type => $this->getType($expr)); + } + + if ($node instanceof Expr\Clone_) { + return $this->getType($node->expr); + } + + if ($node instanceof Node\Scalar\Int_) { + return $this->initializerExprTypeResolver->getType($node, InitializerExprContext::fromScope($this)); + } elseif ($node instanceof String_) { + return $this->initializerExprTypeResolver->getType($node, InitializerExprContext::fromScope($this)); + } elseif ($node instanceof Node\Scalar\InterpolatedString) { + $resultType = null; + foreach ($node->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $partType = new ConstantStringType($part->value); + } else { + $partType = $this->getType($part)->toString(); + } + if ($resultType === null) { + $resultType = $partType; + continue; + } + + $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); + } + + return $resultType ?? new ConstantStringType(''); + } elseif ($node instanceof Node\Scalar\Float_) { + return $this->initializerExprTypeResolver->getType($node, InitializerExprContext::fromScope($this)); + } elseif ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + if ($this->reflectionProvider->hasFunction($node->name, $this)) { + $function = $this->reflectionProvider->getFunction($node->name, $this); + return $this->createFirstClassCallable( + $function, + $function->getVariants(), + ); + } + + return new ObjectType(Closure::class); + } + + $callableType = $this->getType($node->name); + if (!$callableType->isCallable()->yes()) { + return new ObjectType(Closure::class); + } + + return $this->createFirstClassCallable( + null, + $callableType->getCallableParametersAcceptors($this), + ); + } + + if ($node instanceof MethodCall) { + if (!$node->name instanceof Node\Identifier) { + return new ObjectType(Closure::class); + } + + $varType = $this->getType($node->var); + $method = $this->getMethodReflection($varType, $node->name->toString()); + if ($method === null) { + return new ObjectType(Closure::class); + } + + return $this->createFirstClassCallable( + $method, + $method->getVariants(), + ); + } + + if ($node instanceof Expr\StaticCall) { + if (!$node->class instanceof Name) { + return new ObjectType(Closure::class); + } + + if (!$node->name instanceof Node\Identifier) { + return new ObjectType(Closure::class); + } + + $classType = $this->resolveTypeByNameWithLateStaticBinding($node->class, $node->name); + $methodName = $node->name->toString(); + if (!$classType->hasMethod($methodName)->yes()) { + return new ObjectType(Closure::class); + } + + $method = $classType->getMethod($methodName, $this); + return $this->createFirstClassCallable( + $method, + $method->getVariants(), + ); + } + + if ($node instanceof New_) { + return new ErrorType(); + } + + throw new ShouldNotHappenException(); + } elseif ($node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) { + $parameters = []; + $isVariadic = false; + $firstOptionalParameterIndex = null; + foreach ($node->params as $i => $param) { + $isOptionalCandidate = $param->default !== null || $param->variadic; + + if ($isOptionalCandidate) { + if ($firstOptionalParameterIndex === null) { + $firstOptionalParameterIndex = $i; + } + } else { + $firstOptionalParameterIndex = null; + } + } + + foreach ($node->params as $i => $param) { + if ($param->variadic) { + $isVariadic = true; + } + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $parameters[] = new NativeParameterReflection( + $param->var->name, + $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, + $this->getFunctionType($param->type, $this->isParameterValueNullable($param), false), + $param->byRef + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $param->variadic, + $param->default !== null ? $this->getType($param->default) : null, + ); + } + + $callableParameters = null; + $arrayMapArgs = $node->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + if ($arrayMapArgs !== null) { + $callableParameters = []; + foreach ($arrayMapArgs as $funcCallArg) { + $callableParameters[] = new DummyParameter('item', $this->getType($funcCallArg->value)->getIterableValueType(), false, PassedByReference::createNo(), false, null); + } + } else { + $inFunctionCallsStackCount = count($this->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $this->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $callableParameters = $this->nodeScopeResolver->createCallableParameters($this, $node, null, $inParameter->getType()); + } + } + } + + if ($node instanceof Expr\ArrowFunction) { + $arrowScope = $this->enterArrowFunctionWithoutReflection($node, $callableParameters); + + if ($node->expr instanceof Expr\Yield_ || $node->expr instanceof Expr\YieldFrom) { + $yieldNode = $node->expr; + + if ($yieldNode instanceof Expr\Yield_) { + if ($yieldNode->key === null) { + $keyType = new IntegerType(); + } else { + $keyType = $arrowScope->getType($yieldNode->key); + } + + if ($yieldNode->value === null) { + $valueType = new NullType(); + } else { + $valueType = $arrowScope->getType($yieldNode->value); + } + } else { + $yieldFromType = $arrowScope->getType($yieldNode->expr); + $keyType = $arrowScope->getIterableKeyType($yieldFromType); + $valueType = $arrowScope->getIterableValueType($yieldFromType); + } + + $returnType = new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); + } else { + $returnType = $arrowScope->getKeepVoidType($node->expr); + if ($node->returnType !== null) { + $nativeReturnType = $this->getFunctionType($node->returnType, false, false); + $returnType = self::intersectButNotNever($nativeReturnType, $returnType); + } + } + + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( + new Node\Stmt\Expression($node->expr), + $node->expr, + $arrowScope, + static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $arrowScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if (!$node instanceof PropertyAssignNode) { + return; + } + + $arrowFunctionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + }, + ExpressionContext::createDeep(), + ); + $throwPoints = $arrowFunctionExprResult->getThrowPoints(); + $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); + $usedVariables = []; + } else { + $cachedTypes = $node->getAttribute('phpstanCachedTypes', []); + $cacheKey = $this->getClosureScopeCacheKey(); + if (array_key_exists($cacheKey, $cachedTypes)) { + $cachedClosureData = $cachedTypes[$cacheKey]; + + return new ClosureType( + $parameters, + $cachedClosureData['returnType'], + $isVariadic, + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + [], + $cachedClosureData['throwPoints'], + $cachedClosureData['impurePoints'], + $cachedClosureData['invalidateExpressions'], + $cachedClosureData['usedVariables'], + TrinaryLogic::createYes(), + ); + } + if (self::$resolveClosureTypeDepth >= 2) { + return new ClosureType( + $parameters, + $this->getFunctionType($node->returnType, false, false), + $isVariadic, + ); + } + + self::$resolveClosureTypeDepth++; + + $closureScope = $this->enterAnonymousFunctionWithoutReflection($node, $callableParameters); + $closureReturnStatements = []; + $closureYieldStatements = []; + $onlyNeverExecutionEnds = null; + $closureImpurePoints = []; + $invalidateExpressions = []; + + try { + $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($node, $node->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + + if ($node instanceof ExecutionEndNode) { + if ($node->getStatementResult()->isAlwaysTerminating()) { + foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { + if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { + $onlyNeverExecutionEnds = false; + continue; + } + + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } + + break; + } + + if (count($node->getStatementResult()->getExitPoints()) === 0) { + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } + } + } else { + $onlyNeverExecutionEnds = false; + } + + return; + } + + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } + + if (!$node instanceof Expr\Yield_ && !$node instanceof Expr\YieldFrom) { + return; + } + + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } + + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + + $returnTypes = []; + $hasNull = false; + foreach ($closureReturnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } + + $returnTypes[] = $returnScope->getType($returnNode->expr); + } + + if (count($returnTypes) === 0) { + if ($onlyNeverExecutionEnds === true && !$hasNull) { + $returnType = new NonAcceptingNeverType(); + } else { + $returnType = new VoidType(); + } + } else { + if ($onlyNeverExecutionEnds === true) { + $returnTypes[] = new NonAcceptingNeverType(); + } + if ($hasNull) { + $returnTypes[] = new NullType(); + } + $returnType = TypeCombinator::union(...$returnTypes); + } + + if (count($closureYieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { + if ($yieldNode instanceof Expr\Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $yieldScope->getType($yieldNode->key); + } + + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $yieldScope->getType($yieldNode->value); + } + + continue; + } + + $yieldFromType = $yieldScope->getType($yieldNode->expr); + $keyTypes[] = $yieldScope->getIterableKeyType($yieldFromType); + $valueTypes[] = $yieldScope->getIterableValueType($yieldFromType); + } + + $returnType = new GenericObjectType(Generator::class, [ + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes), + new MixedType(), + $returnType, + ]); + } else { + if ($node->returnType !== null) { + $nativeReturnType = $this->getFunctionType($node->returnType, false, false); + $returnType = self::intersectButNotNever($nativeReturnType, $returnType); + } + } + + $usedVariables = []; + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $usedVariables[] = $use->var->name; + } + + foreach ($node->uses as $use) { + if (!$use->byRef) { + continue; + } + + $impurePoints[] = new ImpurePoint( + $this, + $node, + 'functionCall', + 'call to a Closure with by-ref use', + true, + ); + break; + } + } + + foreach ($parameters as $parameter) { + if ($parameter->passedByReference()->no()) { + continue; + } + + $impurePoints[] = new ImpurePoint( + $this, + $node, + 'functionCall', + 'call to a Closure with by-ref parameter', + true, + ); + } + + $throwPointsForClosureType = array_map(static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit() ? SimpleThrowPoint::createExplicit($throwPoint->getType(), $throwPoint->canContainAnyThrowable()) : SimpleThrowPoint::createImplicit(), $throwPoints); + $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); + + $cachedTypes = $node->getAttribute('phpstanCachedTypes', []); + $cachedTypes[$this->getClosureScopeCacheKey()] = [ + 'returnType' => $returnType, + 'throwPoints' => $throwPointsForClosureType, + 'impurePoints' => $impurePointsForClosureType, + 'invalidateExpressions' => $invalidateExpressions, + 'usedVariables' => $usedVariables, + ]; + $node->setAttribute('phpstanCachedTypes', $cachedTypes); + + return new ClosureType( + $parameters, + $returnType, + $isVariadic, + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + [], + $throwPointsForClosureType, + $impurePointsForClosureType, + $invalidateExpressions, + $usedVariables, + TrinaryLogic::createYes(), + ); + } elseif ($node instanceof New_) { + if ($node->class instanceof Name) { + $type = $this->exactInstantiation($node, $node->class->toString()); + if ($type !== null) { + return $type; + } + + $lowercasedClassName = strtolower($node->class->toString()); + if ($lowercasedClassName === 'static') { + if (!$this->isInClass()) { + return new ErrorType(); + } + + return new StaticType($this->getClassReflection()); + } + if ($lowercasedClassName === 'parent') { + return new NonexistentParentClassType(); + } + + return new ObjectType($node->class->toString()); + } + if ($node->class instanceof Node\Stmt\Class_) { + $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($node->class, $this); + + return new ObjectType($anonymousClassReflection->getName()); + } + + $exprType = $this->getType($node->class); + return $exprType->getObjectTypeOrClassStringObjectType(); + + } elseif ($node instanceof Array_) { + return $this->initializerExprTypeResolver->getArrayType($node, fn (Expr $expr): Type => $this->getType($expr)); + } elseif ($node instanceof Int_) { + return $this->getType($node->expr)->toInteger(); + } elseif ($node instanceof Bool_) { + return $this->getType($node->expr)->toBoolean(); + } elseif ($node instanceof Double) { + return $this->getType($node->expr)->toFloat(); + } elseif ($node instanceof Node\Expr\Cast\String_) { + return $this->getType($node->expr)->toString(); + } elseif ($node instanceof Node\Expr\Cast\Array_) { + return $this->getType($node->expr)->toArray(); + } elseif ($node instanceof Node\Scalar\MagicConst) { + return $this->initializerExprTypeResolver->getType($node, InitializerExprContext::fromScope($this)); + } elseif ($node instanceof Object_) { + $castToObject = static function (Type $type): Type { + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + $objects = []; + foreach ($constantArrays as $constantArray) { + $properties = []; + $optionalProperties = []; + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + if (!$keyType instanceof ConstantStringType) { + // an object with integer properties is >weird< + continue; + } + $valueType = $constantArray->getValueTypes()[$i]; + $optional = $constantArray->isOptionalKey($i); + if ($optional) { + $optionalProperties[] = $keyType->getValue(); + } + $properties[$keyType->getValue()] = $valueType; + } + + $objects[] = TypeCombinator::intersect(new ObjectShapeType($properties, $optionalProperties), new ObjectType(stdClass::class)); + } + + return TypeCombinator::union(...$objects); + } + if ($type->isObject()->yes()) { + return $type; + } + + return new ObjectType('stdClass'); + }; + + $exprType = $this->getType($node->expr); + if ($exprType instanceof UnionType) { + return TypeCombinator::union(...array_map($castToObject, $exprType->getTypes())); + } + + return $castToObject($exprType); + } elseif ($node instanceof Unset_) { + return new NullType(); + } elseif ($node instanceof Expr\PostInc || $node instanceof Expr\PostDec) { + return $this->getType($node->var); + } elseif ($node instanceof Expr\PreInc || $node instanceof Expr\PreDec) { + $varType = $this->getType($node->var); + $varScalars = $varType->getConstantScalarValues(); + + if (count($varScalars) > 0) { + $newTypes = []; + + foreach ($varScalars as $varValue) { + if ($node instanceof Expr\PreInc) { + if (!is_bool($varValue)) { + ++$varValue; + } + } elseif (is_numeric($varValue)) { + --$varValue; + } + + $newTypes[] = $this->getTypeFromValue($varValue); + } + return TypeCombinator::union(...$newTypes); + } elseif ($varType->isString()->yes()) { + if ($varType->isLiteralString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); + } + + if ($varType->isNumericString()->yes()) { + return new BenevolentUnionType([ + new IntegerType(), + new FloatType(), + ]); + } + + return new BenevolentUnionType([ + new StringType(), + new IntegerType(), + new FloatType(), + ]); + } + + if ($node instanceof Expr\PreInc) { + return $this->getType(new BinaryOp\Plus($node->var, new Node\Scalar\Int_(1))); + } + + return $this->getType(new BinaryOp\Minus($node->var, new Node\Scalar\Int_(1))); + } elseif ($node instanceof Expr\Yield_) { + $functionReflection = $this->getFunction(); + if ($functionReflection === null) { + return new MixedType(); + } + + $returnType = $functionReflection->getReturnType(); + $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); + if ($generatorSendType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorSendType; + } elseif ($node instanceof Expr\YieldFrom) { + $yieldFromType = $this->getType($node->expr); + $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); + if ($generatorReturnType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorReturnType; + } elseif ($node instanceof Expr\Match_) { + $cond = $node->cond; + $condType = $this->getType($cond); + $types = []; + + $matchScope = $this; + $arms = $node->arms; + if ($condType->isEnum()->yes()) { + // enum match analysis would work even without this if branch + // but would be much slower + // this avoids using ObjectType::$subtractedType which is slow for huge enums + // because of repeated union type normalization + $enumCases = $condType->getEnumCases(); + if (count($enumCases) > 0) { + $indexedEnumCases = []; + foreach ($enumCases as $enumCase) { + $indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase; + } + $unusedIndexedEnumCases = $indexedEnumCases; + + foreach ($arms as $i => $arm) { + if ($arm->conds === null) { + continue; + } + + $conditionCases = []; + foreach ($arm->conds as $armCond) { + if (!$armCond instanceof Expr\ClassConstFetch) { + continue 2; + } + if (!$armCond->class instanceof Name) { + continue 2; + } + if (!$armCond->name instanceof Node\Identifier) { + continue 2; + } + $fetchedClassName = $this->resolveName($armCond->class); + $loweredFetchedClassName = strtolower($fetchedClassName); + if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) { + continue 2; + } + + $caseName = $armCond->name->toString(); + if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) { + continue 2; + } + + $conditionCases[] = $indexedEnumCases[$loweredFetchedClassName][$caseName]; + unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]); + } + + $conditionCasesCount = count($conditionCases); + if ($conditionCasesCount === 0) { + throw new ShouldNotHappenException(); + } elseif ($conditionCasesCount === 1) { + $conditionCaseType = $conditionCases[0]; + } else { + $conditionCaseType = new UnionType($conditionCases); + } + + $types[] = $matchScope->addTypeToExpression( + $cond, + $conditionCaseType, + )->getType($arm->body); + unset($arms[$i]); + } + + $remainingCases = []; + foreach ($unusedIndexedEnumCases as $cases) { + foreach ($cases as $case) { + $remainingCases[] = $case; + } + } + + $remainingCasesCount = count($remainingCases); + if ($remainingCasesCount === 0) { + $remainingType = new NeverType(); + } elseif ($remainingCasesCount === 1) { + $remainingType = $remainingCases[0]; + } else { + $remainingType = new UnionType($remainingCases); + } + + $matchScope = $matchScope->addTypeToExpression($cond, $remainingType); + } + } + + foreach ($arms as $arm) { + if ($arm->conds === null) { + if ($node->hasAttribute(self::KEEP_VOID_ATTRIBUTE_NAME)) { + $arm->body->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, $node->getAttribute(self::KEEP_VOID_ATTRIBUTE_NAME)); + } + $types[] = $matchScope->getType($arm->body); + continue; + } + + if (count($arm->conds) === 0) { + throw new ShouldNotHappenException(); + } + + if (count($arm->conds) === 1) { + $filteringExpr = new BinaryOp\Identical($cond, $arm->conds[0]); + } else { + $items = []; + foreach ($arm->conds as $filteringExpr) { + $items[] = new Node\ArrayItem($filteringExpr); + } + $filteringExpr = new FuncCall( + new Name\FullyQualified('in_array'), + [ + new Arg($cond), + new Arg(new Array_($items)), + new Arg(new ConstFetch(new Name\FullyQualified('true'))), + ], + ); + } + + $filteringExprType = $matchScope->getType($filteringExpr); + + if (!$filteringExprType->isFalse()->yes()) { + $truthyScope = $matchScope->filterByTruthyValue($filteringExpr); + if ($node->hasAttribute(self::KEEP_VOID_ATTRIBUTE_NAME)) { + $arm->body->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, $node->getAttribute(self::KEEP_VOID_ATTRIBUTE_NAME)); + } + $types[] = $truthyScope->getType($arm->body); + } + + $matchScope = $matchScope->filterByFalseyValue($filteringExpr); + } + + return TypeCombinator::union(...$types); + } + + if ($node instanceof Expr\Isset_) { + $issetResult = true; + foreach ($node->vars as $var) { + $result = $this->issetCheck($var, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } + + continue; + } + + $issetResult = $result; + } + + if ($issetResult === null) { + return new BooleanType(); + } + + return new ConstantBooleanType($issetResult); + } + + if ($node instanceof Expr\AssignOp\Coalesce) { + return $this->getType(new BinaryOp\Coalesce($node->var, $node->expr, $node->getAttributes())); + } + + if ($node instanceof Expr\BinaryOp\Coalesce) { + $issetLeftExpr = new Expr\Isset_([$node->left]); + $leftType = $this->filterByTruthyValue($issetLeftExpr)->getType($node->left); + + $result = $this->issetCheck($node->left, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result !== null && $result !== false) { + return TypeCombinator::removeNull($leftType); + } + + $rightType = $this->filterByFalseyValue($issetLeftExpr)->getType($node->right); + + if ($result === null) { + return TypeCombinator::union( + TypeCombinator::removeNull($leftType), + $rightType, + ); + } + + return $rightType; + } + + if ($node instanceof ConstFetch) { + $constName = (string) $node->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } + + $namespacedName = null; + if (!$node->name->isFullyQualified() && $this->getNamespace() !== null) { + $namespacedName = new FullyQualified([$this->getNamespace(), $node->name->toString()]); + } + $globalName = new FullyQualified($node->name->toString()); + + foreach ([$namespacedName, $globalName] as $name) { + if ($name === null) { + continue; + } + $constFetch = new ConstFetch($name); + if ($this->hasExpressionType($constFetch)->yes()) { + return $this->constantResolver->resolveConstantType( + $name->toString(), + $this->expressionTypes[$this->getNodeKey($constFetch)]->getType(), + ); + } + } + + $constantType = $this->constantResolver->resolveConstant($node->name, $this); + if ($constantType !== null) { + return $constantType; + } + + return new ErrorType(); + } elseif ($node instanceof Node\Expr\ClassConstFetch && $node->name instanceof Node\Identifier) { + if ($this->hasExpressionType($node)->yes()) { + return $this->expressionTypes[$exprString]->getType(); + } + return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( + $node->class, + $node->name->name, + $this->isInClass() ? $this->getClassReflection() : null, + fn (Expr $expr): Type => $this->getType($expr), + ); + } + + if ($node instanceof Expr\Ternary) { + $noopCallback = static function (): void { + }; + $condResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->cond), $node->cond, $this, $noopCallback, ExpressionContext::createDeep()); + if ($node->if === null) { + $conditionType = $this->getType($node->cond); + $booleanConditionType = $conditionType->toBoolean(); + if ($booleanConditionType->isTrue()->yes()) { + return $condResult->getTruthyScope()->getType($node->cond); + } + + if ($booleanConditionType->isFalse()->yes()) { + return $condResult->getFalseyScope()->getType($node->else); + } + + return TypeCombinator::union( + TypeCombinator::removeFalsey($condResult->getTruthyScope()->getType($node->cond)), + $condResult->getFalseyScope()->getType($node->else), + ); + } + + $booleanConditionType = $this->getType($node->cond)->toBoolean(); + if ($booleanConditionType->isTrue()->yes()) { + return $condResult->getTruthyScope()->getType($node->if); + } + + if ($booleanConditionType->isFalse()->yes()) { + return $condResult->getFalseyScope()->getType($node->else); + } + + return TypeCombinator::union( + $condResult->getTruthyScope()->getType($node->if), + $condResult->getFalseyScope()->getType($node->else), + ); + } + + if ($node instanceof Variable && is_string($node->name)) { + if ($this->hasVariableType($node->name)->no()) { + return new ErrorType(); + } + + return $this->getVariableType($node->name); + } + + if ($node instanceof Expr\ArrayDimFetch && $node->dim !== null) { + return $this->getNullsafeShortCircuitingType( + $node->var, + $this->getTypeFromArrayDimFetch( + $node, + $this->getType($node->dim), + $this->getType($node->var), + ), + ); + } + + if ($node instanceof MethodCall && $node->name instanceof Node\Identifier) { + if ($this->nativeTypesPromoted) { + $typeCallback = function () use ($node): Type { + $methodReflection = $this->getMethodReflection( + $this->getNativeType($node->var), + $node->name->name, + ); + if ($methodReflection === null) { + return new ErrorType(); + } + + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + }; + + return $this->getNullsafeShortCircuitingType($node->var, $typeCallback()); + } + + $typeCallback = function () use ($node): Type { + $returnType = $this->methodCallReturnType( + $this->getType($node->var), + $node->name->name, + $node, + ); + if ($returnType === null) { + return new ErrorType(); + } + return $returnType; + }; + + return $this->getNullsafeShortCircuitingType($node->var, $typeCallback()); + } + + if ($node instanceof Expr\NullsafeMethodCall) { + $varType = $this->getType($node->var); + if ($varType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($varType)) { + return $this->getType(new MethodCall($node->var, $node->name, $node->args)); + } + + return TypeCombinator::union( + $this->filterByTruthyValue(new BinaryOp\NotIdentical($node->var, new ConstFetch(new Name('null')))) + ->getType(new MethodCall($node->var, $node->name, $node->args)), + new NullType(), + ); + } + + if ($node instanceof Expr\StaticCall && $node->name instanceof Node\Identifier) { + if ($this->nativeTypesPromoted) { + $typeCallback = function () use ($node): Type { + if ($node->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($node->class, $node->name); + } else { + $staticMethodCalledOnType = $this->getNativeType($node->class); + } + $methodReflection = $this->getMethodReflection( + $staticMethodCalledOnType, + $node->name->name, + ); + if ($methodReflection === null) { + return new ErrorType(); + } + + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + }; + + $callType = $typeCallback(); + if ($node->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($node->class, $callType); + } + + return $callType; + } + + $typeCallback = function () use ($node): Type { + if ($node->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($node->class, $node->name); + } else { + $staticMethodCalledOnType = TypeCombinator::removeNull($this->getType($node->class))->getObjectTypeOrClassStringObjectType(); + } + + $returnType = $this->methodCallReturnType( + $staticMethodCalledOnType, + $node->name->toString(), + $node, + ); + if ($returnType === null) { + return new ErrorType(); + } + return $returnType; + }; + + $callType = $typeCallback(); + if ($node->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($node->class, $callType); + } + + return $callType; + } + + if ($node instanceof PropertyFetch && $node->name instanceof Node\Identifier) { + if ($this->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($node, $this); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + $nativeType = $propertyReflection->getNativeType(); + + return $this->getNullsafeShortCircuitingType($node->var, $nativeType); + } + + $typeCallback = function () use ($node): Type { + $returnType = $this->propertyFetchType( + $this->getType($node->var), + $node->name->name, + $node, + ); + if ($returnType === null) { + return new ErrorType(); + } + return $returnType; + }; + + return $this->getNullsafeShortCircuitingType($node->var, $typeCallback()); + } + + if ($node instanceof Expr\NullsafePropertyFetch) { + $varType = $this->getType($node->var); + if ($varType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($varType)) { + return $this->getType(new PropertyFetch($node->var, $node->name)); + } + + return TypeCombinator::union( + $this->filterByTruthyValue(new BinaryOp\NotIdentical($node->var, new ConstFetch(new Name('null')))) + ->getType(new PropertyFetch($node->var, $node->name)), + new NullType(), + ); + } + + if ( + $node instanceof Expr\StaticPropertyFetch + && $node->name instanceof Node\VarLikeIdentifier + ) { + if ($this->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($node, $this); + if ($propertyReflection === null) { + return new ErrorType(); + } + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + $nativeType = $propertyReflection->getNativeType(); + + if ($node->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($node->class, $nativeType); + } + + return $nativeType; + } + + $typeCallback = function () use ($node): Type { + if ($node->class instanceof Name) { + $staticPropertyFetchedOnType = $this->resolveTypeByName($node->class); + } else { + $staticPropertyFetchedOnType = TypeCombinator::removeNull($this->getType($node->class))->getObjectTypeOrClassStringObjectType(); + } + + $returnType = $this->propertyFetchType( + $staticPropertyFetchedOnType, + $node->name->toString(), + $node, + ); + if ($returnType === null) { + return new ErrorType(); + } + return $returnType; + }; + + $fetchType = $typeCallback(); + if ($node->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($node->class, $fetchType); + } + + return $fetchType; + } + + if ($node instanceof FuncCall) { + if ($node->name instanceof Expr) { + $calledOnType = $this->getType($node->name); + if ($calledOnType->isCallable()->no()) { + return new ErrorType(); + } + + return ParametersAcceptorSelector::selectFromArgs( + $this, + $node->getArgs(), + $calledOnType->getCallableParametersAcceptors($this), + null, + )->getReturnType(); + } + + if (!$this->reflectionProvider->hasFunction($node->name, $this)) { + return new ErrorType(); + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $this); + if ($this->nativeTypesPromoted) { + return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); + } + + if ($functionReflection->getName() === 'call_user_func') { + $result = ArgumentsNormalizer::reorderCallUserFuncArguments($node, $this); + if ($result !== null) { + [, $innerFuncCall] = $result; + + return $this->getType($innerFuncCall); + } + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $this, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedNode !== null) { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicFunctionReturnTypeExtensions() as $dynamicFunctionReturnTypeExtension) { + if (!$dynamicFunctionReturnTypeExtension->isFunctionSupported($functionReflection)) { + continue; + } + + $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( + $functionReflection, + $normalizedNode, + $this, + ); + if ($resolvedType !== null) { + return $resolvedType; + } + } + } + + return $this->transformVoidToNull($parametersAcceptor->getReturnType(), $node); + } + + return new MixedType(); + } + + private function getNullsafeShortCircuitingType(Expr $expr, Type $type): Type + { + if ($expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\NullsafeMethodCall) { + $varType = $this->getType($expr->var); + if (TypeCombinator::containsNull($varType)) { + return TypeCombinator::addNull($type); + } + + return $type; + } + + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->getNullsafeShortCircuitingType($expr->var, $type); + } + + if ($expr instanceof PropertyFetch) { + return $this->getNullsafeShortCircuitingType($expr->var, $type); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($expr->class, $type); + } + + if ($expr instanceof MethodCall) { + return $this->getNullsafeShortCircuitingType($expr->var, $type); + } + + if ($expr instanceof Expr\StaticCall && $expr->class instanceof Expr) { + return $this->getNullsafeShortCircuitingType($expr->class, $type); + } + + return $type; + } + + private function transformVoidToNull(Type $type, Node $node): Type + { + if ($node->getAttribute(self::KEEP_VOID_ATTRIBUTE_NAME) === true) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type->isVoid()->yes()) { + return new NullType(); + } + + return $type; + }); + } + + /** + * @param callable(Type): ?bool $typeCallback + */ + public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = null): ?bool + { + // mirrored in PHPStan\Rules\IssetCheck + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + $hasVariable = $this->hasVariableType($expr->name); + if ($hasVariable->maybe()) { + return null; + } + + if ($result === null) { + if ($hasVariable->yes()) { + if ($expr->name === '_SESSION') { + return null; + } + + return $typeCallback($this->getVariableType($expr->name)); + } + + return false; + } + + return $result; + } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->getType($expr->var); + if (!$type->isOffsetAccessible()->yes()) { + return $result ?? $this->issetCheckUndefined($expr->var); + } + + $dimType = $this->getType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if ($hasOffsetValue->no()) { + return false; + } + + // If offset cannot be null, store this error message and see if one of the earlier offsets is. + // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. + if ($hasOffsetValue->yes()) { + $result = $typeCallback($type->getOffsetValueType($dimType)); + + if ($result !== null) { + return $this->issetCheck($expr->var, $typeCallback, $result); + } + } + + // Has offset, it is nullable + return null; + + } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); + + if ($propertyReflection === null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + + if (!$propertyReflection->isNative()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + + $nativeType = $propertyReflection->getNativeType(); + if (!$nativeType instanceof MixedType) { + if (!$this->hasExpressionType($expr)->yes()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + } + + if ($result !== null) { + return $result; + } + + $result = $typeCallback($propertyReflection->getWritableType()); + if ($result !== null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheck($expr->var, $typeCallback, $result); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheck($expr->class, $typeCallback, $result); + } + } + + return $result; + } + + if ($result !== null) { + return $result; + } + + return $typeCallback($this->getType($expr)); + } + + private function issetCheckUndefined(Expr $expr): ?bool + { + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + $hasVariable = $this->hasVariableType($expr->name); + if (!$hasVariable->no()) { + return null; + } + + return false; + } + + if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->getType($expr->var); + if (!$type->isOffsetAccessible()->yes()) { + return $this->issetCheckUndefined($expr->var); + } + + $dimType = $this->getType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + + if (!$hasOffsetValue->no()) { + return $this->issetCheckUndefined($expr->var); + } + + return false; + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + + /** + * @param ParametersAcceptor[] $variants + */ + private function createFirstClassCallable( + FunctionReflection|ExtendedMethodReflection|null $function, + array $variants, + ): Type + { + $closureTypes = []; + + foreach ($variants as $variant) { + $returnType = $variant->getReturnType(); + if ($variant instanceof ExtendedParametersAcceptor) { + $returnType = $this->nativeTypesPromoted ? $variant->getNativeReturnType() : $returnType; + } + + $templateTags = []; + foreach ($variant->getTemplateTypeMap()->getTypes() as $templateType) { + if (!$templateType instanceof TemplateType) { + continue; + } + $templateTags[$templateType->getName()] = new TemplateTag( + $templateType->getName(), + $templateType->getBound(), + $templateType->getDefault(), + $templateType->getVariance(), + ); + } + + $throwPoints = []; + $impurePoints = []; + $acceptsNamedArguments = TrinaryLogic::createYes(); + if ($variant instanceof CallableParametersAcceptor) { + $throwPoints = $variant->getThrowPoints(); + $impurePoints = $variant->getImpurePoints(); + $acceptsNamedArguments = $variant->acceptsNamedArguments(); + } elseif ($function !== null) { + $returnTypeForThrow = $variant->getReturnType(); + $throwType = $function->getThrowType(); + if ($throwType === null) { + if ($returnTypeForThrow instanceof NeverType && $returnTypeForThrow->isExplicit()) { + $throwType = new ObjectType(Throwable::class); + } + } + + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + $throwPoints[] = SimpleThrowPoint::createExplicit($throwType, true); + } + } else { + if (!(new ObjectType(Throwable::class))->isSuperTypeOf($returnTypeForThrow)->yes()) { + $throwPoints[] = SimpleThrowPoint::createImplicit(); + } + } + + $impurePoint = SimpleImpurePoint::createFromVariant($function, $variant); + if ($impurePoint !== null) { + $impurePoints[] = $impurePoint; + } + + $acceptsNamedArguments = $function->acceptsNamedArguments(); + } + + $parameters = $variant->getParameters(); + $closureTypes[] = new ClosureType( + $parameters, + $returnType, + $variant->isVariadic(), + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $templateTags, + $throwPoints, + $impurePoints, + [], + [], + $acceptsNamedArguments, + ); + } + + return TypeCombinator::union(...$closureTypes); + } + + /** @api */ + public function getNativeType(Expr $expr): Type + { + return $this->promoteNativeTypes()->getType($expr); + } + + public function getKeepVoidType(Expr $node): Type + { + $clonedNode = clone $node; + $clonedNode->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, true); + + return $this->getType($clonedNode); + } + + public function doNotTreatPhpDocTypesAsCertain(): Scope + { + return $this->promoteNativeTypes(); + } + + private function promoteNativeTypes(): self + { + if ($this->nativeTypesPromoted) { + return $this; + } + + if ($this->scopeWithPromotedNativeTypes !== null) { + return $this->scopeWithPromotedNativeTypes; + } + + return $this->scopeWithPromotedNativeTypes = $this->scopeFactory->create( + $this->context, + $this->declareStrictTypes, + $this->function, + $this->namespace, + $this->nativeExpressionTypes, + [], + [], + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + true, + ); + } + + /** + * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch + */ + public function hasPropertyNativeType($propertyFetch): bool + { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $this); + if ($propertyReflection === null) { + return false; + } + + if (!$propertyReflection->isNative()) { + return false; + } + + return $propertyReflection->hasNativeType(); + } + + private function getTypeFromArrayDimFetch( + Expr\ArrayDimFetch $arrayDimFetch, + Type $offsetType, + Type $offsetAccessibleType, + ): Type + { + if ($arrayDimFetch->dim === null) { + throw new ShouldNotHappenException(); + } + + if (!$offsetAccessibleType->isArray()->yes() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes()) { + return $this->getType( + new MethodCall( + $arrayDimFetch->var, + new Node\Identifier('offsetGet'), + [ + new Node\Arg($arrayDimFetch->dim), + ], + ), + ); + } + + return $offsetAccessibleType->getOffsetValueType($offsetType); + } + + private function resolveExactName(Name $name): ?string + { + $originalClass = (string) $name; + + switch (strtolower($originalClass)) { + case 'self': + if (!$this->isInClass()) { + return null; + } + return $this->getClassReflection()->getName(); + case 'parent': + if (!$this->isInClass()) { + return null; + } + $currentClassReflection = $this->getClassReflection(); + if ($currentClassReflection->getParentClass() !== null) { + return $currentClassReflection->getParentClass()->getName(); + } + return null; + case 'static': + return null; + } + + return $originalClass; + } + + /** @api */ + public function resolveName(Name $name): string + { + $originalClass = (string) $name; + if ($this->isInClass()) { + $lowerClass = strtolower($originalClass); + if (in_array($lowerClass, [ + 'self', + 'static', + ], true)) { + if ($this->inClosureBindScopeClasses !== [] && $this->inClosureBindScopeClasses !== ['static']) { + return $this->inClosureBindScopeClasses[0]; + } + return $this->getClassReflection()->getName(); + } elseif ($lowerClass === 'parent') { + $currentClassReflection = $this->getClassReflection(); + if ($currentClassReflection->getParentClass() !== null) { + return $currentClassReflection->getParentClass()->getName(); + } + } + } + + return $originalClass; + } + + /** @api */ + public function resolveTypeByName(Name $name): TypeWithClassName + { + if ($name->toLowerString() === 'static' && $this->isInClass()) { + if ($this->inClosureBindScopeClasses !== [] && $this->inClosureBindScopeClasses !== ['static']) { + if ($this->reflectionProvider->hasClass($this->inClosureBindScopeClasses[0])) { + return new StaticType($this->reflectionProvider->getClass($this->inClosureBindScopeClasses[0])); + } + } + + return new StaticType($this->getClassReflection()); + } + + $originalClass = $this->resolveName($name); + if ($this->isInClass()) { + if ($this->inClosureBindScopeClasses === [$originalClass]) { + if ($this->reflectionProvider->hasClass($originalClass)) { + return new ThisType($this->reflectionProvider->getClass($originalClass)); + } + return new ObjectType($originalClass); + } + + $thisType = new ThisType($this->getClassReflection()); + $ancestor = $thisType->getAncestorWithClassName($originalClass); + if ($ancestor !== null) { + return $ancestor; + } + } + + return new ObjectType($originalClass); + } + + private function resolveTypeByNameWithLateStaticBinding(Name $class, Node\Identifier $name): TypeWithClassName + { + $classType = $this->resolveTypeByName($class); + + if ( + $classType instanceof StaticType + && !in_array($class->toLowerString(), ['self', 'static', 'parent'], true) + ) { + $methodReflectionCandidate = $this->getMethodReflection( + $classType, + $name->name, + ); + if ($methodReflectionCandidate !== null && $methodReflectionCandidate->isStatic()) { + $classType = $classType->getStaticObjectType(); + } + } + + return $classType; + } + + /** + * @api + * @param mixed $value + */ + public function getTypeFromValue($value): Type + { + return ConstantTypeHelper::getTypeFromValue($value); + } + + /** @api */ + public function hasExpressionType(Expr $node): TrinaryLogic + { + if ($node instanceof Variable && is_string($node->name)) { + return $this->hasVariableType($node->name); + } + + $exprString = $this->getNodeKey($node); + if (!isset($this->expressionTypes[$exprString])) { + return TrinaryLogic::createNo(); + } + return $this->expressionTypes[$exprString]->getCertainty(); + } + + /** + * @param MethodReflection|FunctionReflection|null $reflection + */ + public function pushInFunctionCall($reflection, ?ParameterReflection $parameter): self + { + $stack = $this->inFunctionCallsStack; + $stack[] = [$reflection, $parameter]; + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function popInFunctionCall(): self + { + $stack = $this->inFunctionCallsStack; + array_pop($stack); + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** @api */ + public function isInClassExists(string $className): bool + { + foreach ($this->inFunctionCallsStack as [$inFunctionCall]) { + if (!$inFunctionCall instanceof FunctionReflection) { + continue; + } + + if (in_array($inFunctionCall->getName(), [ + 'class_exists', + 'interface_exists', + 'trait_exists', + ], true)) { + return true; + } + } + $expr = new FuncCall(new FullyQualified('class_exists'), [ + new Arg(new String_(ltrim($className, '\\'))), + ]); + + return $this->getType($expr)->isTrue()->yes(); + } + + public function getFunctionCallStack(): array + { + return array_values(array_filter( + array_map(static fn ($values) => $values[0], $this->inFunctionCallsStack), + static fn (FunctionReflection|MethodReflection|null $reflection) => $reflection !== null, + )); + } + + public function getFunctionCallStackWithParameters(): array + { + return array_values(array_filter( + $this->inFunctionCallsStack, + static fn ($item) => $item[0] !== null, + )); + } + + /** @api */ + public function isInFunctionExists(string $functionName): bool + { + $expr = new FuncCall(new FullyQualified('function_exists'), [ + new Arg(new String_(ltrim($functionName, '\\'))), + ]); + + return $this->getType($expr)->isTrue()->yes(); + } + + /** @api */ + public function enterClass(ClassReflection $classReflection): self + { + $thisHolder = ExpressionTypeHolder::createYes(new Variable('this'), new ThisType($classReflection)); + $constantTypes = $this->getConstantTypes(); + $constantTypes['$this'] = $thisHolder; + $nativeConstantTypes = $this->getNativeConstantTypes(); + $nativeConstantTypes['$this'] = $thisHolder; + + return $this->scopeFactory->create( + $this->context->enterClass($classReflection), + $this->isDeclareStrictTypes(), + null, + $this->getNamespace(), + $constantTypes, + $nativeConstantTypes, + [], + [], + null, + true, + [], + [], + [], + false, + $classReflection->isAnonymous() ? $this : null, + ); + } + + public function enterTrait(ClassReflection $traitReflection): self + { + $namespace = null; + $traitName = $traitReflection->getName(); + $traitNameParts = explode('\\', $traitName); + if (count($traitNameParts) > 1) { + $namespace = implode('\\', array_slice($traitNameParts, 0, -1)); + } + return $this->scopeFactory->create( + $this->context->enterTrait($traitReflection), + $this->isDeclareStrictTypes(), + $this->getFunction(), + $namespace, + $this->expressionTypes, + $this->nativeExpressionTypes, + [], + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + ); + } + + /** + * @api + * @param Type[] $phpDocParameterTypes + * @param Type[] $parameterOutTypes + * @param array $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + */ + public function enterClassMethod( + Node\Stmt\ClassMethod $classMethod, + TemplateTypeMap $templateTypeMap, + array $phpDocParameterTypes, + ?Type $phpDocReturnType, + ?Type $throwType, + ?string $deprecatedDescription, + bool $isDeprecated, + bool $isInternal, + bool $isFinal, + ?bool $isPure = null, + bool $acceptsNamedArguments = true, + ?Assertions $asserts = null, + ?Type $selfOutType = null, + ?string $phpDocComment = null, + array $parameterOutTypes = [], + array $immediatelyInvokedCallableParameters = [], + array $phpDocClosureThisTypeParameters = [], + bool $isConstructor = false, + ): self + { + if (!$this->isInClass()) { + throw new ShouldNotHappenException(); + } + + return $this->enterFunctionLike( + new PhpMethodFromParserNodeReflection( + $this->getClassReflection(), + $classMethod, + null, + $this->getFile(), + $templateTypeMap, + $this->getRealParameterTypes($classMethod), + array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocParameterTypes), + $this->getRealParameterDefaultValues($classMethod), + $this->getParameterAttributes($classMethod), + $this->transformStaticType($this->getFunctionType($classMethod->returnType, false, false)), + $phpDocReturnType !== null ? $this->transformStaticType(TemplateTypeHelper::toArgument($phpDocReturnType)) : null, + $throwType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isFinal, + $isPure, + $acceptsNamedArguments, + $asserts ?? Assertions::createEmpty(), + $selfOutType, + $phpDocComment, + array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $parameterOutTypes), + $immediatelyInvokedCallableParameters, + array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocClosureThisTypeParameters), + $isConstructor, + $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), + ), + !$classMethod->isStatic(), + ); + } + + /** + * @param Type[] $phpDocParameterTypes + */ + public function enterPropertyHook( + Node\PropertyHook $hook, + string $propertyName, + Identifier|Name|ComplexType|null $nativePropertyTypeNode, + ?Type $phpDocPropertyType, + array $phpDocParameterTypes, + ?Type $throwType, + ?string $deprecatedDescription, + bool $isDeprecated, + ?string $phpDocComment, + ): self + { + if (!$this->isInClass()) { + throw new ShouldNotHappenException(); + } + + $phpDocParameterTypes = array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocParameterTypes); + + $hookName = $hook->name->toLowerString(); + if ($hookName === 'set') { + if ($hook->params === []) { + $hook = clone $hook; + $hook->params = [ + new Node\Param(new Variable('value'), null, $nativePropertyTypeNode), + ]; + } + + $firstParam = $hook->params[0] ?? null; + if ( + $firstParam !== null + && $phpDocPropertyType !== null + && $firstParam->var instanceof Variable + && is_string($firstParam->var->name) + ) { + $valueParamPhpDocType = $phpDocParameterTypes[$firstParam->var->name] ?? null; + if ($valueParamPhpDocType === null) { + $phpDocParameterTypes[$firstParam->var->name] = $this->transformStaticType(TemplateTypeHelper::toArgument($phpDocPropertyType)); + } + } + + $realReturnType = new VoidType(); + $phpDocReturnType = null; + } elseif ($hookName === 'get') { + $realReturnType = $this->getFunctionType($nativePropertyTypeNode, false, false); + $phpDocReturnType = $phpDocPropertyType !== null ? $this->transformStaticType(TemplateTypeHelper::toArgument($phpDocPropertyType)) : null; + } else { + throw new ShouldNotHappenException(); + } + + $realParameterTypes = $this->getRealParameterTypes($hook); + + return $this->enterFunctionLike( + new PhpMethodFromParserNodeReflection( + $this->getClassReflection(), + $hook, + $propertyName, + $this->getFile(), + TemplateTypeMap::createEmpty(), + $realParameterTypes, + $phpDocParameterTypes, + [], + $this->getParameterAttributes($hook), + $realReturnType, + $phpDocReturnType, + $throwType, + $deprecatedDescription, + $isDeprecated, + false, + false, + false, + true, + Assertions::createEmpty(), + null, + $phpDocComment, + [], + [], + [], + false, + $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), + ), + true, + ); + } + + private function transformStaticType(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if (!$this->isInClass()) { + return $type; + } + if ($type instanceof StaticType) { + $classReflection = $this->getClassReflection(); + $changedType = $type->changeBaseClass($classReflection); + if ($classReflection->isFinal() && !$type instanceof ThisType) { + $changedType = $changedType->getStaticObjectType(); + } + return $traverse($changedType); + } + + return $traverse($type); + }); + } + + /** + * @return Type[] + */ + private function getRealParameterTypes(Node\FunctionLike $functionLike): array + { + $realParameterTypes = []; + foreach ($functionLike->getParams() as $parameter) { + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $realParameterTypes[$parameter->var->name] = $this->getFunctionType( + $parameter->type, + $this->isParameterValueNullable($parameter) && $parameter->flags === 0, + false, + ); + } + + return $realParameterTypes; + } + + /** + * @return Type[] + */ + private function getRealParameterDefaultValues(Node\FunctionLike $functionLike): array + { + $realParameterDefaultValues = []; + foreach ($functionLike->getParams() as $parameter) { + if ($parameter->default === null) { + continue; + } + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $realParameterDefaultValues[$parameter->var->name] = $this->getType($parameter->default); + } + + return $realParameterDefaultValues; + } + + /** + * @return array> + */ + private function getParameterAttributes(ClassMethod|Function_|PropertyHook $functionLike): array + { + $parameterAttributes = []; + $className = null; + if ($this->isInClass()) { + $className = $this->getClassReflection()->getName(); + } + foreach ($functionLike->getParams() as $parameter) { + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + + $parameterAttributes[$parameter->var->name] = $this->attributeReflectionFactory->fromAttrGroups($parameter->attrGroups, InitializerExprContext::fromStubParameter($className, $this->getFile(), $functionLike)); + } + + return $parameterAttributes; + } + + /** + * @api + * @param Type[] $phpDocParameterTypes + * @param Type[] $parameterOutTypes + * @param array $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + */ + public function enterFunction( + Node\Stmt\Function_ $function, + TemplateTypeMap $templateTypeMap, + array $phpDocParameterTypes, + ?Type $phpDocReturnType, + ?Type $throwType, + ?string $deprecatedDescription, + bool $isDeprecated, + bool $isInternal, + ?bool $isPure = null, + bool $acceptsNamedArguments = true, + ?Assertions $asserts = null, + ?string $phpDocComment = null, + array $parameterOutTypes = [], + array $immediatelyInvokedCallableParameters = [], + array $phpDocClosureThisTypeParameters = [], + ): self + { + return $this->enterFunctionLike( + new PhpFunctionFromParserNodeReflection( + $function, + $this->getFile(), + $templateTypeMap, + $this->getRealParameterTypes($function), + array_map(static fn (Type $type): Type => TemplateTypeHelper::toArgument($type), $phpDocParameterTypes), + $this->getRealParameterDefaultValues($function), + $this->getParameterAttributes($function), + $this->getFunctionType($function->returnType, $function->returnType === null, false), + $phpDocReturnType !== null ? TemplateTypeHelper::toArgument($phpDocReturnType) : null, + $throwType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isPure, + $acceptsNamedArguments, + $asserts ?? Assertions::createEmpty(), + $phpDocComment, + array_map(static fn (Type $type): Type => TemplateTypeHelper::toArgument($type), $parameterOutTypes), + $immediatelyInvokedCallableParameters, + $phpDocClosureThisTypeParameters, + $this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)), + ), + false, + ); + } + + private function enterFunctionLike( + PhpFunctionFromParserNodeReflection $functionReflection, + bool $preserveThis, + ): self + { + $parametersByName = []; + + foreach ($functionReflection->getParameters() as $parameter) { + $parametersByName[$parameter->getName()] = $parameter; + } + + $expressionTypes = []; + $nativeExpressionTypes = []; + $conditionalTypes = []; + foreach ($functionReflection->getParameters() as $parameter) { + $parameterType = $parameter->getType(); + + if ($parameterType instanceof ConditionalTypeForParameter) { + $targetParameterName = substr($parameterType->getParameterName(), 1); + if (array_key_exists($targetParameterName, $parametersByName)) { + $targetParameter = $parametersByName[$targetParameterName]; + + $ifType = $parameterType->isNegated() ? $parameterType->getElse() : $parameterType->getIf(); + $elseType = $parameterType->isNegated() ? $parameterType->getIf() : $parameterType->getElse(); + + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget())), + ], new ExpressionTypeHolder(new Variable($parameter->getName()), $ifType, TrinaryLogic::createYes())); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget())), + ], new ExpressionTypeHolder(new Variable($parameter->getName()), $elseType, TrinaryLogic::createYes())); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + } + } + + $paramExprString = '$' . $parameter->getName(); + if ($parameter->isVariadic()) { + if (!$this->getPhpVersion()->supportsNamedArguments()->no() && $functionReflection->acceptsNamedArguments()->yes()) { + $parameterType = new ArrayType(new UnionType([new IntegerType(), new StringType()]), $parameterType); + } else { + $parameterType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $parameterType), new AccessoryArrayListType()); + } + } + $parameterNode = new Variable($parameter->getName()); + $expressionTypes[$paramExprString] = ExpressionTypeHolder::createYes($parameterNode, $parameterType); + + $parameterOriginalValueExpr = new ParameterVariableOriginalValueExpr($parameter->getName()); + $parameterOriginalValueExprString = $this->getNodeKey($parameterOriginalValueExpr); + $expressionTypes[$parameterOriginalValueExprString] = ExpressionTypeHolder::createYes($parameterOriginalValueExpr, $parameterType); + + $nativeParameterType = $parameter->getNativeType(); + if ($parameter->isVariadic()) { + if (!$this->getPhpVersion()->supportsNamedArguments()->no() && $functionReflection->acceptsNamedArguments()->yes()) { + $nativeParameterType = new ArrayType(new UnionType([new IntegerType(), new StringType()]), $nativeParameterType); + } else { + $nativeParameterType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $nativeParameterType), new AccessoryArrayListType()); + } + } + $nativeExpressionTypes[$paramExprString] = ExpressionTypeHolder::createYes($parameterNode, $nativeParameterType); + $nativeExpressionTypes[$parameterOriginalValueExprString] = ExpressionTypeHolder::createYes($parameterOriginalValueExpr, $nativeParameterType); + } + + if ($preserveThis && array_key_exists('$this', $this->expressionTypes)) { + $expressionTypes['$this'] = $this->expressionTypes['$this']; + } + if ($preserveThis && array_key_exists('$this', $this->nativeExpressionTypes)) { + $nativeExpressionTypes['$this'] = $this->nativeExpressionTypes['$this']; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $functionReflection, + $this->getNamespace(), + array_merge($this->getConstantTypes(), $expressionTypes), + array_merge($this->getNativeConstantTypes(), $nativeExpressionTypes), + $conditionalTypes, + ); + } + + /** @api */ + public function enterNamespace(string $namespaceName): self + { + return $this->scopeFactory->create( + $this->context->beginFile(), + $this->isDeclareStrictTypes(), + null, + $namespaceName, + ); + } + + /** + * @param list $scopeClasses + */ + public function enterClosureBind(?Type $thisType, ?Type $nativeThisType, array $scopeClasses): self + { + $expressionTypes = $this->expressionTypes; + if ($thisType !== null) { + $expressionTypes['$this'] = ExpressionTypeHolder::createYes(new Variable('this'), $thisType); + } else { + unset($expressionTypes['$this']); + } + + $nativeExpressionTypes = $this->nativeExpressionTypes; + if ($nativeThisType !== null) { + $nativeExpressionTypes['$this'] = ExpressionTypeHolder::createYes(new Variable('this'), $nativeThisType); + } else { + unset($nativeExpressionTypes['$this']); + } + + if ($scopeClasses === ['static'] && $this->isInClass()) { + $scopeClasses = [$this->getClassReflection()->getName()]; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $scopeClasses, + $this->anonymousFunctionReflection, + ); + } + + public function restoreOriginalScopeAfterClosureBind(self $originalScope): self + { + $expressionTypes = $this->expressionTypes; + if (isset($originalScope->expressionTypes['$this'])) { + $expressionTypes['$this'] = $originalScope->expressionTypes['$this']; + } else { + unset($expressionTypes['$this']); + } + + $nativeExpressionTypes = $this->nativeExpressionTypes; + if (isset($originalScope->nativeExpressionTypes['$this'])) { + $nativeExpressionTypes['$this'] = $originalScope->nativeExpressionTypes['$this']; + } else { + unset($nativeExpressionTypes['$this']); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $originalScope->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + ); + } + + public function restoreThis(self $restoreThisScope): self + { + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + + if ($restoreThisScope->isInClass()) { + $nodeFinder = new NodeFinder(); + $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; + foreach ($restoreThisScope->expressionTypes as $exprString => $expressionTypeHolder) { + $expr = $expressionTypeHolder->getExpr(); + $thisExpr = $nodeFinder->findFirst([$expr], $cb); + if ($thisExpr === null) { + continue; + } + + $expressionTypes[$exprString] = $expressionTypeHolder; + } + + foreach ($restoreThisScope->nativeExpressionTypes as $exprString => $expressionTypeHolder) { + $expr = $expressionTypeHolder->getExpr(); + $thisExpr = $nodeFinder->findFirst([$expr], $cb); + if ($thisExpr === null) { + continue; + } + + $nativeExpressionTypes[$exprString] = $expressionTypeHolder; + } + } else { + unset($expressionTypes['$this']); + unset($nativeExpressionTypes['$this']); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function enterClosureCall(Type $thisType, Type $nativeThisType): self + { + $expressionTypes = $this->expressionTypes; + $expressionTypes['$this'] = ExpressionTypeHolder::createYes(new Variable('this'), $thisType); + + $nativeExpressionTypes = $this->nativeExpressionTypes; + $nativeExpressionTypes['$this'] = ExpressionTypeHolder::createYes(new Variable('this'), $nativeThisType); + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $thisType->getObjectClassNames(), + $this->anonymousFunctionReflection, + ); + } + + /** @api */ + public function isInClosureBind(): bool + { + return $this->inClosureBindScopeClasses !== []; + } + + /** + * @api + * @param ParameterReflection[]|null $callableParameters + */ + public function enterAnonymousFunction( + Expr\Closure $closure, + ?array $callableParameters, + ): self + { + $anonymousFunctionReflection = $this->getType($closure); + if (!$anonymousFunctionReflection instanceof ClosureType) { + throw new ShouldNotHappenException(); + } + + $scope = $this->enterAnonymousFunctionWithoutReflection($closure, $callableParameters); + + return $this->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + [], + $scope->inClosureBindScopeClasses, + $anonymousFunctionReflection, + true, + [], + [], + $this->inFunctionCallsStack, + false, + $this, + $this->nativeTypesPromoted, + ); + } + + /** + * @param ParameterReflection[]|null $callableParameters + */ + private function enterAnonymousFunctionWithoutReflection( + Expr\Closure $closure, + ?array $callableParameters, + ): self + { + $expressionTypes = []; + $nativeTypes = []; + foreach ($closure->params as $i => $parameter) { + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $paramExprString = sprintf('$%s', $parameter->var->name); + $isNullable = $this->isParameterValueNullable($parameter); + $parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic); + if ($callableParameters !== null) { + if (isset($callableParameters[$i])) { + $parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType()); + } elseif (count($callableParameters) > 0) { + $lastParameter = $callableParameters[count($callableParameters) - 1]; + if ($lastParameter->isVariadic()) { + $parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType()); + } else { + $parameterType = self::intersectButNotNever($parameterType, new MixedType()); + } + } else { + $parameterType = self::intersectButNotNever($parameterType, new MixedType()); + } + } + $holder = ExpressionTypeHolder::createYes($parameter->var, $parameterType); + $expressionTypes[$paramExprString] = $holder; + $nativeTypes[$paramExprString] = $holder; + } + + $nonRefVariableNames = []; + foreach ($closure->uses as $use) { + if (!is_string($use->var->name)) { + throw new ShouldNotHappenException(); + } + $variableName = $use->var->name; + $paramExprString = '$' . $use->var->name; + if ($use->byRef) { + $holder = ExpressionTypeHolder::createYes($use->var, new MixedType()); + $expressionTypes[$paramExprString] = $holder; + $nativeTypes[$paramExprString] = $holder; + continue; + } + $nonRefVariableNames[$variableName] = true; + if ($this->hasVariableType($variableName)->no()) { + $variableType = new ErrorType(); + $variableNativeType = new ErrorType(); + } else { + $variableType = $this->getVariableType($variableName); + $variableNativeType = $this->getNativeType($use->var); + } + $expressionTypes[$paramExprString] = ExpressionTypeHolder::createYes($use->var, $variableType); + $nativeTypes[$paramExprString] = ExpressionTypeHolder::createYes($use->var, $variableNativeType); + } + + foreach ($this->invalidateStaticExpressions($this->expressionTypes) as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); + if ($expr instanceof Variable) { + continue; + } + $variables = (new NodeFinder())->findInstanceOf([$expr], Variable::class); + if ($variables === [] && !$this->expressionTypeIsUnchangeable($typeHolder)) { + continue; + } + foreach ($variables as $variable) { + if (!is_string($variable->name)) { + continue 2; + } + if (!array_key_exists($variable->name, $nonRefVariableNames)) { + continue 2; + } + } + + $expressionTypes[$exprString] = $typeHolder; + } + + if ($this->hasVariableType('this')->yes() && !$closure->static) { + $node = new Variable('this'); + $expressionTypes['$this'] = ExpressionTypeHolder::createYes($node, $this->getType($node)); + $nativeTypes['$this'] = ExpressionTypeHolder::createYes($node, $this->getNativeType($node)); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + array_merge($this->getConstantTypes(), $expressionTypes), + array_merge($this->getNativeConstantTypes(), $nativeTypes), + [], + $this->inClosureBindScopeClasses, + new TrivialParametersAcceptor(), + true, + [], + [], + [], + false, + $this, + $this->nativeTypesPromoted, + ); + } + + private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool + { + $expr = $typeHolder->getExpr(); + $type = $typeHolder->getType(); + + return $expr instanceof FuncCall + && !$expr->isFirstClassCallable() + && $expr->name instanceof FullyQualified + && $expr->name->toLowerString() === 'function_exists' + && isset($expr->getArgs()[0]) + && count($this->getType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 + && $type->isTrue()->yes(); + } + + /** + * @param array $expressionTypes + * @return array + */ + private function invalidateStaticExpressions(array $expressionTypes): array + { + $filteredExpressionTypes = []; + $nodeFinder = new NodeFinder(); + foreach ($expressionTypes as $exprString => $expressionType) { + $staticExpression = $nodeFinder->findFirst( + [$expressionType->getExpr()], + static fn ($node) => $node instanceof Expr\StaticCall || $node instanceof Expr\StaticPropertyFetch, + ); + if ($staticExpression !== null) { + continue; + } + $filteredExpressionTypes[$exprString] = $expressionType; + } + return $filteredExpressionTypes; + } + + /** + * @api + * @param ParameterReflection[]|null $callableParameters + */ + public function enterArrowFunction(Expr\ArrowFunction $arrowFunction, ?array $callableParameters): self + { + $anonymousFunctionReflection = $this->getType($arrowFunction); + if (!$anonymousFunctionReflection instanceof ClosureType) { + throw new ShouldNotHappenException(); + } + + $scope = $this->enterArrowFunctionWithoutReflection($arrowFunction, $callableParameters); + + return $this->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $scope->conditionalExpressions, + $scope->inClosureBindScopeClasses, + $anonymousFunctionReflection, + true, + [], + [], + $this->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** + * @param ParameterReflection[]|null $callableParameters + */ + private function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFunction, ?array $callableParameters): self + { + $arrowFunctionScope = $this; + foreach ($arrowFunction->params as $i => $parameter) { + if ($parameter->type === null) { + $parameterType = new MixedType(); + } else { + $isNullable = $this->isParameterValueNullable($parameter); + $parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic); + } + + if ($callableParameters !== null) { + if (isset($callableParameters[$i])) { + $parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType()); + } elseif (count($callableParameters) > 0) { + $lastParameter = $callableParameters[count($callableParameters) - 1]; + if ($lastParameter->isVariadic()) { + $parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType()); + } else { + $parameterType = self::intersectButNotNever($parameterType, new MixedType()); + } + } else { + $parameterType = self::intersectButNotNever($parameterType, new MixedType()); + } + } + + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $arrowFunctionScope = $arrowFunctionScope->assignVariable($parameter->var->name, $parameterType, $parameterType, TrinaryLogic::createYes()); + } + + if ($arrowFunction->static) { + $arrowFunctionScope = $arrowFunctionScope->invalidateExpression(new Variable('this')); + } + + return $this->scopeFactory->create( + $arrowFunctionScope->context, + $this->isDeclareStrictTypes(), + $arrowFunctionScope->getFunction(), + $arrowFunctionScope->getNamespace(), + $this->invalidateStaticExpressions($arrowFunctionScope->expressionTypes), + $arrowFunctionScope->nativeExpressionTypes, + $arrowFunctionScope->conditionalExpressions, + $arrowFunctionScope->inClosureBindScopeClasses, + new TrivialParametersAcceptor(), + true, + [], + [], + [], + $arrowFunctionScope->afterExtractCall, + $arrowFunctionScope->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function isParameterValueNullable(Node\Param $parameter): bool + { + if ($parameter->default instanceof ConstFetch) { + return strtolower((string) $parameter->default->name) === 'null'; + } + + return false; + } + + /** + * @api + * @param Node\Name|Node\Identifier|Node\ComplexType|null $type + */ + public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type + { + if ($isNullable) { + return TypeCombinator::addNull( + $this->getFunctionType($type, false, $isVariadic), + ); + } + if ($isVariadic) { + if (!$this->getPhpVersion()->supportsNamedArguments()->no()) { + return new ArrayType(new UnionType([new IntegerType(), new StringType()]), $this->getFunctionType( + $type, + false, + false, + )); + } + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), $this->getFunctionType( + $type, + false, + false, + )), new AccessoryArrayListType()); + } + + if ($type instanceof Name) { + $className = (string) $type; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'parent') { + if ($this->isInClass() && $this->getClassReflection()->getParentClass() !== null) { + return new ObjectType($this->getClassReflection()->getParentClass()->getName()); + } + + return new NonexistentParentClassType(); + } + } + + return ParserNodeTypeToPHPStanType::resolve($type, $this->isInClass() ? $this->getClassReflection() : null); + } + + private static function intersectButNotNever(Type $nativeType, Type $inferredType): Type + { + if ($nativeType->isSuperTypeOf($inferredType)->no()) { + return $nativeType; + } + + $result = TypeCombinator::intersect($nativeType, $inferredType); + if (TypeCombinator::containsNull($nativeType)) { + return TypeCombinator::addNull($result); + } + + return $result; + } + + public function enterMatch(Expr\Match_ $expr): self + { + if ($expr->cond instanceof Variable) { + return $this; + } + if ($expr->cond instanceof AlwaysRememberedExpr) { + $cond = $expr->cond->expr; + } else { + $cond = $expr->cond; + } + + $type = $this->getType($cond); + $nativeType = $this->getNativeType($cond); + $condExpr = new AlwaysRememberedExpr($cond, $type, $nativeType); + $expr->cond = $condExpr; + + return $this->assignExpression($condExpr, $type, $nativeType); + } + + public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName): self + { + $iterateeType = $originalScope->getType($iteratee); + $nativeIterateeType = $originalScope->getNativeType($iteratee); + $scope = $this->assignVariable( + $valueName, + $originalScope->getIterableValueType($iterateeType), + $originalScope->getIterableValueType($nativeIterateeType), + TrinaryLogic::createYes(), + ); + if ($keyName !== null) { + $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + } + + return $scope; + } + + public function enterForeachKey(self $originalScope, Expr $iteratee, string $keyName): self + { + $iterateeType = $originalScope->getType($iteratee); + $nativeIterateeType = $originalScope->getNativeType($iteratee); + $scope = $this->assignVariable( + $keyName, + $originalScope->getIterableKeyType($iterateeType), + $originalScope->getIterableKeyType($nativeIterateeType), + TrinaryLogic::createYes(), + ); + + if ($iterateeType->isArray()->yes()) { + $scope = $scope->assignExpression( + new Expr\ArrayDimFetch($iteratee, new Variable($keyName)), + $originalScope->getIterableValueType($iterateeType), + $originalScope->getIterableValueType($nativeIterateeType), + ); + } + + return $scope; + } + + public function enterCatchType(Type $catchType, ?string $variableName): self + { + if ($variableName === null) { + return $this; + } + + return $this->assignVariable( + $variableName, + TypeCombinator::intersect($catchType, new ObjectType(Throwable::class)), + TypeCombinator::intersect($catchType, new ObjectType(Throwable::class)), + TrinaryLogic::createYes(), + ); + } + + public function enterExpressionAssign(Expr $expr): self + { + $exprString = $this->getNodeKey($expr); + $currentlyAssignedExpressions = $this->currentlyAssignedExpressions; + $currentlyAssignedExpressions[$exprString] = true; + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + $scope->resolvedTypes = $this->resolvedTypes; + $scope->truthyScopes = $this->truthyScopes; + $scope->falseyScopes = $this->falseyScopes; + + return $scope; + } + + public function exitExpressionAssign(Expr $expr): self + { + $exprString = $this->getNodeKey($expr); + $currentlyAssignedExpressions = $this->currentlyAssignedExpressions; + unset($currentlyAssignedExpressions[$exprString]); + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + $scope->resolvedTypes = $this->resolvedTypes; + $scope->truthyScopes = $this->truthyScopes; + $scope->falseyScopes = $this->falseyScopes; + + return $scope; + } + + /** @api */ + public function isInExpressionAssign(Expr $expr): bool + { + $exprString = $this->getNodeKey($expr); + return array_key_exists($exprString, $this->currentlyAssignedExpressions); + } + + public function setAllowedUndefinedExpression(Expr $expr): self + { + if ($expr instanceof Expr\StaticPropertyFetch) { + return $this; + } + + $exprString = $this->getNodeKey($expr); + $currentlyAllowedUndefinedExpressions = $this->currentlyAllowedUndefinedExpressions; + $currentlyAllowedUndefinedExpressions[$exprString] = true; + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + $scope->resolvedTypes = $this->resolvedTypes; + $scope->truthyScopes = $this->truthyScopes; + $scope->falseyScopes = $this->falseyScopes; + + return $scope; + } + + public function unsetAllowedUndefinedExpression(Expr $expr): self + { + $exprString = $this->getNodeKey($expr); + $currentlyAllowedUndefinedExpressions = $this->currentlyAllowedUndefinedExpressions; + unset($currentlyAllowedUndefinedExpressions[$exprString]); + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + $scope->resolvedTypes = $this->resolvedTypes; + $scope->truthyScopes = $this->truthyScopes; + $scope->falseyScopes = $this->falseyScopes; + + return $scope; + } + + /** @api */ + public function isUndefinedExpressionAllowed(Expr $expr): bool + { + $exprString = $this->getNodeKey($expr); + return array_key_exists($exprString, $this->currentlyAllowedUndefinedExpressions); + } + + public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty): self + { + $node = new Variable($variableName); + $scope = $this->assignExpression($node, $type, $nativeType); + if ($certainty->no()) { + throw new ShouldNotHappenException(); + } elseif (!$certainty->yes()) { + $exprString = '$' . $variableName; + $scope->expressionTypes[$exprString] = new ExpressionTypeHolder($node, $type, $certainty); + $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); + } + + $parameterOriginalValueExprString = $this->getNodeKey(new ParameterVariableOriginalValueExpr($variableName)); + unset($scope->expressionTypes[$parameterOriginalValueExprString]); + unset($scope->nativeExpressionTypes[$parameterOriginalValueExprString]); + + return $scope; + } + + public function unsetExpression(Expr $expr): self + { + $scope = $this; + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + $exprVarType = $scope->getType($expr->var); + $dimType = $scope->getType($expr->dim); + $unsetType = $exprVarType->unsetOffset($dimType); + $exprVarNativeType = $scope->getNativeType($expr->var); + $dimNativeType = $scope->getNativeType($expr->dim); + $unsetNativeType = $exprVarNativeType->unsetOffset($dimNativeType); + $scope = $scope->assignExpression($expr->var, $unsetType, $unsetNativeType)->invalidateExpression( + new FuncCall(new FullyQualified('count'), [new Arg($expr->var)]), + )->invalidateExpression( + new FuncCall(new FullyQualified('sizeof'), [new Arg($expr->var)]), + )->invalidateExpression( + new FuncCall(new Name('count'), [new Arg($expr->var)]), + )->invalidateExpression( + new FuncCall(new Name('sizeof'), [new Arg($expr->var)]), + ); + + if ($expr->var instanceof Expr\ArrayDimFetch && $expr->var->dim !== null) { + $scope = $scope->assignExpression( + $expr->var->var, + $this->getType($expr->var->var)->setOffsetValueType( + $scope->getType($expr->var->dim), + $scope->getType($expr->var), + ), + $this->getNativeType($expr->var->var)->setOffsetValueType( + $scope->getNativeType($expr->var->dim), + $scope->getNativeType($expr->var), + ), + ); + } + } + + return $scope->invalidateExpression($expr); + } + + public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self + { + if ($expr instanceof ConstFetch) { + $loweredConstName = strtolower($expr->name->toString()); + if (in_array($loweredConstName, ['true', 'false', 'null'], true)) { + return $this; + } + } + + if ($expr instanceof FuncCall && $expr->name instanceof Name && $type->isFalse()->yes()) { + $functionName = $this->reflectionProvider->resolveFunctionName($expr->name, $this); + if ($functionName !== null && in_array(strtolower($functionName), [ + 'is_dir', + 'is_file', + 'file_exists', + ], true)) { + return $this; + } + } + + $scope = $this; + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + $dimType = $scope->getType($expr->dim)->toArrayKey(); + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $exprVarType = $scope->getType($expr->var); + if (!$exprVarType instanceof MixedType && !$exprVarType->isArray()->no()) { + $types = [ + new ArrayType(new MixedType(), new MixedType()), + new ObjectType(ArrayAccess::class), + new NullType(), + ]; + if ($dimType instanceof ConstantIntegerType) { + $types[] = new StringType(); + } + + $scope = $scope->specifyExpressionType( + $expr->var, + TypeCombinator::intersect( + TypeCombinator::intersect($exprVarType, TypeCombinator::union(...$types)), + new HasOffsetValueType($dimType, $type), + ), + $scope->getNativeType($expr->var), + $certainty, + ); + } + } + } + + if ($certainty->no()) { + throw new ShouldNotHappenException(); + } + + $exprString = $this->getNodeKey($expr); + $expressionTypes = $scope->expressionTypes; + $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); + $nativeTypes = $scope->nativeExpressionTypes; + $nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + + if ($expr instanceof AlwaysRememberedExpr) { + return $scope->specifyExpressionType($expr->expr, $type, $nativeType, $certainty); + } + + return $scope; + } + + public function assignExpression(Expr $expr, Type $type, Type $nativeType): self + { + $scope = $this; + if ($expr instanceof PropertyFetch) { + $scope = $this->invalidateExpression($expr) + ->invalidateMethodsOnExpression($expr->var); + } elseif ($expr instanceof Expr\StaticPropertyFetch) { + $scope = $this->invalidateExpression($expr); + } elseif ($expr instanceof Variable) { + $scope = $this->invalidateExpression($expr); + } + + return $scope->specifyExpressionType($expr, $type, $nativeType, TrinaryLogic::createYes()); + } + + public function assignInitializedProperty(Type $fetchedOnType, string $propertyName): self + { + if (!$this->isInClass()) { + return $this; + } + + if (TypeUtils::findThisType($fetchedOnType) === null) { + return $this; + } + + $propertyReflection = $this->getPropertyReflection($fetchedOnType, $propertyName); + if ($propertyReflection === null) { + return $this; + } + $declaringClass = $propertyReflection->getDeclaringClass(); + if ($this->getClassReflection()->getName() !== $declaringClass->getName()) { + return $this; + } + if (!$declaringClass->hasNativeProperty($propertyName)) { + return $this; + } + + return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType()); + } + + public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false): self + { + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $invalidated = false; + $exprStringToInvalidate = $this->getNodeKey($expressionToInvalidate); + + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + $exprExpr = $exprTypeHolder->getExpr(); + if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $requireMoreCharacters)) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; + } + + $newConditionalExpressions = []; + foreach ($this->conditionalExpressions as $conditionalExprString => $holders) { + if (count($holders) === 0) { + continue; + } + if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $holders[array_key_first($holders)]->getTypeHolder()->getExpr())) { + $invalidated = true; + continue; + } + foreach ($holders as $holder) { + $conditionalTypeHolders = $holder->getConditionExpressionTypeHolders(); + foreach ($conditionalTypeHolders as $conditionalTypeHolder) { + if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr())) { + $invalidated = true; + continue 3; + } + } + } + $newConditionalExpressions[$conditionalExprString] = $holders; + } + + if (!$invalidated) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $newConditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, bool $requireMoreCharacters = false): bool + { + if ($requireMoreCharacters && $exprStringToInvalidate === $this->getNodeKey($expr)) { + return false; + } + + // Variables will not contain traversable expressions. skip the NodeFinder overhead + if ($expr instanceof Variable && is_string($expr->name) && !$requireMoreCharacters) { + return $exprStringToInvalidate === $this->getNodeKey($expr); + } + + $nodeFinder = new NodeFinder(); + $expressionToInvalidateClass = get_class($exprToInvalidate); + $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($expressionToInvalidateClass, $exprStringToInvalidate): bool { + if (!$node instanceof $expressionToInvalidateClass) { + return false; + } + + $nodeString = $this->getNodeKey($node); + + return $nodeString === $exprStringToInvalidate; + }); + + if ($found === null) { + return false; + } + + if ($this->phpVersion->supportsReadOnlyProperties() && $expr instanceof PropertyFetch && $expr->name instanceof Node\Identifier && $requireMoreCharacters) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); + if ($propertyReflection !== null) { + $nativePropertyReflection = $propertyReflection->getNativeReflection(); + if ($nativePropertyReflection !== null && $nativePropertyReflection->isReadOnly()) { + return false; + } + } + } + + return true; + } + + private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): self + { + $exprStringToInvalidate = $this->getNodeKey($expressionToInvalidate); + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $invalidated = false; + $nodeFinder = new NodeFinder(); + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + $expr = $exprTypeHolder->getExpr(); + $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($exprStringToInvalidate): bool { + if (!$node instanceof MethodCall) { + return false; + } + + return $this->getNodeKey($node->var) === $exprStringToInvalidate; + }); + if ($found === null) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; + } + + if (!$invalidated) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): self + { + if ($this->hasExpressionType($expr)->no()) { + throw new ShouldNotHappenException(); + } + + $originalExprType = $this->getType($expr); + $nativeType = $this->getNativeType($expr); + + return $this->specifyExpressionType( + $expr, + $originalExprType, + $nativeType, + $certainty, + ); + } + + public function addTypeToExpression(Expr $expr, Type $type): self + { + $originalExprType = $this->getType($expr); + $nativeType = $this->getNativeType($expr); + + if ($originalExprType->equals($nativeType)) { + $newType = TypeCombinator::intersect($type, $originalExprType); + return $this->specifyExpressionType($expr, $newType, $newType, TrinaryLogic::createYes()); + } + + return $this->specifyExpressionType( + $expr, + TypeCombinator::intersect($type, $originalExprType), + TypeCombinator::intersect($type, $nativeType), + TrinaryLogic::createYes(), + ); + } + + public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self + { + $exprType = $this->getType($expr); + if ( + $exprType instanceof NeverType || + $typeToRemove instanceof NeverType + ) { + return $this; + } + return $this->specifyExpressionType( + $expr, + TypeCombinator::remove($exprType, $typeToRemove), + TypeCombinator::remove($this->getNativeType($expr), $typeToRemove), + TrinaryLogic::createYes(), + ); + } + + /** + * @api + * @return MutatingScope + */ + public function filterByTruthyValue(Expr $expr): Scope + { + $exprString = $this->getNodeKey($expr); + if (array_key_exists($exprString, $this->truthyScopes)) { + return $this->truthyScopes[$exprString]; + } + + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); + $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $this->truthyScopes[$exprString] = $scope; + + return $scope; + } + + /** + * @api + * @return MutatingScope + */ + public function filterByFalseyValue(Expr $expr): Scope + { + $exprString = $this->getNodeKey($expr); + if (array_key_exists($exprString, $this->falseyScopes)) { + return $this->falseyScopes[$exprString]; + } + + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); + $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $this->falseyScopes[$exprString] = $scope; + + return $scope; + } + + public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self + { + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + + $scope = $this; + $specifiedExpressions = []; + foreach ($typeSpecifications as $typeSpecification) { + $expr = $typeSpecification['expr']; + $type = $typeSpecification['type']; + + if ($expr instanceof IssetExpr) { + $issetExpr = $expr; + $expr = $issetExpr->getExpr(); + + if ($typeSpecification['sure']) { + $scope = $scope->setExpressionCertainty( + $expr, + TrinaryLogic::createMaybe(), + ); + } else { + $scope = $scope->unsetExpression($expr); + } + + continue; + } + + if ($typeSpecification['sure']) { + if ($specifiedTypes->shouldOverwrite()) { + $scope = $scope->assignExpression($expr, $type, $type); + } else { + $scope = $scope->addTypeToExpression($expr, $type); + } + } else { + $scope = $scope->removeTypeFromExpression($expr, $type); + } + $specifiedExpressions[$this->getNodeKey($expr)] = ExpressionTypeHolder::createYes($expr, $scope->getType($expr)); + } + + $conditions = []; + foreach ($scope->conditionalExpressions as $conditionalExprString => $conditionalExpressions) { + foreach ($conditionalExpressions as $conditionalExpression) { + foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { + if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + continue 2; + } + } + + $conditions[$conditionalExprString][] = $conditionalExpression; + $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + } + } + + foreach ($conditions as $conditionalExprString => $expressions) { + $certainty = TrinaryLogic::lazyExtremeIdentity($expressions, static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getCertainty()); + if ($certainty->no()) { + unset($scope->expressionTypes[$conditionalExprString]); + } else { + $type = TypeCombinator::intersect(...array_map(static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getType(), $expressions)); + + $scope->expressionTypes[$conditionalExprString] = array_key_exists($conditionalExprString, $scope->expressionTypes) + ? new ExpressionTypeHolder( + $scope->expressionTypes[$conditionalExprString]->getExpr(), + TypeCombinator::intersect($scope->expressionTypes[$conditionalExprString]->getType(), $type), + TrinaryLogic::maxMin($scope->expressionTypes[$conditionalExprString]->getCertainty(), $certainty), + ) + : $expressions[0]->getTypeHolder(); + } + } + + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + array_merge($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); + } + + /** + * @param ConditionalExpressionHolder[] $conditionalExpressionHolders + */ + public function addConditionalExpressions(string $exprString, array $conditionalExpressionHolders): self + { + $conditionalExpressions = $this->conditionalExpressions; + $conditionalExpressions[$exprString] = $conditionalExpressionHolders; + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function exitFirstLevelStatements(): self + { + if (!$this->inFirstLevelStatement) { + return $this; + } + + if ($this->scopeOutOfFirstLevelStatement !== null) { + return $this->scopeOutOfFirstLevelStatement; + } + + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + false, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + $scope->resolvedTypes = $this->resolvedTypes; + $scope->truthyScopes = $this->truthyScopes; + $scope->falseyScopes = $this->falseyScopes; + $this->scopeOutOfFirstLevelStatement = $scope; + + return $scope; + } + + /** @api */ + public function isInFirstLevelStatement(): bool + { + return $this->inFirstLevelStatement; + } + + public function mergeWith(?self $otherScope): self + { + if ($otherScope === null) { + return $this; + } + $ourExpressionTypes = $this->expressionTypes; + $theirExpressionTypes = $otherScope->expressionTypes; + + $mergedExpressionTypes = $this->mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes); + $conditionalExpressions = $this->intersectConditionalExpressions($otherScope->conditionalExpressions); + $conditionalExpressions = $this->createConditionalExpressions( + $conditionalExpressions, + $ourExpressionTypes, + $theirExpressionTypes, + $mergedExpressionTypes, + ); + $conditionalExpressions = $this->createConditionalExpressions( + $conditionalExpressions, + $theirExpressionTypes, + $ourExpressionTypes, + $mergedExpressionTypes, + ); + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $mergedExpressionTypes, + $this->mergeVariableHolders($this->nativeExpressionTypes, $otherScope->nativeExpressionTypes), + $conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall && $otherScope->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** + * @param array $otherConditionalExpressions + * @return array + */ + private function intersectConditionalExpressions(array $otherConditionalExpressions): array + { + $newConditionalExpressions = []; + foreach ($this->conditionalExpressions as $exprString => $holders) { + if (!array_key_exists($exprString, $otherConditionalExpressions)) { + continue; + } + + $otherHolders = $otherConditionalExpressions[$exprString]; + foreach (array_keys($holders) as $key) { + if (!array_key_exists($key, $otherHolders)) { + continue 2; + } + } + + $newConditionalExpressions[$exprString] = $holders; + } + + return $newConditionalExpressions; + } + + /** + * @param array $conditionalExpressions + * @param array $ourExpressionTypes + * @param array $theirExpressionTypes + * @param array $mergedExpressionTypes + * @return array + */ + private function createConditionalExpressions( + array $conditionalExpressions, + array $ourExpressionTypes, + array $theirExpressionTypes, + array $mergedExpressionTypes, + ): array + { + $newVariableTypes = $ourExpressionTypes; + foreach ($theirExpressionTypes as $exprString => $holder) { + if (!array_key_exists($exprString, $mergedExpressionTypes)) { + continue; + } + + if (!$mergedExpressionTypes[$exprString]->getType()->equals($holder->getType())) { + continue; + } + + unset($newVariableTypes[$exprString]); + } + + $typeGuards = []; + foreach ($newVariableTypes as $exprString => $holder) { + if (!$holder->getCertainty()->yes()) { + continue; + } + if (!array_key_exists($exprString, $mergedExpressionTypes)) { + continue; + } + if ($mergedExpressionTypes[$exprString]->getType()->equals($holder->getType())) { + continue; + } + + $typeGuards[$exprString] = $holder; + } + + if (count($typeGuards) === 0) { + return $conditionalExpressions; + } + + foreach ($newVariableTypes as $exprString => $holder) { + if ( + array_key_exists($exprString, $mergedExpressionTypes) + && $mergedExpressionTypes[$exprString]->equals($holder) + ) { + continue; + } + + $variableTypeGuards = $typeGuards; + unset($variableTypeGuards[$exprString]); + + if (count($variableTypeGuards) === 0) { + continue; + } + + $conditionalExpression = new ConditionalExpressionHolder($variableTypeGuards, $holder); + $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + } + + foreach ($mergedExpressionTypes as $exprString => $mergedExprTypeHolder) { + if (array_key_exists($exprString, $ourExpressionTypes)) { + continue; + } + + $conditionalExpression = new ConditionalExpressionHolder($typeGuards, new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); + $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + } + + return $conditionalExpressions; + } + + /** + * @param array $ourVariableTypeHolders + * @param array $theirVariableTypeHolders + * @return array + */ + private function mergeVariableHolders(array $ourVariableTypeHolders, array $theirVariableTypeHolders): array + { + $intersectedVariableTypeHolders = []; + foreach ($ourVariableTypeHolders as $exprString => $variableTypeHolder) { + if (isset($theirVariableTypeHolders[$exprString])) { + $intersectedVariableTypeHolders[$exprString] = $variableTypeHolder->and($theirVariableTypeHolders[$exprString]); + } else { + $intersectedVariableTypeHolders[$exprString] = ExpressionTypeHolder::createMaybe($variableTypeHolder->getExpr(), $variableTypeHolder->getType()); + } + } + + foreach ($theirVariableTypeHolders as $exprString => $variableTypeHolder) { + if (isset($intersectedVariableTypeHolders[$exprString])) { + continue; + } + + $intersectedVariableTypeHolders[$exprString] = ExpressionTypeHolder::createMaybe($variableTypeHolder->getExpr(), $variableTypeHolder->getType()); + } + + return $intersectedVariableTypeHolders; + } + + public function mergeInitializedProperties(self $calledMethodScope): self + { + $scope = $this; + foreach ($calledMethodScope->expressionTypes as $exprString => $typeHolder) { + $exprString = (string) $exprString; + if (!str_starts_with($exprString, '__phpstanPropertyInitialization(')) { + continue; + } + $propertyName = substr($exprString, strlen('__phpstanPropertyInitialization('), -1); + $propertyExpr = new PropertyInitializationExpr($propertyName); + if (!array_key_exists($exprString, $scope->expressionTypes)) { + $scope = $scope->assignExpression($propertyExpr, new MixedType(), new MixedType()); + $scope->expressionTypes[$exprString] = $typeHolder; + continue; + } + + $certainty = $scope->expressionTypes[$exprString]->getCertainty(); + $scope = $scope->assignExpression($propertyExpr, new MixedType(), new MixedType()); + $scope->expressionTypes[$exprString] = new ExpressionTypeHolder( + $typeHolder->getExpr(), + $typeHolder->getType(), + $typeHolder->getCertainty()->or($certainty), + ); + } + + return $scope; + } + + public function processFinallyScope(self $finallyScope, self $originalFinallyScope): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->processFinallyScopeVariableTypeHolders( + $this->expressionTypes, + $finallyScope->expressionTypes, + $originalFinallyScope->expressionTypes, + ), + $this->processFinallyScopeVariableTypeHolders( + $this->nativeExpressionTypes, + $finallyScope->nativeExpressionTypes, + $originalFinallyScope->nativeExpressionTypes, + ), + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** + * @param array $ourVariableTypeHolders + * @param array $finallyVariableTypeHolders + * @param array $originalVariableTypeHolders + * @return array + */ + private function processFinallyScopeVariableTypeHolders( + array $ourVariableTypeHolders, + array $finallyVariableTypeHolders, + array $originalVariableTypeHolders, + ): array + { + foreach ($finallyVariableTypeHolders as $exprString => $variableTypeHolder) { + if ( + isset($originalVariableTypeHolders[$exprString]) + && !$originalVariableTypeHolders[$exprString]->getType()->equals($variableTypeHolder->getType()) + ) { + $ourVariableTypeHolders[$exprString] = $variableTypeHolder; + continue; + } + + if (isset($originalVariableTypeHolders[$exprString])) { + continue; + } + + $ourVariableTypeHolders[$exprString] = $variableTypeHolder; + } + + return $ourVariableTypeHolders; + } + + /** + * @param Node\ClosureUse[] $byRefUses + */ + public function processClosureScope( + self $closureScope, + ?self $prevScope, + array $byRefUses, + ): self + { + $nativeExpressionTypes = $this->nativeExpressionTypes; + $expressionTypes = $this->expressionTypes; + if (count($byRefUses) === 0) { + return $this; + } + + foreach ($byRefUses as $use) { + if (!is_string($use->var->name)) { + throw new ShouldNotHappenException(); + } + + $variableName = $use->var->name; + $variableExprString = '$' . $variableName; + + if (!$closureScope->hasVariableType($variableName)->yes()) { + $holder = ExpressionTypeHolder::createYes($use->var, new NullType()); + $expressionTypes[$variableExprString] = $holder; + $nativeExpressionTypes[$variableExprString] = $holder; + continue; + } + + $variableType = $closureScope->getVariableType($variableName); + + if ($prevScope !== null) { + $prevVariableType = $prevScope->getVariableType($variableName); + if (!$variableType->equals($prevVariableType)) { + $variableType = TypeCombinator::union($variableType, $prevVariableType); + $variableType = self::generalizeType($variableType, $prevVariableType, 0); + } + } + + $expressionTypes[$variableExprString] = ExpressionTypeHolder::createYes($use->var, $variableType); + $nativeExpressionTypes[$variableExprString] = ExpressionTypeHolder::createYes($use->var, $variableType); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope): self + { + $expressionTypes = $this->expressionTypes; + foreach ($finalScope->expressionTypes as $variableExprString => $variableTypeHolder) { + if (!isset($expressionTypes[$variableExprString])) { + $expressionTypes[$variableExprString] = ExpressionTypeHolder::createMaybe($variableTypeHolder->getExpr(), $variableTypeHolder->getType()); + continue; + } + + $expressionTypes[$variableExprString] = new ExpressionTypeHolder( + $variableTypeHolder->getExpr(), + $variableTypeHolder->getType(), + $variableTypeHolder->getCertainty()->and($expressionTypes[$variableExprString]->getCertainty()), + ); + } + $nativeTypes = $this->nativeExpressionTypes; + foreach ($finalScope->nativeExpressionTypes as $variableExprString => $variableTypeHolder) { + if (!isset($nativeTypes[$variableExprString])) { + $nativeTypes[$variableExprString] = ExpressionTypeHolder::createMaybe($variableTypeHolder->getExpr(), $variableTypeHolder->getType()); + continue; + } + + $nativeTypes[$variableExprString] = new ExpressionTypeHolder( + $variableTypeHolder->getExpr(), + $variableTypeHolder->getType(), + $variableTypeHolder->getCertainty()->and($nativeTypes[$variableExprString]->getCertainty()), + ); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + public function generalizeWith(self $otherScope): self + { + $variableTypeHolders = $this->generalizeVariableTypeHolders( + $this->expressionTypes, + $otherScope->expressionTypes, + ); + $nativeTypes = $this->generalizeVariableTypeHolders( + $this->nativeExpressionTypes, + $otherScope->nativeExpressionTypes, + ); + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $variableTypeHolders, + $nativeTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + /** + * @param array $variableTypeHolders + * @param array $otherVariableTypeHolders + * @return array + */ + private function generalizeVariableTypeHolders( + array $variableTypeHolders, + array $otherVariableTypeHolders, + ): array + { + foreach ($variableTypeHolders as $variableExprString => $variableTypeHolder) { + if (!isset($otherVariableTypeHolders[$variableExprString])) { + continue; + } + + $variableTypeHolders[$variableExprString] = new ExpressionTypeHolder( + $variableTypeHolder->getExpr(), + self::generalizeType($variableTypeHolder->getType(), $otherVariableTypeHolders[$variableExprString]->getType(), 0), + $variableTypeHolder->getCertainty(), + ); + } + + return $variableTypeHolders; + } + + private static function generalizeType(Type $a, Type $b, int $depth): Type + { + if ($a->equals($b)) { + return $a; + } + + $constantIntegers = ['a' => [], 'b' => []]; + $constantFloats = ['a' => [], 'b' => []]; + $constantBooleans = ['a' => [], 'b' => []]; + $constantStrings = ['a' => [], 'b' => []]; + $constantArrays = ['a' => [], 'b' => []]; + $generalArrays = ['a' => [], 'b' => []]; + $integerRanges = ['a' => [], 'b' => []]; + $otherTypes = []; + + foreach ([ + 'a' => TypeUtils::flattenTypes($a), + 'b' => TypeUtils::flattenTypes($b), + ] as $key => $types) { + foreach ($types as $type) { + if ($type instanceof ConstantIntegerType) { + $constantIntegers[$key][] = $type; + continue; + } + if ($type instanceof ConstantFloatType) { + $constantFloats[$key][] = $type; + continue; + } + if ($type instanceof ConstantBooleanType) { + $constantBooleans[$key][] = $type; + continue; + } + if ($type instanceof ConstantStringType) { + $constantStrings[$key][] = $type; + continue; + } + if ($type->isConstantArray()->yes()) { + $constantArrays[$key][] = $type; + continue; + } + if ($type->isArray()->yes()) { + $generalArrays[$key][] = $type; + continue; + } + if ($type instanceof IntegerRangeType) { + $integerRanges[$key][] = $type; + continue; + } + + $otherTypes[] = $type; + } + } + + $resultTypes = []; + foreach ([ + $constantFloats, + $constantBooleans, + $constantStrings, + ] as $constantTypes) { + if (count($constantTypes['a']) === 0) { + if (count($constantTypes['b']) > 0) { + $resultTypes[] = TypeCombinator::union(...$constantTypes['b']); + } + continue; + } elseif (count($constantTypes['b']) === 0) { + $resultTypes[] = TypeCombinator::union(...$constantTypes['a']); + continue; + } + + $aTypes = TypeCombinator::union(...$constantTypes['a']); + $bTypes = TypeCombinator::union(...$constantTypes['b']); + if ($aTypes->equals($bTypes)) { + $resultTypes[] = $aTypes; + continue; + } + + $resultTypes[] = TypeCombinator::union(...$constantTypes['a'], ...$constantTypes['b'])->generalize(GeneralizePrecision::moreSpecific()); + } + + if (count($constantArrays['a']) > 0) { + if (count($constantArrays['b']) === 0) { + $resultTypes[] = TypeCombinator::union(...$constantArrays['a']); + } else { + $constantArraysA = TypeCombinator::union(...$constantArrays['a']); + $constantArraysB = TypeCombinator::union(...$constantArrays['b']); + if ($constantArraysA->getIterableKeyType()->equals($constantArraysB->getIterableKeyType())) { + $resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach (TypeUtils::flattenTypes($constantArraysA->getIterableKeyType()) as $keyType) { + $resultArrayBuilder->setOffsetValueType( + $keyType, + self::generalizeType( + $constantArraysA->getOffsetValueType($keyType), + $constantArraysB->getOffsetValueType($keyType), + $depth + 1, + ), + !$constantArraysA->hasOffsetValueType($keyType)->and($constantArraysB->hasOffsetValueType($keyType))->negate()->no(), + ); + } + + $resultTypes[] = $resultArrayBuilder->getArray(); + } else { + $resultType = new ArrayType( + TypeCombinator::union(self::generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)), + TypeCombinator::union(self::generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), + ); + if ($constantArraysA->isIterableAtLeastOnce()->yes() && $constantArraysB->isIterableAtLeastOnce()->yes()) { + $resultType = TypeCombinator::intersect($resultType, new NonEmptyArrayType()); + } + if ($constantArraysA->isList()->yes() && $constantArraysB->isList()->yes()) { + $resultType = TypeCombinator::intersect($resultType, new AccessoryArrayListType()); + } + $resultTypes[] = $resultType; + } + } + } elseif (count($constantArrays['b']) > 0) { + $resultTypes[] = TypeCombinator::union(...$constantArrays['b']); + } + + if (count($generalArrays['a']) > 0) { + if (count($generalArrays['b']) === 0) { + $resultTypes[] = TypeCombinator::union(...$generalArrays['a']); + } else { + $generalArraysA = TypeCombinator::union(...$generalArrays['a']); + $generalArraysB = TypeCombinator::union(...$generalArrays['b']); + + $aValueType = $generalArraysA->getIterableValueType(); + $bValueType = $generalArraysB->getIterableValueType(); + if ( + $aValueType->isArray()->yes() + && $aValueType->isConstantArray()->no() + && $bValueType->isArray()->yes() + && $bValueType->isConstantArray()->no() + ) { + $aDepth = self::getArrayDepth($aValueType) + $depth; + $bDepth = self::getArrayDepth($bValueType) + $depth; + if ( + ($aDepth > 2 || $bDepth > 2) + && abs($aDepth - $bDepth) > 0 + ) { + $aValueType = new MixedType(); + $bValueType = new MixedType(); + } + } + + $resultType = new ArrayType( + TypeCombinator::union(self::generalizeType($generalArraysA->getIterableKeyType(), $generalArraysB->getIterableKeyType(), $depth + 1)), + TypeCombinator::union(self::generalizeType($aValueType, $bValueType, $depth + 1)), + ); + if ($generalArraysA->isIterableAtLeastOnce()->yes() && $generalArraysB->isIterableAtLeastOnce()->yes()) { + $resultType = TypeCombinator::intersect($resultType, new NonEmptyArrayType()); + } + if ($generalArraysA->isList()->yes() && $generalArraysB->isList()->yes()) { + $resultType = TypeCombinator::intersect($resultType, new AccessoryArrayListType()); + } + if ($generalArraysA->isOversizedArray()->yes() && $generalArraysB->isOversizedArray()->yes()) { + $resultType = TypeCombinator::intersect($resultType, new OversizedArrayType()); + } + $resultTypes[] = $resultType; + } + } elseif (count($generalArrays['b']) > 0) { + $resultTypes[] = TypeCombinator::union(...$generalArrays['b']); + } + + if (count($constantIntegers['a']) > 0) { + if (count($constantIntegers['b']) === 0) { + $resultTypes[] = TypeCombinator::union(...$constantIntegers['a']); + } else { + $constantIntegersA = TypeCombinator::union(...$constantIntegers['a']); + $constantIntegersB = TypeCombinator::union(...$constantIntegers['b']); + + if ($constantIntegersA->equals($constantIntegersB)) { + $resultTypes[] = $constantIntegersA; + } else { + $min = null; + $max = null; + foreach ($constantIntegers['a'] as $int) { + if ($min === null || $int->getValue() < $min) { + $min = $int->getValue(); + } + if ($max !== null && $int->getValue() <= $max) { + continue; + } + + $max = $int->getValue(); + } + + $gotGreater = false; + $gotSmaller = false; + foreach ($constantIntegers['b'] as $int) { + if ($int->getValue() > $max) { + $gotGreater = true; + } + if ($int->getValue() >= $min) { + continue; + } + + $gotSmaller = true; + } + + if ($gotGreater && $gotSmaller) { + $resultTypes[] = new IntegerType(); + } elseif ($gotGreater) { + $resultTypes[] = IntegerRangeType::fromInterval($min, null); + } elseif ($gotSmaller) { + $resultTypes[] = IntegerRangeType::fromInterval(null, $max); + } else { + $resultTypes[] = TypeCombinator::union($constantIntegersA, $constantIntegersB); + } + } + } + } elseif (count($constantIntegers['b']) > 0) { + $resultTypes[] = TypeCombinator::union(...$constantIntegers['b']); + } + + if (count($integerRanges['a']) > 0) { + if (count($integerRanges['b']) === 0) { + $resultTypes[] = TypeCombinator::union(...$integerRanges['a']); + } else { + $integerRangesA = TypeCombinator::union(...$integerRanges['a']); + $integerRangesB = TypeCombinator::union(...$integerRanges['b']); + + if ($integerRangesA->equals($integerRangesB)) { + $resultTypes[] = $integerRangesA; + } else { + $min = null; + $max = null; + foreach ($integerRanges['a'] as $range) { + if ($range->getMin() === null) { + $rangeMin = PHP_INT_MIN; + } else { + $rangeMin = $range->getMin(); + } + if ($range->getMax() === null) { + $rangeMax = PHP_INT_MAX; + } else { + $rangeMax = $range->getMax(); + } + + if ($min === null || $rangeMin < $min) { + $min = $rangeMin; + } + if ($max !== null && $rangeMax <= $max) { + continue; + } + + $max = $rangeMax; + } + + $gotGreater = false; + $gotSmaller = false; + foreach ($integerRanges['b'] as $range) { + if ($range->getMin() === null) { + $rangeMin = PHP_INT_MIN; + } else { + $rangeMin = $range->getMin(); + } + if ($range->getMax() === null) { + $rangeMax = PHP_INT_MAX; + } else { + $rangeMax = $range->getMax(); + } + + if ($rangeMax > $max) { + $gotGreater = true; + } + if ($rangeMin >= $min) { + continue; + } + + $gotSmaller = true; + } + + if ($min === PHP_INT_MIN) { + $min = null; + } + if ($max === PHP_INT_MAX) { + $max = null; + } + + if ($gotGreater && $gotSmaller) { + $resultTypes[] = new IntegerType(); + } elseif ($gotGreater) { + $resultTypes[] = IntegerRangeType::fromInterval($min, null); + } elseif ($gotSmaller) { + $resultTypes[] = IntegerRangeType::fromInterval(null, $max); + } else { + $resultTypes[] = TypeCombinator::union($integerRangesA, $integerRangesB); + } + } + } + } elseif (count($integerRanges['b']) > 0) { + $resultTypes[] = TypeCombinator::union(...$integerRanges['b']); + } + + $accessoryTypes = array_map( + static fn (Type $type): Type => $type->generalize(GeneralizePrecision::moreSpecific()), + TypeUtils::getAccessoryTypes($a), + ); + + return TypeCombinator::union(TypeCombinator::intersect( + TypeCombinator::union(...$resultTypes, ...$otherTypes), + ...$accessoryTypes, + ), ...$otherTypes); + } + + private static function getArrayDepth(Type $type): int + { + $depth = 0; + $arrays = TypeUtils::toBenevolentUnion($type)->getArrays(); + while (count($arrays) > 0) { + $temp = $type->getIterableValueType(); + $type = $temp; + $arrays = TypeUtils::toBenevolentUnion($type)->getArrays(); + $depth++; + } + + return $depth; + } + + public function equals(self $otherScope): bool + { + if (!$this->context->equals($otherScope->context)) { + return false; + } + + if (!$this->compareVariableTypeHolders($this->expressionTypes, $otherScope->expressionTypes)) { + return false; + } + return $this->compareVariableTypeHolders($this->nativeExpressionTypes, $otherScope->nativeExpressionTypes); + } + + /** + * @param array $variableTypeHolders + * @param array $otherVariableTypeHolders + */ + private function compareVariableTypeHolders(array $variableTypeHolders, array $otherVariableTypeHolders): bool + { + if (count($variableTypeHolders) !== count($otherVariableTypeHolders)) { + return false; + } + foreach ($variableTypeHolders as $variableExprString => $variableTypeHolder) { + if (!isset($otherVariableTypeHolders[$variableExprString])) { + return false; + } + + if (!$variableTypeHolder->getCertainty()->equals($otherVariableTypeHolders[$variableExprString]->getCertainty())) { + return false; + } + + if (!$variableTypeHolder->getType()->equals($otherVariableTypeHolders[$variableExprString]->getType())) { + return false; + } + + unset($otherVariableTypeHolders[$variableExprString]); + } + + return true; + } + + private function getBooleanExpressionDepth(Expr $expr, int $depth = 0): int + { + while ( + $expr instanceof BinaryOp\BooleanOr + || $expr instanceof BinaryOp\LogicalOr + || $expr instanceof BinaryOp\BooleanAnd + || $expr instanceof BinaryOp\LogicalAnd + ) { + return $this->getBooleanExpressionDepth($expr->left, $depth + 1); + } + + return $depth; + } + + /** + * @api + * @deprecated Use canReadProperty() or canWriteProperty() + */ + public function canAccessProperty(PropertyReflection $propertyReflection): bool + { + return $this->canAccessClassMember($propertyReflection); + } + + /** @api */ + public function canReadProperty(ExtendedPropertyReflection $propertyReflection): bool + { + return $this->canAccessClassMember($propertyReflection); + } + + /** @api */ + public function canWriteProperty(ExtendedPropertyReflection $propertyReflection): bool + { + if (!$propertyReflection->isPrivateSet() && !$propertyReflection->isProtectedSet()) { + return $this->canAccessClassMember($propertyReflection); + } + + if (!$this->phpVersion->supportsAsymmetricVisibility()) { + return $this->canAccessClassMember($propertyReflection); + } + + $classReflectionName = $propertyReflection->getDeclaringClass()->getName(); + $canAccessClassMember = static function (ClassReflection $classReflection) use ($propertyReflection, $classReflectionName) { + if ($propertyReflection->isPrivateSet()) { + return $classReflection->getName() === $classReflectionName; + } + + // protected set + + if ( + $classReflection->getName() === $classReflectionName + || $classReflection->isSubclassOf($classReflectionName) + ) { + return true; + } + + return $propertyReflection->getDeclaringClass()->isSubclassOf($classReflection->getName()); + }; + + foreach ($this->inClosureBindScopeClasses as $inClosureBindScopeClass) { + if (!$this->reflectionProvider->hasClass($inClosureBindScopeClass)) { + continue; + } + + if ($canAccessClassMember($this->reflectionProvider->getClass($inClosureBindScopeClass))) { + return true; + } + } + + if ($this->isInClass()) { + return $canAccessClassMember($this->getClassReflection()); + } + + return false; + } + + /** @api */ + public function canCallMethod(MethodReflection $methodReflection): bool + { + if ($this->canAccessClassMember($methodReflection)) { + return true; + } + + return $this->canAccessClassMember($methodReflection->getPrototype()); + } + + /** @api */ + public function canAccessConstant(ClassConstantReflection $constantReflection): bool + { + return $this->canAccessClassMember($constantReflection); + } + + private function canAccessClassMember(ClassMemberReflection $classMemberReflection): bool + { + if ($classMemberReflection->isPublic()) { + return true; + } + + $classReflectionName = $classMemberReflection->getDeclaringClass()->getName(); + $canAccessClassMember = static function (ClassReflection $classReflection) use ($classMemberReflection, $classReflectionName) { + if ($classMemberReflection->isPrivate()) { + return $classReflection->getName() === $classReflectionName; + } + + // protected + + if ( + $classReflection->getName() === $classReflectionName + || $classReflection->isSubclassOf($classReflectionName) + ) { + return true; + } + + return $classMemberReflection->getDeclaringClass()->isSubclassOf($classReflection->getName()); + }; + + foreach ($this->inClosureBindScopeClasses as $inClosureBindScopeClass) { + if (!$this->reflectionProvider->hasClass($inClosureBindScopeClass)) { + continue; + } + + if ($canAccessClassMember($this->reflectionProvider->getClass($inClosureBindScopeClass))) { + return true; + } + } + + if ($this->isInClass()) { + return $canAccessClassMember($this->getClassReflection()); + } + + return false; + } + + /** + * @return string[] + */ + public function debug(): array + { + $descriptions = []; + foreach ($this->expressionTypes as $name => $variableTypeHolder) { + $key = sprintf('%s (%s)', $name, $variableTypeHolder->getCertainty()->describe()); + $descriptions[$key] = $variableTypeHolder->getType()->describe(VerbosityLevel::precise()); + } + foreach ($this->nativeExpressionTypes as $exprString => $nativeTypeHolder) { + $key = sprintf('native %s (%s)', $exprString, $nativeTypeHolder->getCertainty()->describe()); + $descriptions[$key] = $nativeTypeHolder->getType()->describe(VerbosityLevel::precise()); + } + + foreach ($this->conditionalExpressions as $exprString => $holders) { + foreach (array_values($holders) as $i => $holder) { + $key = sprintf('condition about %s #%d', $exprString, $i + 1); + $parts = []; + foreach ($holder->getConditionExpressionTypeHolders() as $conditionalExprString => $expressionTypeHolder) { + $parts[] = $conditionalExprString . '=' . $expressionTypeHolder->getType()->describe(VerbosityLevel::precise()); + } + $condition = implode(' && ', $parts); + $descriptions[$key] = sprintf( + 'if %s then %s is %s (%s)', + $condition, + $exprString, + $holder->getTypeHolder()->getType()->describe(VerbosityLevel::precise()), + $holder->getTypeHolder()->getCertainty()->describe(), + ); + } + } + + return $descriptions; + } + + /** + * @param non-empty-string $className + */ + private function exactInstantiation(New_ $node, string $className): ?Type + { + $resolvedClassName = $this->resolveExactName(new Name($className)); + $isStatic = false; + if ($resolvedClassName === null) { + if (strtolower($className) !== 'static') { + return null; + } + + if (!$this->isInClass()) { + return null; + } + $resolvedClassName = $this->getClassReflection()->getName(); + $isStatic = true; + } + + if (!$this->reflectionProvider->hasClass($resolvedClassName)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($resolvedClassName); + if ($classReflection->hasConstructor()) { + $constructorMethod = $classReflection->getConstructor(); + } else { + $constructorMethod = new DummyConstructorReflection($classReflection); + } + + $resolvedTypes = []; + $methodCall = new Expr\StaticCall( + new Name($resolvedClassName), + new Node\Identifier($constructorMethod->getName()), + $node->getArgs(), + ); + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $this, + $methodCall->getArgs(), + $constructorMethod->getVariants(), + $constructorMethod->getNamedArgumentsVariants(), + ); + $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); + + if ($normalizedMethodCall !== null) { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($classReflection->getName()) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($constructorMethod)) { + continue; + } + + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $constructorMethod, + $normalizedMethodCall, + $this, + ); + if ($resolvedType === null) { + continue; + } + + $resolvedTypes[] = $resolvedType; + } + } + + if (count($resolvedTypes) > 0) { + return TypeCombinator::union(...$resolvedTypes); + } + + $methodResult = $this->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return $methodResult; + } + + $objectType = $isStatic ? new StaticType($classReflection) : new ObjectType($resolvedClassName); + if (!$classReflection->isGeneric()) { + return $objectType; + } + + $assignedToProperty = $node->getAttribute(NewAssignedToPropertyVisitor::ATTRIBUTE_NAME); + if ($assignedToProperty !== null) { + $constructorVariant = $constructorMethod->getOnlyVariant(); + $classTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + $originalClassTemplateTypes = $classTemplateTypes; + foreach ($constructorVariant->getParameters() as $parameter) { + TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use (&$classTemplateTypes): Type { + if ($type instanceof TemplateType && array_key_exists($type->getName(), $classTemplateTypes)) { + $classTemplateType = $classTemplateTypes[$type->getName()]; + if ($classTemplateType instanceof TemplateType && $classTemplateType->getScope()->equals($type->getScope())) { + unset($classTemplateTypes[$type->getName()]); + } + return $type; + } + + return $traverse($type); + }); + } + + if (count($classTemplateTypes) === count($originalClassTemplateTypes)) { + $propertyType = TypeCombinator::removeNull($this->getType($assignedToProperty)); + if ($objectType->isSuperTypeOf($propertyType)->yes()) { + return $propertyType; + } + } + } + + if ($constructorMethod instanceof DummyConstructorReflection) { + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + null, + [], + ); + } + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + ); + } + + if ($constructorMethod->getDeclaringClass()->getName() !== $classReflection->getName()) { + if (!$constructorMethod->getDeclaringClass()->isGeneric()) { + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + null, + [], + ); + } + + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + ); + } + $newType = new GenericObjectType($resolvedClassName, $classReflection->typeMapToList($classReflection->getTemplateTypeMap())); + $ancestorType = $newType->getAncestorWithClassName($constructorMethod->getDeclaringClass()->getName()); + if ($ancestorType === null) { + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + null, + [], + ); + } + + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + ); + } + $ancestorClassReflections = $ancestorType->getObjectClassReflections(); + if (count($ancestorClassReflections) !== 1) { + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + null, + [], + ); + } + + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + ); + } + + $newParentNode = new New_(new Name($constructorMethod->getDeclaringClass()->getName()), $node->args); + $newParentType = $this->getType($newParentNode); + $newParentTypeClassReflections = $newParentType->getObjectClassReflections(); + if (count($newParentTypeClassReflections) !== 1) { + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + null, + [], + ); + } + + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + ); + } + $newParentTypeClassReflection = $newParentTypeClassReflections[0]; + + $ancestorClassReflection = $ancestorClassReflections[0]; + $ancestorMapping = []; + foreach ($ancestorClassReflection->getActiveTemplateTypeMap()->getTypes() as $typeName => $templateType) { + if (!$templateType instanceof TemplateType) { + continue; + } + + $ancestorMapping[$typeName] = $templateType; + } + + $resolvedTypeMap = []; + foreach ($newParentTypeClassReflection->getActiveTemplateTypeMap()->getTypes() as $typeName => $type) { + if (!array_key_exists($typeName, $ancestorMapping)) { + continue; + } + + $ancestorType = $ancestorMapping[$typeName]; + if (!$ancestorType->getBound()->isSuperTypeOf($type)->yes()) { + continue; + } + + if (!array_key_exists($ancestorType->getName(), $resolvedTypeMap)) { + $resolvedTypeMap[$ancestorType->getName()] = $type; + continue; + } + + $resolvedTypeMap[$ancestorType->getName()] = TypeCombinator::union($resolvedTypeMap[$ancestorType->getName()], $type); + } + + if ($isStatic) { + return new GenericStaticType( + $classReflection, + $classReflection->typeMapToList(new TemplateTypeMap($resolvedTypeMap)), + null, + [], + ); + } + + return new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList(new TemplateTypeMap($resolvedTypeMap)), + ); + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $this, + $methodCall->getArgs(), + $constructorMethod->getVariants(), + $constructorMethod->getNamedArgumentsVariants(), + ); + + $resolvedTemplateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $newGenericType = new GenericObjectType( + $resolvedClassName, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()), + ); + if ($isStatic) { + $newGenericType = new GenericStaticType( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()), + null, + [], + ); + } + return TypeTraverser::map($newGenericType, static function (Type $type, callable $traverse) use ($resolvedTemplateTypeMap): Type { + if ($type instanceof TemplateType && !$type->isArgument()) { + $newType = $resolvedTemplateTypeMap->getType($type->getName()); + if ($newType === null || $newType instanceof ErrorType) { + return $type->getDefault() ?? $type->getBound(); + } + + return TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + } + + return $traverse($type); + }); + } + + private function filterTypeWithMethod(Type $typeWithMethod, string $methodName): ?Type + { + if ($typeWithMethod instanceof UnionType) { + $typeWithMethod = $typeWithMethod->filterTypes(static fn (Type $innerType) => $innerType->hasMethod($methodName)->yes()); + } + + if (!$typeWithMethod->hasMethod($methodName)->yes()) { + return null; + } + + return $typeWithMethod; + } + + /** @api */ + public function getMethodReflection(Type $typeWithMethod, string $methodName): ?ExtendedMethodReflection + { + $type = $this->filterTypeWithMethod($typeWithMethod, $methodName); + if ($type === null) { + return null; + } + + return $type->getMethod($methodName, $this); + } + + public function getNakedMethod(Type $typeWithMethod, string $methodName): ?ExtendedMethodReflection + { + $type = $this->filterTypeWithMethod($typeWithMethod, $methodName); + if ($type === null) { + return null; + } + + return $type->getUnresolvedMethodPrototype($methodName, $this)->getNakedMethod(); + } + + /** + * @param MethodCall|Node\Expr\StaticCall $methodCall + */ + private function methodCallReturnType(Type $typeWithMethod, string $methodName, Expr $methodCall): ?Type + { + $typeWithMethod = $this->filterTypeWithMethod($typeWithMethod, $methodName); + if ($typeWithMethod === null) { + return null; + } + + $methodReflection = $typeWithMethod->getMethod($methodName, $this); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $this, + $methodCall->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + if ($methodCall instanceof MethodCall) { + $normalizedMethodCall = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $methodCall); + } else { + $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); + } + if ($normalizedMethodCall === null) { + return $this->transformVoidToNull($parametersAcceptor->getReturnType(), $methodCall); + } + + $resolvedTypes = []; + foreach ($typeWithMethod->getObjectClassNames() as $className) { + if ($normalizedMethodCall instanceof MethodCall) { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { + continue; + } + + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $this); + if ($resolvedType === null) { + continue; + } + + $resolvedTypes[] = $resolvedType; + } + } else { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { + continue; + } + + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $methodReflection, + $normalizedMethodCall, + $this, + ); + if ($resolvedType === null) { + continue; + } + + $resolvedTypes[] = $resolvedType; + } + } + } + + if (count($resolvedTypes) > 0) { + return $this->transformVoidToNull(TypeCombinator::union(...$resolvedTypes), $methodCall); + } + + return $this->transformVoidToNull($parametersAcceptor->getReturnType(), $methodCall); + } + + /** @api */ + public function getPropertyReflection(Type $typeWithProperty, string $propertyName): ?ExtendedPropertyReflection + { + if ($typeWithProperty instanceof UnionType) { + $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasProperty($propertyName)->yes()); + } + if (!$typeWithProperty->hasProperty($propertyName)->yes()) { + return null; + } + + return $typeWithProperty->getProperty($propertyName, $this); + } + + /** + * @param PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch + */ + private function propertyFetchType(Type $fetchedOnType, string $propertyName, Expr $propertyFetch): ?Type + { + $propertyReflection = $this->getPropertyReflection($fetchedOnType, $propertyName); + if ($propertyReflection === null) { + return null; + } + + if ($this->isInExpressionAssign($propertyFetch)) { + return $propertyReflection->getWritableType(); + } + + return $propertyReflection->getReadableType(); + } + + public function getConstantReflection(Type $typeWithConstant, string $constantName): ?ClassConstantReflection + { + if ($typeWithConstant instanceof UnionType) { + $typeWithConstant = $typeWithConstant->filterTypes(static fn (Type $innerType) => $innerType->hasConstant($constantName)->yes()); + } + if (!$typeWithConstant->hasConstant($constantName)->yes()) { + return null; + } + + return $typeWithConstant->getConstant($constantName); + } + + /** + * @return array + */ + private function getConstantTypes(): array + { + $constantTypes = []; + foreach ($this->expressionTypes as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); + if (!$expr instanceof ConstFetch) { + continue; + } + $constantTypes[$exprString] = $typeHolder; + } + return $constantTypes; + } + + private function getGlobalConstantType(Name $name): ?Type + { + $fetches = []; + if (!$name->isFullyQualified() && $this->getNamespace() !== null) { + $fetches[] = new ConstFetch(new FullyQualified([$this->getNamespace(), $name->toString()])); + } + + $fetches[] = new ConstFetch(new FullyQualified($name->toString())); + $fetches[] = new ConstFetch($name); + + foreach ($fetches as $constFetch) { + if ($this->hasExpressionType($constFetch)->yes()) { + return $this->getType($constFetch); + } + } + + return null; + } + + /** + * @return array + */ + private function getNativeConstantTypes(): array + { + $constantTypes = []; + foreach ($this->nativeExpressionTypes as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); + if (!$expr instanceof ConstFetch) { + continue; + } + $constantTypes[$exprString] = $typeHolder; + } + return $constantTypes; + } + + public function getIterableKeyType(Type $iteratee): Type + { + if ($iteratee instanceof UnionType) { + $filtered = $iteratee->filterTypes(static fn (Type $innerType) => $innerType->isIterable()->yes()); + if (!$filtered instanceof NeverType) { + $iteratee = $filtered; + } + } + + return $iteratee->getIterableKeyType(); + } + + public function getIterableValueType(Type $iteratee): Type + { + if ($iteratee instanceof UnionType) { + $filtered = $iteratee->filterTypes(static fn (Type $innerType) => $innerType->isIterable()->yes()); + if (!$filtered instanceof NeverType) { + $iteratee = $filtered; + } + } + + return $iteratee->getIterableValueType(); + } + + public function getPhpVersion(): PhpVersions + { + $constType = $this->getGlobalConstantType(new Name('PHP_VERSION_ID')); + if ($constType !== null) { + return new PhpVersions($constType); + } + + if (is_array($this->configPhpVersion)) { + return new PhpVersions(IntegerRangeType::fromInterval($this->configPhpVersion['min'], $this->configPhpVersion['max'])); + } + return new PhpVersions(new ConstantIntegerType($this->phpVersion->getVersionId())); + } + +} diff --git a/src/Analyser/NameScope.php b/src/Analyser/NameScope.php new file mode 100644 index 00000000..ccc487c0 --- /dev/null +++ b/src/Analyser/NameScope.php @@ -0,0 +1,226 @@ + $uses alias(string) => fullName(string) + * @param array $constUses alias(string) => fullName(string) + * @param array $typeAliasesMap + */ + public function __construct(private ?string $namespace, private array $uses, private ?string $className = null, private ?string $functionName = null, ?TemplateTypeMap $templateTypeMap = null, private array $typeAliasesMap = [], private bool $bypassTypeAliases = false, private array $constUses = [], private ?string $typeAliasClassName = null) + { + $this->templateTypeMap = $templateTypeMap ?? TemplateTypeMap::createEmpty(); + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + /** + * @return array + */ + public function getUses(): array + { + return $this->uses; + } + + public function hasUseAlias(string $name): bool + { + return isset($this->uses[strtolower($name)]); + } + + /** + * @return array + */ + public function getConstUses(): array + { + return $this->constUses; + } + + public function getClassName(): ?string + { + return $this->className; + } + + public function getClassNameForTypeAlias(): ?string + { + return $this->typeAliasClassName ?? $this->className; + } + + public function resolveStringName(string $name): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $nameParts = explode('\\', $name); + $firstNamePart = strtolower($nameParts[0]); + if (isset($this->uses[$firstNamePart])) { + if (count($nameParts) === 1) { + return $this->uses[$firstNamePart]; + } + array_shift($nameParts); + return sprintf('%s\\%s', $this->uses[$firstNamePart], implode('\\', $nameParts)); + } + + if ($this->namespace !== null) { + return sprintf('%s\\%s', $this->namespace, $name); + } + + return $name; + } + + /** + * @return non-empty-list + */ + public function resolveConstantNames(string $name): array + { + if (str_starts_with($name, '\\')) { + return [ltrim($name, '\\')]; + } + + $nameParts = explode('\\', $name); + $firstNamePart = strtolower($nameParts[0]); + + if (count($nameParts) > 1) { + if (isset($this->uses[$firstNamePart])) { + array_shift($nameParts); + return [sprintf('%s\\%s', $this->uses[$firstNamePart], implode('\\', $nameParts))]; + } + } elseif (isset($this->constUses[$firstNamePart])) { + return [$this->constUses[$firstNamePart]]; + } + + if ($this->namespace !== null) { + return [ + sprintf('%s\\%s', $this->namespace, $name), + $name, + ]; + } + + return [$name]; + } + + public function getTemplateTypeScope(): ?TemplateTypeScope + { + if ($this->className !== null) { + if ($this->functionName !== null) { + return TemplateTypeScope::createWithMethod($this->className, $this->functionName); + } + + return TemplateTypeScope::createWithClass($this->className); + } + + if ($this->functionName !== null) { + return TemplateTypeScope::createWithFunction($this->functionName); + } + + return null; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function resolveTemplateTypeName(string $name): ?Type + { + return $this->templateTypeMap->getType($name); + } + + public function withTemplateTypeMap(TemplateTypeMap $map): self + { + if ($map->isEmpty() && $this->templateTypeMap->isEmpty()) { + return $this; + } + + return new self( + $this->namespace, + $this->uses, + $this->className, + $this->functionName, + new TemplateTypeMap(array_merge( + $this->templateTypeMap->getTypes(), + $map->getTypes(), + )), + $this->typeAliasesMap, + $this->bypassTypeAliases, + $this->constUses, + ); + } + + public function withClassName(string $className): self + { + return new self( + $this->namespace, + $this->uses, + $className, + $this->functionName, + $this->templateTypeMap, + $this->typeAliasesMap, + $this->bypassTypeAliases, + $this->constUses, + ); + } + + public function unsetTemplateType(string $name): self + { + $map = $this->templateTypeMap; + if (!$map->hasType($name)) { + return $this; + } + + return new self( + $this->namespace, + $this->uses, + $this->className, + $this->functionName, + $this->templateTypeMap->unsetType($name), + $this->typeAliasesMap, + $this->bypassTypeAliases, + $this->constUses, + ); + } + + public function bypassTypeAliases(): self + { + return new self($this->namespace, $this->uses, $this->className, $this->functionName, $this->templateTypeMap, $this->typeAliasesMap, true, $this->constUses); + } + + public function shouldBypassTypeAliases(): bool + { + return $this->bypassTypeAliases; + } + + public function hasTypeAlias(string $alias): bool + { + return array_key_exists($alias, $this->typeAliasesMap); + } + +} diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php new file mode 100644 index 00000000..5936ee6a --- /dev/null +++ b/src/Analyser/NodeScopeResolver.php @@ -0,0 +1,6595 @@ + bool(true) */ + private array $analysedFiles = []; + + /** @var array */ + private array $earlyTerminatingMethodNames; + + /** @var array */ + private array $calledMethodStack = []; + + /** @var array */ + private array $calledMethodResults = []; + + /** + * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) + * @param array $earlyTerminatingFunctionCalls + * @param string[] $universalObjectCratesClasses + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly InitializerExprTypeResolver $initializerExprTypeResolver, + private readonly Reflector $reflector, + private readonly ClassReflectionExtensionRegistryProvider $classReflectionExtensionRegistryProvider, + private readonly ParameterOutTypeExtensionProvider $parameterOutTypeExtensionProvider, + private readonly Parser $parser, + private readonly FileTypeMapper $fileTypeMapper, + private readonly StubPhpDocProvider $stubPhpDocProvider, + private readonly PhpVersion $phpVersion, + private readonly SignatureMapProvider $signatureMapProvider, + private readonly AttributeReflectionFactory $attributeReflectionFactory, + private readonly PhpDocInheritanceResolver $phpDocInheritanceResolver, + private readonly FileHelper $fileHelper, + private readonly TypeSpecifier $typeSpecifier, + private readonly DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, + private readonly ReadWritePropertiesExtensionProvider $readWritePropertiesExtensionProvider, + private readonly ParameterClosureTypeExtensionProvider $parameterClosureTypeExtensionProvider, + private readonly ScopeFactory $scopeFactory, + private readonly bool $polluteScopeWithLoopInitialAssignments, + private readonly bool $polluteScopeWithAlwaysIterableForeach, + private readonly bool $polluteScopeWithBlock, + private readonly array $earlyTerminatingMethodCalls, + private readonly array $earlyTerminatingFunctionCalls, + private readonly array $universalObjectCratesClasses, + private readonly bool $implicitThrows, + private readonly bool $treatPhpDocTypesAsCertain, + ) + { + $earlyTerminatingMethodNames = []; + foreach ($this->earlyTerminatingMethodCalls as $methodNames) { + foreach ($methodNames as $methodName) { + $earlyTerminatingMethodNames[strtolower($methodName)] = true; + } + } + $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + } + + /** + * @api + * @param string[] $files + */ + public function setAnalysedFiles(array $files): void + { + $this->analysedFiles = array_fill_keys($files, true); + } + + /** + * @api + * @param Node[] $nodes + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processNodes( + array $nodes, + MutatingScope $scope, + callable $nodeCallback, + ): void + { + $alreadyTerminated = false; + foreach ($nodes as $i => $node) { + if ( + !$node instanceof Node\Stmt + || ($alreadyTerminated && !($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike)) + ) { + continue; + } + + $statementResult = $this->processStmtNode($node, $scope, $nodeCallback, StatementContext::createTopLevel()); + $scope = $statementResult->getScope(); + if ($alreadyTerminated || !$statementResult->isAlwaysTerminating()) { + continue; + } + + $alreadyTerminated = true; + $nextStmts = $this->getNextUnreachableStatements(array_slice($nodes, $i + 1), true); + $this->processUnreachableStatement($nextStmts, $scope, $nodeCallback); + } + } + + /** + * @param Node\Stmt[] $nextStmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processUnreachableStatement(array $nextStmts, MutatingScope $scope, callable $nodeCallback): void + { + if ($nextStmts === []) { + return; + } + + $unreachableStatement = null; + $nextStatements = []; + + foreach ($nextStmts as $key => $nextStmt) { + if ($key === 0) { + $unreachableStatement = $nextStmt; + continue; + } + + $nextStatements[] = $nextStmt; + } + + if (!$unreachableStatement instanceof Node\Stmt) { + return; + } + + $nodeCallback(new UnreachableStatementNode($unreachableStatement, $nextStatements), $scope); + } + + /** + * @api + * @param Node\Stmt[] $stmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processStmtNodes( + Node $parentNode, + array $stmts, + MutatingScope $scope, + callable $nodeCallback, + StatementContext $context, + ): StatementResult + { + $exitPoints = []; + $throwPoints = []; + $impurePoints = []; + $alreadyTerminated = false; + $hasYield = false; + $stmtCount = count($stmts); + $shouldCheckLastStatement = $parentNode instanceof Node\Stmt\Function_ + || $parentNode instanceof Node\Stmt\ClassMethod + || $parentNode instanceof PropertyHookStatementNode + || $parentNode instanceof Expr\Closure; + foreach ($stmts as $i => $stmt) { + if ($alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike)) { + continue; + } + + $isLast = $i === $stmtCount - 1; + $statementResult = $this->processStmtNode( + $stmt, + $scope, + $nodeCallback, + $context, + ); + $scope = $statementResult->getScope(); + $hasYield = $hasYield || $statementResult->hasYield(); + + if ($shouldCheckLastStatement && $isLast) { + /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|PropertyHookStatementNode|Expr\Closure $parentNode */ + $parentNode = $parentNode; + + $endStatements = $statementResult->getEndStatements(); + if (count($endStatements) > 0) { + foreach ($endStatements as $endStatement) { + $endStatementResult = $endStatement->getResult(); + $nodeCallback(new ExecutionEndNode( + $endStatement->getStatement(), + new StatementResult( + $endStatementResult->getScope(), + $hasYield, + $endStatementResult->isAlwaysTerminating(), + $endStatementResult->getExitPoints(), + $endStatementResult->getThrowPoints(), + $endStatementResult->getImpurePoints(), + ), + $parentNode->getReturnType() !== null, + ), $endStatementResult->getScope()); + } + } else { + $nodeCallback(new ExecutionEndNode( + $stmt, + new StatementResult( + $scope, + $hasYield, + $statementResult->isAlwaysTerminating(), + $statementResult->getExitPoints(), + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + ), + $parentNode->getReturnType() !== null, + ), $scope); + } + } + + $exitPoints = array_merge($exitPoints, $statementResult->getExitPoints()); + $throwPoints = array_merge($throwPoints, $statementResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $statementResult->getImpurePoints()); + + if ($alreadyTerminated || !$statementResult->isAlwaysTerminating()) { + continue; + } + + $alreadyTerminated = true; + $nextStmts = $this->getNextUnreachableStatements(array_slice($stmts, $i + 1), $parentNode instanceof Node\Stmt\Namespace_); + $this->processUnreachableStatement($nextStmts, $scope, $nodeCallback); + } + + $statementResult = new StatementResult($scope, $hasYield, $alreadyTerminated, $exitPoints, $throwPoints, $impurePoints); + if ($stmtCount === 0 && $shouldCheckLastStatement) { + /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|PropertyHookStatementNode|Expr\Closure $parentNode */ + $parentNode = $parentNode; + $returnTypeNode = $parentNode->getReturnType(); + if ($parentNode instanceof Expr\Closure) { + $parentNode = new Node\Stmt\Expression($parentNode, $parentNode->getAttributes()); + } + $nodeCallback(new ExecutionEndNode( + $parentNode, + $statementResult, + $returnTypeNode !== null, + ), $scope); + } + + return $statementResult; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processStmtNode( + Node\Stmt $stmt, + MutatingScope $scope, + callable $nodeCallback, + StatementContext $context, + ): StatementResult + { + if ( + !$stmt instanceof Static_ + && !$stmt instanceof Foreach_ + && !$stmt instanceof Node\Stmt\Global_ + && !$stmt instanceof Node\Stmt\Property + && !$stmt instanceof Node\Stmt\ClassConst + && !$stmt instanceof Node\Stmt\Const_ + ) { + $scope = $this->processStmtVarAnnotation($scope, $stmt, null, $nodeCallback); + } + + if ($stmt instanceof Node\Stmt\ClassMethod) { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + if ( + $scope->isInTrait() + && $scope->getClassReflection()->hasNativeMethod($stmt->name->toString()) + ) { + $methodReflection = $scope->getClassReflection()->getNativeMethod($stmt->name->toString()); + if ($methodReflection instanceof NativeMethodReflection) { + return new StatementResult($scope, false, false, [], [], []); + } + if ($methodReflection instanceof PhpMethodReflection) { + $declaringTrait = $methodReflection->getDeclaringTrait(); + if ($declaringTrait === null || $declaringTrait->getName() !== $scope->getTraitReflection()->getName()) { + return new StatementResult($scope, false, false, [], [], []); + } + } + } + } + + $stmtScope = $scope; + if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Expr\Throw_) { + $stmtScope = $this->processStmtVarAnnotation($scope, $stmt, $stmt->expr->expr, $nodeCallback); + } + if ($stmt instanceof Return_) { + $stmtScope = $this->processStmtVarAnnotation($scope, $stmt, $stmt->expr, $nodeCallback); + } + + $nodeCallback($stmt, $stmtScope); + + $overridingThrowPoints = $this->getOverridingThrowPoints($stmt, $scope); + + if ($stmt instanceof Node\Stmt\Declare_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $alwaysTerminating = false; + $exitPoints = []; + foreach ($stmt->declares as $declare) { + $nodeCallback($declare, $scope); + $nodeCallback($declare->value, $scope); + if ( + $declare->key->name !== 'strict_types' + || !($declare->value instanceof Node\Scalar\Int_) + || $declare->value->value !== 1 + ) { + continue; + } + + $scope = $scope->enterDeclareStrictTypes(); + } + + if ($stmt->stmts !== null) { + $result = $this->processStmtNodes($stmt, $stmt->stmts, $scope, $nodeCallback, $context); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $alwaysTerminating = $result->isAlwaysTerminating(); + $exitPoints = $result->getExitPoints(); + } + + return new StatementResult($scope, $hasYield, $alwaysTerminating, $exitPoints, $throwPoints, $impurePoints); + } elseif ($stmt instanceof Node\Stmt\Function_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, , $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts,, $phpDocParameterOutTypes] = $this->getPhpDocs($scope, $stmt); + + foreach ($stmt->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + + if ($stmt->returnType !== null) { + $nodeCallback($stmt->returnType, $scope); + } + + if (!$isDeprecated) { + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $stmt); + } + + $functionScope = $scope->enterFunction( + $stmt, + $templateTypeMap, + $phpDocParameterTypes, + $phpDocReturnType, + $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isPure, + $acceptsNamedArguments, + $asserts, + $phpDocComment, + $phpDocParameterOutTypes, + $phpDocImmediatelyInvokedCallableParameters, + $phpDocClosureThisTypeParameters, + ); + $functionReflection = $functionScope->getFunction(); + if (!$functionReflection instanceof PhpFunctionFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } + + $nodeCallback(new InFunctionNode($functionReflection, $stmt), $functionScope); + + $gatheredReturnStatements = []; + $gatheredYieldStatements = []; + $executionEnds = []; + $functionImpurePoints = []; + $statementResult = $this->processStmtNodes($stmt, $stmt->stmts, $functionScope, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $functionScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $functionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { + return; + } + + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel()); + + $nodeCallback(new FunctionReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $functionImpurePoints), + $functionReflection, + ), $functionScope); + } elseif ($stmt instanceof Node\Stmt\ClassMethod) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes] = $this->getPhpDocs($scope, $stmt); + + foreach ($stmt->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + + if ($stmt->returnType !== null) { + $nodeCallback($stmt->returnType, $scope); + } + + if (!$isDeprecated) { + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $stmt); + } + + $isFromTrait = $stmt->getAttribute('originalTraitMethodName') === '__construct'; + $isConstructor = $isFromTrait || $stmt->name->toLowerString() === '__construct'; + + $methodScope = $scope->enterClassMethod( + $stmt, + $templateTypeMap, + $phpDocParameterTypes, + $phpDocReturnType, + $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isFinal, + $isPure, + $acceptsNamedArguments, + $asserts, + $selfOutType, + $phpDocComment, + $phpDocParameterOutTypes, + $phpDocImmediatelyInvokedCallableParameters, + $phpDocClosureThisTypeParameters, + $isConstructor, + ); + + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $classReflection = $scope->getClassReflection(); + + if ($isConstructor) { + foreach ($stmt->params as $param) { + if ($param->flags === 0 && $param->hooks === []) { + continue; + } + + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $phpDoc = null; + if ($param->getDocComment() !== null) { + $phpDoc = $param->getDocComment()->getText(); + } + $nodeCallback(new ClassPropertyNode( + $param->var->name, + $param->flags, + $param->type !== null ? ParserNodeTypeToPHPStanType::resolve($param->type, $classReflection) : null, + null, + $phpDoc, + $phpDocParameterTypes[$param->var->name] ?? null, + true, + $isFromTrait, + $param, + false, + $scope->isInTrait(), + $classReflection->isReadOnly(), + false, + $classReflection, + ), $methodScope); + $this->processPropertyHooks( + $stmt, + $param->type, + $phpDocParameterTypes[$param->var->name] ?? null, + $param->var->name, + $param->hooks, + $scope, + $nodeCallback, + ); + $methodScope = $methodScope->assignExpression(new PropertyInitializationExpr($param->var->name), new MixedType(), new MixedType()); + } + } + + if ($stmt->getAttribute('virtual', false) === false) { + $methodReflection = $methodScope->getFunction(); + if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } + $nodeCallback(new InClassMethodNode($classReflection, $methodReflection, $stmt), $methodScope); + } + + if ($stmt->stmts !== null) { + $gatheredReturnStatements = []; + $gatheredYieldStatements = []; + $executionEnds = []; + $methodImpurePoints = []; + $statementResult = $this->processStmtNodes($stmt, $stmt->stmts, $methodScope, static function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $methodScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + if ( + $node->getPropertyFetch() instanceof Expr\PropertyFetch + && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection + && $scope->getFunction()->getDeclaringClass()->hasConstructor() + && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() + && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null + ) { + return; + } + $methodImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { + return; + } + + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel()); + + $methodReflection = $methodScope->getFunction(); + if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } + + $nodeCallback(new MethodReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $methodImpurePoints), + $classReflection, + $methodReflection, + ), $methodScope); + } + } elseif ($stmt instanceof Echo_) { + $hasYield = false; + $throwPoints = []; + foreach ($stmt->exprs as $echoExpr) { + $result = $this->processExprNode($stmt, $echoExpr, $scope, $nodeCallback, ExpressionContext::createDeep()); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + } + + $throwPoints = $overridingThrowPoints ?? $throwPoints; + $impurePoints = [ + new ImpurePoint($scope, $stmt, 'echo', 'echo', true), + ]; + } elseif ($stmt instanceof Return_) { + if ($stmt->expr !== null) { + $result = $this->processExprNode($stmt, $stmt->expr, $scope, $nodeCallback, ExpressionContext::createDeep()); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + } else { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + } + + return new StatementResult($scope, $hasYield, true, [ + new StatementExitPoint($stmt, $scope), + ], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } elseif ($stmt instanceof Continue_ || $stmt instanceof Break_) { + if ($stmt->num !== null) { + $result = $this->processExprNode($stmt, $stmt->num, $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + } else { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + } + + return new StatementResult($scope, $hasYield, true, [ + new StatementExitPoint($stmt, $scope), + ], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } elseif ($stmt instanceof Node\Stmt\Expression) { + if ($stmt->expr instanceof Expr\Throw_) { + $scope = $stmtScope; + } + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); + $hasAssign = false; + $currentScope = $scope; + $result = $this->processExprNode($stmt, $stmt->expr, $scope, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { + $nodeCallback($node, $scope); + if ($scope->getAnonymousFunctionReflection() !== $currentScope->getAnonymousFunctionReflection()) { + return; + } + if ($scope->getFunction() !== $currentScope->getFunction()) { + return; + } + if (!$node instanceof VariableAssignNode && !$node instanceof PropertyAssignNode) { + return; + } + + $hasAssign = true; + }, ExpressionContext::createTopLevel()); + $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); + if ( + count($result->getImpurePoints()) === 0 + && count($throwPoints) === 0 + && !$stmt->expr instanceof Expr\PostInc + && !$stmt->expr instanceof Expr\PreInc + && !$stmt->expr instanceof Expr\PostDec + && !$stmt->expr instanceof Expr\PreDec + ) { + $nodeCallback(new NoopExpressionNode($stmt->expr, $hasAssign), $scope); + } + $scope = $result->getScope(); + $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $scope, + $stmt->expr, + TypeSpecifierContext::createNull(), + )); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + if ($earlyTerminationExpr !== null) { + return new StatementResult($scope, $hasYield, true, [ + new StatementExitPoint($stmt, $scope), + ], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } + return new StatementResult($scope, $hasYield, false, [], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } elseif ($stmt instanceof Node\Stmt\Namespace_) { + if ($stmt->name !== null) { + $scope = $scope->enterNamespace($stmt->name->toString()); + } + + $scope = $this->processStmtNodes($stmt, $stmt->stmts, $scope, $nodeCallback, $context)->getScope(); + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + } elseif ($stmt instanceof Node\Stmt\Trait_) { + return new StatementResult($scope, false, false, [], [], []); + } elseif ($stmt instanceof Node\Stmt\ClassLike) { + if (!$context->isTopLevel()) { + return new StatementResult($scope, false, false, [], [], []); + } + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + if (isset($stmt->namespacedName)) { + $classReflection = $this->getCurrentClassReflection($stmt, $stmt->namespacedName->toString(), $scope); + $classScope = $scope->enterClass($classReflection); + $nodeCallback(new InClassNode($stmt, $classReflection), $classScope); + } elseif ($stmt instanceof Class_) { + if ($stmt->name === null) { + throw new ShouldNotHappenException(); + } + if (!$stmt->isAnonymous()) { + $classReflection = $this->reflectionProvider->getClass($stmt->name->toString()); + } else { + $classReflection = $this->reflectionProvider->getAnonymousClassReflection($stmt, $scope); + } + $classScope = $scope->enterClass($classReflection); + $nodeCallback(new InClassNode($stmt, $classReflection), $classScope); + } else { + throw new ShouldNotHappenException(); + } + + $classStatementsGatherer = new ClassStatementsGatherer($classReflection, $nodeCallback); + $this->processAttributeGroups($stmt, $stmt->attrGroups, $classScope, $classStatementsGatherer); + + $this->processStmtNodes($stmt, $stmt->stmts, $classScope, $classStatementsGatherer, $context); + $nodeCallback(new ClassPropertiesNode($stmt, $this->readWritePropertiesExtensionProvider, $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls(), $classStatementsGatherer->getReturnStatementsNodes(), $classStatementsGatherer->getPropertyAssigns(), $classReflection), $classScope); + $nodeCallback(new ClassMethodsNode($stmt, $classStatementsGatherer->getMethods(), $classStatementsGatherer->getMethodCalls(), $classReflection), $classScope); + $nodeCallback(new ClassConstantsNode($stmt, $classStatementsGatherer->getConstants(), $classStatementsGatherer->getConstantFetches(), $classReflection), $classScope); + $classReflection->evictPrivateSymbols(); + $this->calledMethodResults = []; + } elseif ($stmt instanceof Node\Stmt\Property) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + + $nativePropertyType = $stmt->type !== null ? ParserNodeTypeToPHPStanType::resolve($stmt->type, $scope->getClassReflection()) : null; + + [,,,,,,,,,,,,$isReadOnly, $docComment, ,,,$varTags, $isAllowedPrivateMutation] = $this->getPhpDocs($scope, $stmt); + $phpDocType = null; + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } + + foreach ($stmt->props as $prop) { + $nodeCallback($prop, $scope); + if ($prop->default !== null) { + $this->processExprNode($stmt, $prop->default, $scope, $nodeCallback, ExpressionContext::createDeep()); + } + + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + $propertyName = $prop->name->toString(); + + if ($phpDocType === null) { + if (isset($varTags[$propertyName])) { + $phpDocType = $varTags[$propertyName]->getType(); + } + } + + $propStmt = clone $stmt; + $propStmt->setAttributes($prop->getAttributes()); + $nodeCallback( + new ClassPropertyNode( + $propertyName, + $stmt->flags, + $nativePropertyType, + $prop->default, + $docComment, + $phpDocType, + false, + false, + $propStmt, + $isReadOnly, + $scope->isInTrait(), + $scope->getClassReflection()->isReadOnly(), + $isAllowedPrivateMutation, + $scope->getClassReflection(), + ), + $scope, + ); + } + + if (count($stmt->hooks) > 0) { + if (!isset($propertyName)) { + throw new ShouldNotHappenException('Property name should be known when analysing hooks.'); + } + $this->processPropertyHooks( + $stmt, + $stmt->type, + $phpDocType, + $propertyName, + $stmt->hooks, + $scope, + $nodeCallback, + ); + } + + if ($stmt->type !== null) { + $nodeCallback($stmt->type, $scope); + } + } elseif ($stmt instanceof If_) { + $conditionType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $ifAlwaysTrue = $conditionType->isTrue()->yes(); + $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $nodeCallback, ExpressionContext::createDeep()); + $exitPoints = []; + $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + $endStatements = []; + $finalScope = null; + $alwaysTerminating = true; + $hasYield = $condResult->hasYield(); + + $branchScopeStatementResult = $this->processStmtNodes($stmt, $stmt->stmts, $condResult->getTruthyScope(), $nodeCallback, $context); + + if (!$conditionType instanceof ConstantBooleanType || $conditionType->getValue()) { + $exitPoints = $branchScopeStatementResult->getExitPoints(); + $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); + $branchScope = $branchScopeStatementResult->getScope(); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? null : $branchScope; + $alwaysTerminating = $branchScopeStatementResult->isAlwaysTerminating(); + if (count($branchScopeStatementResult->getEndStatements()) > 0) { + $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); + } elseif (count($stmt->stmts) > 0) { + $endStatements[] = new EndStatementResult($stmt->stmts[count($stmt->stmts) - 1], $branchScopeStatementResult); + } else { + $endStatements[] = new EndStatementResult($stmt, $branchScopeStatementResult); + } + $hasYield = $branchScopeStatementResult->hasYield() || $hasYield; + } + + $scope = $condResult->getFalseyScope(); + $lastElseIfConditionIsTrue = false; + + $condScope = $scope; + foreach ($stmt->elseifs as $elseif) { + $nodeCallback($elseif, $scope); + $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condScope->getType($elseif->cond) : $scope->getNativeType($elseif->cond))->toBoolean(); + $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $nodeCallback, ExpressionContext::createDeep()); + $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); + $condScope = $condResult->getScope(); + $branchScopeStatementResult = $this->processStmtNodes($elseif, $elseif->stmts, $condResult->getTruthyScope(), $nodeCallback, $context); + + if ( + !$ifAlwaysTrue + && ( + !$lastElseIfConditionIsTrue + && ( + !$elseIfConditionType instanceof ConstantBooleanType + || $elseIfConditionType->getValue() + ) + ) + ) { + $exitPoints = array_merge($exitPoints, $branchScopeStatementResult->getExitPoints()); + $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); + $branchScope = $branchScopeStatementResult->getScope(); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); + $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); + if (count($branchScopeStatementResult->getEndStatements()) > 0) { + $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); + } elseif (count($elseif->stmts) > 0) { + $endStatements[] = new EndStatementResult($elseif->stmts[count($elseif->stmts) - 1], $branchScopeStatementResult); + } else { + $endStatements[] = new EndStatementResult($elseif, $branchScopeStatementResult); + } + $hasYield = $hasYield || $branchScopeStatementResult->hasYield(); + } + + if ( + $elseIfConditionType->isTrue()->yes() + ) { + $lastElseIfConditionIsTrue = true; + } + + $condScope = $condScope->filterByFalseyValue($elseif->cond); + $scope = $condScope; + } + + if ($stmt->else === null) { + if (!$ifAlwaysTrue && !$lastElseIfConditionIsTrue) { + $finalScope = $scope->mergeWith($finalScope); + $alwaysTerminating = false; + } + } else { + $nodeCallback($stmt->else, $scope); + $branchScopeStatementResult = $this->processStmtNodes($stmt->else, $stmt->else->stmts, $scope, $nodeCallback, $context); + + if (!$ifAlwaysTrue && !$lastElseIfConditionIsTrue) { + $exitPoints = array_merge($exitPoints, $branchScopeStatementResult->getExitPoints()); + $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); + $branchScope = $branchScopeStatementResult->getScope(); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); + $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); + if (count($branchScopeStatementResult->getEndStatements()) > 0) { + $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); + } elseif (count($stmt->else->stmts) > 0) { + $endStatements[] = new EndStatementResult($stmt->else->stmts[count($stmt->else->stmts) - 1], $branchScopeStatementResult); + } else { + $endStatements[] = new EndStatementResult($stmt->else, $branchScopeStatementResult); + } + $hasYield = $hasYield || $branchScopeStatementResult->hasYield(); + } + } + + if ($finalScope === null) { + $finalScope = $scope; + } + + if ($stmt->else === null && !$ifAlwaysTrue && !$lastElseIfConditionIsTrue) { + $endStatements[] = new EndStatementResult($stmt, new StatementResult($finalScope, $hasYield, $alwaysTerminating, $exitPoints, $throwPoints, $impurePoints)); + } + + return new StatementResult($finalScope, $hasYield, $alwaysTerminating, $exitPoints, $throwPoints, $impurePoints, $endStatements); + } elseif ($stmt instanceof Node\Stmt\TraitUse) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $this->processTraitUse($stmt, $scope, $nodeCallback); + } elseif ($stmt instanceof Foreach_) { + $condResult = $this->processExprNode($stmt, $stmt->expr, $scope, $nodeCallback, ExpressionContext::createDeep()); + $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + $scope = $condResult->getScope(); + $arrayComparisonExpr = new BinaryOp\NotIdentical( + $stmt->expr, + new Array_([]), + ); + if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { + $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); + } + $nodeCallback(new InForeachNode($stmt), $scope); + $originalScope = $scope; + $bodyScope = $scope; + + if ($context->isTopLevel()) { + $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; + $bodyScope = $this->enterForeach($originalScope, $originalScope, $stmt); + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $this->enterForeach($bodyScope, $originalScope, $stmt); + $bodyScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, static function (): void { + }, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } + + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $this->enterForeach($bodyScope, $originalScope, $stmt); + $finalScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints(); + $finalScope = $finalScopeResult->getScope(); + foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $finalScope = $continueExitPoint->getScope()->mergeWith($finalScope); + } + foreach ($finalScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + + $exprType = $scope->getType($stmt->expr); + $isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce(); + if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) { + $finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr( + new BinaryOp\Identical( + $stmt->expr, + new Array_([]), + ), + new FuncCall(new Name\FullyQualified('is_object'), [ + new Arg($stmt->expr), + ]), + ))); + } elseif ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) { + $finalScope = $scope; + } elseif (!$this->polluteScopeWithAlwaysIterableForeach) { + $finalScope = $scope->processAlwaysIterableForeachScopeWithoutPollute($finalScope); + // get types from finalScope, but don't create new variables + } + + if (!$isIterableAtLeastOnce->no()) { + $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); + } + if (!(new ObjectType(Traversable::class))->isSuperTypeOf($scope->getType($stmt->expr))->no()) { + $throwPoints[] = ThrowPoint::createImplicit($scope, $stmt->expr); + } + + return new StatementResult( + $finalScope, + $finalScopeResult->hasYield() || $condResult->hasYield(), + $isIterableAtLeastOnce->yes() && $finalScopeResult->isAlwaysTerminating(), + $finalScopeResult->getExitPointsForOuterLoop(), + $throwPoints, + $impurePoints, + ); + } elseif ($stmt instanceof While_) { + $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, static function (): void { + }, ExpressionContext::createDeep()); + $bodyScope = $condResult->getTruthyScope(); + + if ($context->isTopLevel()) { + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, static function (): void { + }, ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, static function (): void { + }, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } + + $bodyScope = $bodyScope->mergeWith($scope); + $bodyScopeMaybeRan = $bodyScope; + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $finalScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints(); + $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $alwaysIterates = $condBooleanType->isTrue()->yes() && $context->isTopLevel(); + $neverIterates = $condBooleanType->isFalse()->yes() && $context->isTopLevel(); + if (!$alwaysIterates) { + foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $finalScope = $finalScope->mergeWith($continueExitPoint->getScope()); + } + } + + $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); + foreach ($breakExitPoints as $breakExitPoint) { + $finalScope = $finalScope->mergeWith($breakExitPoint->getScope()); + } + + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $isIterableAtLeastOnce = $beforeCondBooleanType->isTrue()->yes(); + $nodeCallback(new BreaklessWhileLoopNode($stmt, $finalScopeResult->getExitPoints()), $bodyScopeMaybeRan); + + if ($alwaysIterates) { + $isAlwaysTerminating = count($finalScopeResult->getExitPointsByType(Break_::class)) === 0; + } elseif ($isIterableAtLeastOnce) { + $isAlwaysTerminating = $finalScopeResult->isAlwaysTerminating(); + } else { + $isAlwaysTerminating = false; + } + $condScope = $condResult->getFalseyScope(); + if (!$isIterableAtLeastOnce) { + if (!$this->polluteScopeWithLoopInitialAssignments) { + $condScope = $condScope->mergeWith($scope); + } + $finalScope = $finalScope->mergeWith($condScope); + } + + $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + if (!$neverIterates) { + $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); + } + + return new StatementResult( + $finalScope, + $finalScopeResult->hasYield() || $condResult->hasYield(), + $isAlwaysTerminating, + $finalScopeResult->getExitPointsForOuterLoop(), + $throwPoints, + $impurePoints, + ); + } elseif ($stmt instanceof Do_) { + $finalScope = null; + $bodyScope = $scope; + $count = 0; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + + if ($context->isTopLevel()) { + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $bodyScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, static function (): void { + }, $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, static function (): void { + }, ExpressionContext::createDeep())->getTruthyScope(); + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $bodyScope = $bodyScope->mergeWith($scope); + } + + $bodyScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $alwaysIterates = $condBooleanType->isTrue()->yes() && $context->isTopLevel(); + + $nodeCallback(new DoWhileLoopConditionNode($stmt->cond, $bodyScopeResult->getExitPoints()), $bodyScope); + + if ($alwaysIterates) { + $alwaysTerminating = count($bodyScopeResult->getExitPointsByType(Break_::class)) === 0; + } else { + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + if ($finalScope === null) { + $finalScope = $scope; + } + if (!$alwaysTerminating) { + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $nodeCallback, ExpressionContext::createDeep()); + $hasYield = $condResult->hasYield(); + $throwPoints = $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + $finalScope = $condResult->getFalseyScope(); + } else { + $this->processExprNode($stmt, $stmt->cond, $bodyScope, $nodeCallback, ExpressionContext::createDeep()); + } + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + + return new StatementResult( + $finalScope, + $bodyScopeResult->hasYield() || $hasYield, + $alwaysTerminating, + $bodyScopeResult->getExitPointsForOuterLoop(), + array_merge($throwPoints, $bodyScopeResult->getThrowPoints()), + array_merge($impurePoints, $bodyScopeResult->getImpurePoints()), + ); + } elseif ($stmt instanceof For_) { + $initScope = $scope; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($stmt->init as $initExpr) { + $initResult = $this->processExprNode($stmt, $initExpr, $initScope, $nodeCallback, ExpressionContext::createTopLevel()); + $initScope = $initResult->getScope(); + $hasYield = $hasYield || $initResult->hasYield(); + $throwPoints = array_merge($throwPoints, $initResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $initResult->getImpurePoints()); + } + + $bodyScope = $initScope; + $isIterableAtLeastOnce = TrinaryLogic::createYes(); + $lastCondExpr = $stmt->cond[count($stmt->cond) - 1] ?? null; + foreach ($stmt->cond as $condExpr) { + $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, static function (): void { + }, ExpressionContext::createDeep()); + $initScope = $condResult->getScope(); + $condResultScope = $condResult->getScope(); + + if ($condExpr === $lastCondExpr) { + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); + } + + $hasYield = $hasYield || $condResult->hasYield(); + $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); + $bodyScope = $condResult->getTruthyScope(); + } + + if ($context->isTopLevel()) { + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($initScope); + if ($lastCondExpr !== null) { + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, static function (): void { + }, ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, static function (): void { + }, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + foreach ($stmt->loop as $loopExpr) { + $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, static function (): void { + }, ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } + + $bodyScope = $bodyScope->mergeWith($initScope); + + $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); + if ($lastCondExpr !== null) { + $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + } + + $finalScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints(); + $finalScope = $finalScopeResult->getScope(); + foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $finalScope = $continueExitPoint->getScope()->mergeWith($finalScope); + } + + $loopScope = $finalScope; + foreach ($stmt->loop as $loopExpr) { + $loopScope = $this->processExprNode($stmt, $loopExpr, $loopScope, $nodeCallback, ExpressionContext::createTopLevel())->getScope(); + } + $finalScope = $finalScope->generalizeWith($loopScope); + + if ($lastCondExpr !== null) { + $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + } + + foreach ($finalScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + + if ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) { + if ($this->polluteScopeWithLoopInitialAssignments) { + $finalScope = $initScope; + } else { + $finalScope = $scope; + } + + } elseif ($isIterableAtLeastOnce->maybe()) { + if ($this->polluteScopeWithLoopInitialAssignments) { + $finalScope = $finalScope->mergeWith($initScope); + } else { + $finalScope = $finalScope->mergeWith($scope); + } + } else { + if (!$this->polluteScopeWithLoopInitialAssignments) { + $finalScope = $finalScope->mergeWith($scope); + } + } + + if ($alwaysIterates->yes()) { + $isAlwaysTerminating = count($finalScopeResult->getExitPointsByType(Break_::class)) === 0; + } elseif ($isIterableAtLeastOnce->yes()) { + $isAlwaysTerminating = $finalScopeResult->isAlwaysTerminating(); + } else { + $isAlwaysTerminating = false; + } + + return new StatementResult( + $finalScope, + $finalScopeResult->hasYield() || $hasYield, + $isAlwaysTerminating, + $finalScopeResult->getExitPointsForOuterLoop(), + array_merge($throwPoints, $finalScopeResult->getThrowPoints()), + array_merge($impurePoints, $finalScopeResult->getImpurePoints()), + ); + } elseif ($stmt instanceof Switch_) { + $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $condResult->getScope(); + $scopeForBranches = $scope; + $finalScope = null; + $prevScope = null; + $hasDefaultCase = false; + $alwaysTerminating = true; + $hasYield = $condResult->hasYield(); + $exitPointsForOuterLoop = []; + $throwPoints = $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + foreach ($stmt->cases as $caseNode) { + if ($caseNode->cond !== null) { + $condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond); + $caseResult = $this->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $nodeCallback, ExpressionContext::createDeep()); + $scopeForBranches = $caseResult->getScope(); + $hasYield = $hasYield || $caseResult->hasYield(); + $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints()); + $branchScope = $caseResult->getTruthyScope()->filterByTruthyValue($condExpr); + } else { + $hasDefaultCase = true; + $branchScope = $scopeForBranches; + } + + $branchScope = $branchScope->mergeWith($prevScope); + $branchScopeResult = $this->processStmtNodes($caseNode, $caseNode->stmts, $branchScope, $nodeCallback, $context); + $branchScope = $branchScopeResult->getScope(); + $branchFinalScopeResult = $branchScopeResult->filterOutLoopExitPoints(); + $hasYield = $hasYield || $branchFinalScopeResult->hasYield(); + foreach ($branchScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $alwaysTerminating = false; + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + foreach ($branchScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $finalScope = $continueExitPoint->getScope()->mergeWith($finalScope); + } + $exitPointsForOuterLoop = array_merge($exitPointsForOuterLoop, $branchFinalScopeResult->getExitPointsForOuterLoop()); + $throwPoints = array_merge($throwPoints, $branchFinalScopeResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $branchFinalScopeResult->getImpurePoints()); + if ($branchScopeResult->isAlwaysTerminating()) { + $alwaysTerminating = $alwaysTerminating && $branchFinalScopeResult->isAlwaysTerminating(); + $prevScope = null; + if (isset($condExpr)) { + $scopeForBranches = $scopeForBranches->filterByFalseyValue($condExpr); + } + if (!$branchFinalScopeResult->isAlwaysTerminating()) { + $finalScope = $branchScope->mergeWith($finalScope); + } + } else { + $prevScope = $branchScope; + } + } + + $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + + if (!$hasDefaultCase && !$exhaustive) { + $alwaysTerminating = false; + } + + if ($prevScope !== null && isset($branchFinalScopeResult)) { + $finalScope = $prevScope->mergeWith($finalScope); + $alwaysTerminating = $alwaysTerminating && $branchFinalScopeResult->isAlwaysTerminating(); + } + + if ((!$hasDefaultCase && !$exhaustive) || $finalScope === null) { + $finalScope = $scope->mergeWith($finalScope); + } + + return new StatementResult($finalScope, $hasYield, $alwaysTerminating, $exitPointsForOuterLoop, $throwPoints, $impurePoints); + } elseif ($stmt instanceof TryCatch) { + $branchScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $scope, $nodeCallback, $context); + $branchScope = $branchScopeResult->getScope(); + $finalScope = $branchScopeResult->isAlwaysTerminating() ? null : $branchScope; + + $exitPoints = []; + $finallyExitPoints = []; + $alwaysTerminating = $branchScopeResult->isAlwaysTerminating(); + $hasYield = $branchScopeResult->hasYield(); + + if ($stmt->finally !== null) { + $finallyScope = $branchScope; + } else { + $finallyScope = null; + } + foreach ($branchScopeResult->getExitPoints() as $exitPoint) { + $finallyExitPoints[] = $exitPoint; + if ($exitPoint->getStatement() instanceof Node\Stmt\Expression && $exitPoint->getStatement()->expr instanceof Expr\Throw_) { + continue; + } + if ($finallyScope !== null) { + $finallyScope = $finallyScope->mergeWith($exitPoint->getScope()); + } + $exitPoints[] = $exitPoint; + } + + $throwPoints = $branchScopeResult->getThrowPoints(); + $impurePoints = $branchScopeResult->getImpurePoints(); + $throwPointsForLater = []; + $pastCatchTypes = new NeverType(); + + foreach ($stmt->catches as $catchNode) { + $nodeCallback($catchNode, $scope); + + $originalCatchTypes = array_map(static fn (Name $name): Type => new ObjectType($name->toString()), $catchNode->types); + $catchTypes = array_map(static fn (Type $type): Type => TypeCombinator::remove($type, $pastCatchTypes), $originalCatchTypes); + + $originalCatchType = TypeCombinator::union(...$originalCatchTypes); + $catchType = TypeCombinator::union(...$catchTypes); + $pastCatchTypes = TypeCombinator::union($pastCatchTypes, $originalCatchType); + + $matchingThrowPoints = []; + $matchingCatchTypes = array_fill_keys(array_keys($originalCatchTypes), false); + + // throwable matches all + foreach ($originalCatchTypes as $catchTypeIndex => $catchTypeItem) { + if (!$catchTypeItem->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { + continue; + } + + foreach ($throwPoints as $throwPointIndex => $throwPoint) { + $matchingThrowPoints[$throwPointIndex] = $throwPoint; + $matchingCatchTypes[$catchTypeIndex] = true; + } + } + + // explicit only + $onlyExplicitIsThrow = true; + if (count($matchingThrowPoints) === 0) { + foreach ($throwPoints as $throwPointIndex => $throwPoint) { + foreach ($catchTypes as $catchTypeIndex => $catchTypeItem) { + if ($catchTypeItem->isSuperTypeOf($throwPoint->getType())->no()) { + continue; + } + + $matchingCatchTypes[$catchTypeIndex] = true; + if (!$throwPoint->isExplicit()) { + continue; + } + $throwNode = $throwPoint->getNode(); + if ( + !$throwNode instanceof Expr\Throw_ + && !($throwNode instanceof Node\Stmt\Expression && $throwNode->expr instanceof Expr\Throw_) + ) { + $onlyExplicitIsThrow = false; + } + $matchingThrowPoints[$throwPointIndex] = $throwPoint; + } + } + } + + // implicit only + if (count($matchingThrowPoints) === 0 || $onlyExplicitIsThrow) { + foreach ($throwPoints as $throwPointIndex => $throwPoint) { + if ($throwPoint->isExplicit()) { + continue; + } + + foreach ($catchTypes as $catchTypeIndex => $catchTypeItem) { + if ($catchTypeItem->isSuperTypeOf($throwPoint->getType())->no()) { + continue; + } + + $matchingThrowPoints[$throwPointIndex] = $throwPoint; + } + } + } + + // include previously removed throw points + if (count($matchingThrowPoints) === 0) { + if ($originalCatchType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { + foreach ($branchScopeResult->getThrowPoints() as $originalThrowPoint) { + if (!$originalThrowPoint->canContainAnyThrowable()) { + continue; + } + + $matchingThrowPoints[] = $originalThrowPoint; + $matchingCatchTypes = array_fill_keys(array_keys($originalCatchTypes), true); + } + } + } + + // emit error + foreach ($matchingCatchTypes as $catchTypeIndex => $matched) { + if ($matched) { + continue; + } + $nodeCallback(new CatchWithUnthrownExceptionNode($catchNode, $catchTypes[$catchTypeIndex], $originalCatchTypes[$catchTypeIndex]), $scope); + } + + if (count($matchingThrowPoints) === 0) { + continue; + } + + // recompute throw points + $newThrowPoints = []; + foreach ($throwPoints as $throwPoint) { + $newThrowPoint = $throwPoint->subtractCatchType($originalCatchType); + + if ($newThrowPoint->getType() instanceof NeverType) { + continue; + } + + $newThrowPoints[] = $newThrowPoint; + } + $throwPoints = $newThrowPoints; + + $catchScope = null; + foreach ($matchingThrowPoints as $matchingThrowPoint) { + if ($catchScope === null) { + $catchScope = $matchingThrowPoint->getScope(); + } else { + $catchScope = $catchScope->mergeWith($matchingThrowPoint->getScope()); + } + } + + $variableName = null; + if ($catchNode->var !== null) { + if (!is_string($catchNode->var->name)) { + throw new ShouldNotHappenException(); + } + + $variableName = $catchNode->var->name; + } + + $catchScopeResult = $this->processStmtNodes($catchNode, $catchNode->stmts, $catchScope->enterCatchType($catchType, $variableName), $nodeCallback, $context); + $catchScopeForFinally = $catchScopeResult->getScope(); + + $finalScope = $catchScopeResult->isAlwaysTerminating() ? $finalScope : $catchScopeResult->getScope()->mergeWith($finalScope); + $alwaysTerminating = $alwaysTerminating && $catchScopeResult->isAlwaysTerminating(); + $hasYield = $hasYield || $catchScopeResult->hasYield(); + $catchThrowPoints = $catchScopeResult->getThrowPoints(); + $impurePoints = array_merge($impurePoints, $catchScopeResult->getImpurePoints()); + $throwPointsForLater = array_merge($throwPointsForLater, $catchThrowPoints); + + if ($finallyScope !== null) { + $finallyScope = $finallyScope->mergeWith($catchScopeForFinally); + } + foreach ($catchScopeResult->getExitPoints() as $exitPoint) { + $finallyExitPoints[] = $exitPoint; + if ($exitPoint->getStatement() instanceof Node\Stmt\Expression && $exitPoint->getStatement()->expr instanceof Expr\Throw_) { + continue; + } + if ($finallyScope !== null) { + $finallyScope = $finallyScope->mergeWith($exitPoint->getScope()); + } + $exitPoints[] = $exitPoint; + } + + foreach ($catchThrowPoints as $catchThrowPoint) { + if ($finallyScope === null) { + continue; + } + $finallyScope = $finallyScope->mergeWith($catchThrowPoint->getScope()); + } + } + + if ($finalScope === null) { + $finalScope = $scope; + } + + foreach ($throwPoints as $throwPoint) { + if ($finallyScope === null) { + continue; + } + $finallyScope = $finallyScope->mergeWith($throwPoint->getScope()); + } + + if ($finallyScope !== null) { + $originalFinallyScope = $finallyScope; + $finallyResult = $this->processStmtNodes($stmt->finally, $stmt->finally->stmts, $finallyScope, $nodeCallback, $context); + $alwaysTerminating = $alwaysTerminating || $finallyResult->isAlwaysTerminating(); + $hasYield = $hasYield || $finallyResult->hasYield(); + $throwPointsForLater = array_merge($throwPointsForLater, $finallyResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $finallyResult->getImpurePoints()); + $finallyScope = $finallyResult->getScope(); + $finalScope = $finallyResult->isAlwaysTerminating() ? $finalScope : $finalScope->processFinallyScope($finallyScope, $originalFinallyScope); + if (count($finallyResult->getExitPoints()) > 0) { + $nodeCallback(new FinallyExitPointsNode( + $finallyResult->getExitPoints(), + $finallyExitPoints, + ), $scope); + } + $exitPoints = array_merge($exitPoints, $finallyResult->getExitPoints()); + } + + return new StatementResult($finalScope, $hasYield, $alwaysTerminating, $exitPoints, array_merge($throwPoints, $throwPointsForLater), $impurePoints); + } elseif ($stmt instanceof Unset_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($stmt->vars as $var) { + $scope = $this->lookForSetAllowedUndefinedExpressions($scope, $var); + $exprResult = $this->processExprNode($stmt, $var, $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $exprResult->getScope(); + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $var); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + if ($var instanceof ArrayDimFetch && $var->dim !== null) { + $cloningTraverser = new NodeTraverser(); + $cloningTraverser->addVisitor(new CloningVisitor()); + + /** @var Expr $clonedVar */ + [$clonedVar] = $cloningTraverser->traverse([$var->var]); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class () extends NodeVisitorAbstract { + + public function leaveNode(Node $node): ?ExistingArrayDimFetch + { + if (!$node instanceof ArrayDimFetch || $node->dim === null) { + return null; + } + + return new ExistingArrayDimFetch($node->var, $node->dim); + } + + }); + + /** @var Expr $clonedVar */ + [$clonedVar] = $traverser->traverse([$clonedVar]); + $scope = $this->processAssignVar( + $scope, + $stmt, + $clonedVar, + new UnsetOffsetExpr($var->var, $var->dim), + static function (Node $node, Scope $scope) use ($nodeCallback): void { + if (!$node instanceof PropertyAssignNode && !$node instanceof VariableAssignNode) { + return; + } + + $nodeCallback($node, $scope); + }, + ExpressionContext::createDeep(), + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + false, + )->getScope(); + } elseif ($var instanceof PropertyFetch) { + $scope = $scope->invalidateExpression($var); + $impurePoints[] = new ImpurePoint( + $scope, + $var, + 'propertyUnset', + 'property unset', + true, + ); + } else { + $scope = $scope->invalidateExpression($var); + } + + } + } elseif ($stmt instanceof Node\Stmt\Use_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($stmt->uses as $use) { + $nodeCallback($use, $scope); + } + } elseif ($stmt instanceof Node\Stmt\Global_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = [ + new ImpurePoint( + $scope, + $stmt, + 'global', + 'global variable', + true, + ), + ]; + $vars = []; + foreach ($stmt->vars as $var) { + if (!$var instanceof Variable) { + throw new ShouldNotHappenException(); + } + $scope = $this->lookForSetAllowedUndefinedExpressions($scope, $var); + $varResult = $this->processExprNode($stmt, $var, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $var); + + if (!is_string($var->name)) { + continue; + } + + $scope = $scope->assignVariable($var->name, new MixedType(), new MixedType(), TrinaryLogic::createYes()); + $vars[] = $var->name; + } + $scope = $this->processVarAnnotation($scope, $vars, $stmt); + } elseif ($stmt instanceof Static_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = [ + new ImpurePoint( + $scope, + $stmt, + 'static', + 'static variable', + true, + ), + ]; + + $vars = []; + foreach ($stmt->vars as $var) { + if (!is_string($var->var->name)) { + throw new ShouldNotHappenException(); + } + + if ($var->default !== null) { + $defaultExprResult = $this->processExprNode($stmt, $var->default, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = array_merge($impurePoints, $defaultExprResult->getImpurePoints()); + } + + $scope = $scope->enterExpressionAssign($var->var); + $varResult = $this->processExprNode($stmt, $var->var, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); + $scope = $scope->exitExpressionAssign($var->var); + + $scope = $scope->assignVariable($var->var->name, new MixedType(), new MixedType(), TrinaryLogic::createYes()); + $vars[] = $var->var->name; + } + + $scope = $this->processVarAnnotation($scope, $vars, $stmt); + } elseif ($stmt instanceof Node\Stmt\Const_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($stmt->consts as $const) { + $nodeCallback($const, $scope); + $constResult = $this->processExprNode($stmt, $const->value, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = array_merge($impurePoints, $constResult->getImpurePoints()); + if ($const->namespacedName !== null) { + $constantName = new Name\FullyQualified($const->namespacedName->toString()); + } else { + $constantName = new Name\FullyQualified($const->name->toString()); + } + $scope = $scope->assignExpression(new ConstFetch($constantName), $scope->getType($const->value), $scope->getNativeType($const->value)); + } + } elseif ($stmt instanceof Node\Stmt\ClassConst) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + foreach ($stmt->consts as $const) { + $nodeCallback($const, $scope); + $constResult = $this->processExprNode($stmt, $const->value, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = array_merge($impurePoints, $constResult->getImpurePoints()); + if ($scope->getClassReflection() === null) { + throw new ShouldNotHappenException(); + } + $scope = $scope->assignExpression( + new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), + $scope->getType($const->value), + $scope->getNativeType($const->value), + ); + } + } elseif ($stmt instanceof Node\Stmt\EnumCase) { + $hasYield = false; + $throwPoints = []; + $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + $impurePoints = []; + if ($stmt->expr !== null) { + $exprResult = $this->processExprNode($stmt, $stmt->expr, $scope, $nodeCallback, ExpressionContext::createDeep()); + $impurePoints = $exprResult->getImpurePoints(); + } + } elseif ($stmt instanceof InlineHTML) { + $hasYield = false; + $throwPoints = []; + $impurePoints = [ + new ImpurePoint($scope, $stmt, 'betweenPhpTags', 'output between PHP opening and closing tags', true), + ]; + } elseif ($stmt instanceof Node\Stmt\Block) { + $result = $this->processStmtNodes($stmt, $stmt->stmts, $scope, $nodeCallback, $context); + if ($this->polluteScopeWithBlock) { + return $result; + } + + return new StatementResult( + $scope->mergeWith($result->getScope()), + $result->hasYield(), + $result->isAlwaysTerminating(), + $result->getExitPoints(), + $result->getThrowPoints(), + $result->getImpurePoints(), + $result->getEndStatements(), + ); + } elseif ($stmt instanceof Node\Stmt\Nop) { + $hasYield = false; + $throwPoints = $overridingThrowPoints ?? []; + $impurePoints = []; + } elseif ($stmt instanceof Node\Stmt\GroupUse) { + $hasYield = false; + $throwPoints = []; + foreach ($stmt->uses as $use) { + $nodeCallback($use, $scope); + } + $impurePoints = []; + } else { + $hasYield = false; + $throwPoints = $overridingThrowPoints ?? []; + $impurePoints = []; + } + + return new StatementResult($scope, $hasYield, false, [], $throwPoints, $impurePoints); + } + + /** + * @return array{bool, string|null} + */ + private function getDeprecatedAttribute(Scope $scope, Node\Stmt\Function_|Node\Stmt\ClassMethod|Node\PropertyHook $stmt): array + { + $initializerExprContext = InitializerExprContext::fromStubParameter( + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->getFile(), + $stmt, + ); + $isDeprecated = false; + $deprecatedDescription = null; + $deprecatedDescriptionType = null; + foreach ($stmt->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toString() !== 'Deprecated') { + continue; + } + $isDeprecated = true; + $arguments = $attr->args; + foreach ($arguments as $i => $arg) { + $argName = $arg->name; + if ($argName === null) { + if ($i !== 0) { + continue; + } + + $deprecatedDescriptionType = $this->initializerExprTypeResolver->getType($arg->value, $initializerExprContext); + break; + } + + if ($argName->toString() !== 'message') { + continue; + } + + $deprecatedDescriptionType = $this->initializerExprTypeResolver->getType($arg->value, $initializerExprContext); + break; + } + } + } + + if ($deprecatedDescriptionType !== null) { + $constantStrings = $deprecatedDescriptionType->getConstantStrings(); + if (count($constantStrings) === 1) { + $deprecatedDescription = $constantStrings[0]->getValue(); + } + } + + return [$isDeprecated, $deprecatedDescription]; + } + + /** + * @return ThrowPoint[]|null + */ + private function getOverridingThrowPoints(Node\Stmt $statement, MutatingScope $scope): ?array + { + foreach ($statement->getComments() as $comment) { + if (!$comment instanceof Doc) { + continue; + } + + $function = $scope->getFunction(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $comment->getText(), + ); + + $throwsTag = $resolvedPhpDoc->getThrowsTag(); + if ($throwsTag !== null) { + $throwsType = $throwsTag->getType(); + if ($throwsType->isVoid()->yes()) { + return []; + } + + return [ThrowPoint::createExplicit($scope, $throwsType, $statement, false)]; + } + } + + return null; + } + + private function getCurrentClassReflection(Node\Stmt\ClassLike $stmt, string $className, Scope $scope): ClassReflection + { + if (!$this->reflectionProvider->hasClass($className)) { + return $this->createAstClassReflection($stmt, $className, $scope); + } + + $defaultClassReflection = $this->reflectionProvider->getClass($className); + if ($defaultClassReflection->getFileName() !== $scope->getFile()) { + return $this->createAstClassReflection($stmt, $className, $scope); + } + + $startLine = $defaultClassReflection->getNativeReflection()->getStartLine(); + if ($startLine !== $stmt->getStartLine()) { + return $this->createAstClassReflection($stmt, $className, $scope); + } + + return $defaultClassReflection; + } + + private function createAstClassReflection(Node\Stmt\ClassLike $stmt, string $className, Scope $scope): ClassReflection + { + $nodeToReflection = new NodeToReflection(); + $betterReflectionClass = $nodeToReflection->__invoke( + $this->reflector, + $stmt, + new LocatedSource(FileReader::read($scope->getFile()), $className, $scope->getFile()), + $scope->getNamespace() !== null ? new Node\Stmt\Namespace_(new Name($scope->getNamespace())) : null, + ); + if (!$betterReflectionClass instanceof \PHPStan\BetterReflection\Reflection\ReflectionClass) { + throw new ShouldNotHappenException(); + } + + $enumAdapter = base64_decode('UEhQU3RhblxCZXR0ZXJSZWZsZWN0aW9uXFJlZmxlY3Rpb25cQWRhcHRlclxSZWZsZWN0aW9uRW51bQ==', true); + + return new ClassReflection( + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->fileTypeMapper, + $this->stubPhpDocProvider, + $this->phpDocInheritanceResolver, + $this->phpVersion, + $this->signatureMapProvider, + $this->attributeReflectionFactory, + $this->classReflectionExtensionRegistryProvider->getRegistry()->getPropertiesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getMethodsClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getAllowedSubTypesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsPropertyClassReflectionExtension(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsMethodsClassReflectionExtension(), + $betterReflectionClass->getName(), + $betterReflectionClass instanceof ReflectionEnum && PHP_VERSION_ID >= 80000 ? new $enumAdapter($betterReflectionClass) : new ReflectionClass($betterReflectionClass), + null, + null, + null, + $this->universalObjectCratesClasses, + sprintf('%s:%d', $scope->getFile(), $stmt->getStartLine()), + ); + } + + private function lookForSetAllowedUndefinedExpressions(MutatingScope $scope, Expr $expr): MutatingScope + { + return $this->lookForExpressionCallback($scope, $expr, static fn (MutatingScope $scope, Expr $expr): MutatingScope => $scope->setAllowedUndefinedExpression($expr)); + } + + private function lookForUnsetAllowedUndefinedExpressions(MutatingScope $scope, Expr $expr): MutatingScope + { + return $this->lookForExpressionCallback($scope, $expr, static fn (MutatingScope $scope, Expr $expr): MutatingScope => $scope->unsetAllowedUndefinedExpression($expr)); + } + + /** + * @param Closure(MutatingScope $scope, Expr $expr): MutatingScope $callback + */ + private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Closure $callback): MutatingScope + { + if (!$expr instanceof ArrayDimFetch || $expr->dim !== null) { + $scope = $callback($scope, $expr); + } + + if ($expr instanceof ArrayDimFetch) { + $scope = $this->lookForExpressionCallback($scope, $expr->var, $callback); + } elseif ($expr instanceof PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + $scope = $this->lookForExpressionCallback($scope, $expr->var, $callback); + } elseif ($expr instanceof StaticPropertyFetch && $expr->class instanceof Expr) { + $scope = $this->lookForExpressionCallback($scope, $expr->class, $callback); + } elseif ($expr instanceof List_) { + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + $scope = $this->lookForExpressionCallback($scope, $item->value, $callback); + } + } + + return $scope; + } + + private function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + { + $exprType = $scope->getType($exprToSpecify); + $isNull = $exprType->isNull(); + if ($isNull->yes()) { + return new EnsuredNonNullabilityResult($scope, []); + } + + // keep certainty + $certainty = TrinaryLogic::createYes(); + $hasExpressionType = $originalScope->hasExpressionType($exprToSpecify); + if (!$hasExpressionType->no()) { + $certainty = $hasExpressionType; + } + + $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); + if ($exprType->equals($exprTypeWithoutNull)) { + $originalExprType = $originalScope->getType($exprToSpecify); + if (!$originalExprType->equals($exprTypeWithoutNull)) { + $originalNativeType = $originalScope->getNativeType($exprToSpecify); + + return new EnsuredNonNullabilityResult($scope, [ + new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $certainty), + ]); + } + return new EnsuredNonNullabilityResult($scope, []); + } + + $nativeType = $scope->getNativeType($exprToSpecify); + $scope = $scope->specifyExpressionType( + $exprToSpecify, + $exprTypeWithoutNull, + TypeCombinator::removeNull($nativeType), + TrinaryLogic::createYes(), + ); + + return new EnsuredNonNullabilityResult( + $scope, + [ + new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty), + ], + ); + } + + private function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + { + $specifiedExpressions = []; + $originalScope = $scope; + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { + $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { + $specifiedExpressions[] = $specifiedExpression; + } + return $result->getScope(); + }); + + return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + } + + /** + * @param EnsuredNonNullabilityResultExpression[] $specifiedExpressions + */ + private function revertNonNullability(MutatingScope $scope, array $specifiedExpressions): MutatingScope + { + foreach ($specifiedExpressions as $specifiedExpressionResult) { + $scope = $scope->specifyExpressionType( + $specifiedExpressionResult->getExpression(), + $specifiedExpressionResult->getOriginalType(), + $specifiedExpressionResult->getOriginalNativeType(), + $specifiedExpressionResult->getCertainty(), + ); + } + + return $scope; + } + + private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr + { + if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { + if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { + if ($expr instanceof MethodCall) { + $methodCalledOnType = $scope->getType($expr->var); + } else { + if ($expr->class instanceof Name) { + $methodCalledOnType = $scope->resolveTypeByName($expr->class); + } else { + $methodCalledOnType = $scope->getType($expr->class); + } + } + + foreach ($methodCalledOnType->getObjectClassNames() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($referencedClass); + foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { + if (!isset($this->earlyTerminatingMethodCalls[$className])) { + continue; + } + + if (in_array((string) $expr->name, $this->earlyTerminatingMethodCalls[$className], true)) { + return $expr; + } + } + } + } + } + + if ($expr instanceof FuncCall && $expr->name instanceof Name) { + if (in_array((string) $expr->name, $this->earlyTerminatingFunctionCalls, true)) { + return $expr; + } + } + + if ($expr instanceof Expr\Exit_ || $expr instanceof Expr\Throw_) { + return $expr; + } + + $exprType = $scope->getType($expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + return $expr; + } + + return null; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processExprNode(Node\Stmt $stmt, Expr $expr, MutatingScope $scope, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { + if ($expr instanceof FuncCall) { + $newExpr = new FunctionCallableNode($expr->name, $expr); + } elseif ($expr instanceof MethodCall) { + $newExpr = new MethodCallableNode($expr->var, $expr->name, $expr); + } elseif ($expr instanceof StaticCall) { + $newExpr = new StaticMethodCallableNode($expr->class, $expr->name, $expr); + } elseif ($expr instanceof New_ && !$expr->class instanceof Class_) { + $newExpr = new InstantiationCallableNode($expr->class, $expr); + } else { + throw new ShouldNotHappenException(); + } + + return $this->processExprNode($stmt, $newExpr, $scope, $nodeCallback, $context); + } + + $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $context); + + if ($expr instanceof Variable) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + if ($expr->name instanceof Expr) { + return $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + } elseif (in_array($expr->name, Scope::SUPERGLOBAL_VARIABLES, true)) { + $impurePoints[] = new ImpurePoint($scope, $expr, 'superglobal', 'access to superglobal variable', true); + } + } elseif ($expr instanceof Assign || $expr instanceof AssignRef) { + $result = $this->processAssignVar( + $scope, + $stmt, + $expr->var, + $expr->expr, + $nodeCallback, + $context, + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context): ExpressionResult { + $impurePoints = []; + if ($expr instanceof AssignRef) { + $referencedExpr = $expr->expr; + while ($referencedExpr instanceof ArrayDimFetch) { + $referencedExpr = $referencedExpr->var; + } + + if ($referencedExpr instanceof PropertyFetch || $referencedExpr instanceof StaticPropertyFetch) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'propertyAssignByRef', + 'property assignment by reference', + false, + ); + } + + $scope = $scope->enterExpressionAssign($expr->expr); + } + + if ($expr->var instanceof Variable && is_string($expr->var->name)) { + $context = $context->enterRightSideAssign( + $expr->var->name, + $scope->getType($expr->expr), + $scope->getNativeType($expr->expr), + ); + } + + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + + if ($expr instanceof AssignRef) { + $scope = $scope->exitExpressionAssign($expr->expr); + } + + return new ExpressionResult($scope, $hasYield, $throwPoints, $impurePoints); + }, + true, + ); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $vars = $this->getAssignedVariables($expr->var); + if (count($vars) > 0) { + $varChangedScope = false; + $scope = $this->processVarAnnotation($scope, $vars, $stmt, $varChangedScope); + if (!$varChangedScope) { + $scope = $this->processStmtVarAnnotation($scope, $stmt, null, $nodeCallback); + } + } + } elseif ($expr instanceof Expr\AssignOp) { + $result = $this->processAssignVar( + $scope, + $stmt, + $expr->var, + $expr, + $nodeCallback, + $context, + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context): ExpressionResult { + $originalScope = $scope; + if ($expr instanceof Expr\AssignOp\Coalesce) { + $scope = $scope->filterByFalseyValue( + new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), + ); + } + + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + if ($expr instanceof Expr\AssignOp\Coalesce) { + return new ExpressionResult( + $result->getScope()->mergeWith($originalScope), + $result->hasYield(), + $result->getThrowPoints(), + $result->getImpurePoints(), + ); + } + + return $result; + }, + $expr instanceof Expr\AssignOp\Coalesce, + ); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + if ( + ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && + !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + ) { + $throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); + } + } elseif ($expr instanceof FuncCall) { + $parametersAcceptor = null; + $functionReflection = null; + $throwPoints = []; + $impurePoints = []; + if ($expr->name instanceof Expr) { + $nameType = $scope->getType($expr->name); + if (!$nameType->isCallable()->no()) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $nameType->getCallableParametersAcceptors($scope), + null, + ); + } + + $nameResult = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $scope = $nameResult->getScope(); + $throwPoints = $nameResult->getThrowPoints(); + $impurePoints = $nameResult->getImpurePoints(); + if ( + $nameType->isObject()->yes() + && $nameType->isCallable()->yes() + && (new ObjectType(Closure::class))->isSuperTypeOf($nameType)->no() + ) { + $invokeResult = $this->processExprNode( + $stmt, + new MethodCall($expr->name, '__invoke', $expr->getArgs(), $expr->getAttributes()), + $scope, + static function (): void { + }, + $context->enterDeep(), + ); + $throwPoints = array_merge($throwPoints, $invokeResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $invokeResult->getImpurePoints()); + } elseif ($parametersAcceptor instanceof CallableParametersAcceptor) { + $callableThrowPoints = array_map(static fn (SimpleThrowPoint $throwPoint) => $throwPoint->isExplicit() ? ThrowPoint::createExplicit($scope, $throwPoint->getType(), $expr, $throwPoint->canContainAnyThrowable()) : ThrowPoint::createImplicit($scope, $expr), $parametersAcceptor->getThrowPoints()); + if (!$this->implicitThrows) { + $callableThrowPoints = array_values(array_filter($callableThrowPoints, static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit())); + } + $throwPoints = array_merge($throwPoints, $callableThrowPoints); + $impurePoints = array_merge($impurePoints, array_map(static fn (SimpleImpurePoint $impurePoint) => new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $parametersAcceptor->getImpurePoints())); + + $scope = $this->processImmediatelyCalledCallable($scope, $parametersAcceptor->getInvalidateExpressions(), $parametersAcceptor->getUsedVariables()); + } + } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor); + if ($impurePoint !== null) { + $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); + } + } else { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'functionCall', + 'call to unknown function', + false, + ); + } + + if ($parametersAcceptor !== null) { + $expr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr) ?? $expr; + } + $result = $this->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $expr, $scope, $nodeCallback, $context); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + + if ($functionReflection !== null) { + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $expr, $scope); + if ($functionThrowPoint !== null) { + $throwPoints[] = $functionThrowPoint; + } + } else { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['json_encode', 'json_decode'], true) + ) { + $scope = $scope->invalidateExpression(new FuncCall(new Name('json_last_error'), [])) + ->invalidateExpression(new FuncCall(new Name\FullyQualified('json_last_error'), [])) + ->invalidateExpression(new FuncCall(new Name('json_last_error_msg'), [])) + ->invalidateExpression(new FuncCall(new Name\FullyQualified('json_last_error_msg'), [])); + } + + if ( + $functionReflection !== null + && $functionReflection->getName() === 'file_put_contents' + && count($expr->getArgs()) > 0 + ) { + $scope = $scope->invalidateExpression(new FuncCall(new Name('file_get_contents'), [$expr->getArgs()[0]])) + ->invalidateExpression(new FuncCall(new Name\FullyQualified('file_get_contents'), [$expr->getArgs()[0]])); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['array_pop', 'array_shift'], true) + && count($expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->getArgs()[0]->value; + + $arrayArgType = $scope->getType($arrayArg); + $arrayArgNativeType = $scope->getNativeType($arrayArg); + + $isArrayPop = $functionReflection->getName() === 'array_pop'; + $scope = $scope->invalidateExpression($arrayArg)->assignExpression( + $arrayArg, + $isArrayPop ? $arrayArgType->popArray() : $arrayArgType->shiftArray(), + $isArrayPop ? $arrayArgNativeType->popArray() : $arrayArgNativeType->shiftArray(), + ); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['array_push', 'array_unshift'], true) + && count($expr->getArgs()) >= 2 + ) { + $arrayType = $this->getArrayFunctionAppendingType($functionReflection, $scope, $expr); + $arrayNativeType = $this->getArrayFunctionAppendingType($functionReflection, $scope->doNotTreatPhpDocTypesAsCertain(), $expr); + + $arrayArg = $expr->getArgs()[0]->value; + $scope = $scope->invalidateExpression($arrayArg)->assignExpression($arrayArg, $arrayType, $arrayNativeType); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['fopen', 'file_get_contents'], true) + ) { + $scope = $scope->assignVariable('http_response_header', TypeCombinator::intersect(new ArrayType(new IntegerType(), new StringType()), new AccessoryArrayListType()), new ArrayType(new IntegerType(), new StringType()), TrinaryLogic::createYes()); + } + + if ( + $functionReflection !== null + && $functionReflection->getName() === 'shuffle' + ) { + $arrayArg = $expr->getArgs()[0]->value; + $scope = $scope->assignExpression( + $arrayArg, + $scope->getType($arrayArg)->shuffleArray(), + $scope->getNativeType($arrayArg)->shuffleArray(), + ); + } + + if ( + $functionReflection !== null + && $functionReflection->getName() === 'array_splice' + && count($expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->getArgs()[0]->value; + $arrayArgType = $scope->getType($arrayArg); + $valueType = $arrayArgType->getIterableValueType(); + if (count($expr->getArgs()) >= 4) { + $replacementType = $scope->getType($expr->getArgs()[3]->value)->toArray(); + $valueType = TypeCombinator::union($valueType, $replacementType->getIterableValueType()); + } + $scope = $scope->invalidateExpression($arrayArg)->assignExpression( + $arrayArg, + new ArrayType($arrayArgType->getIterableKeyType(), $valueType), + new ArrayType($arrayArgType->getIterableKeyType(), $valueType), + ); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['sort', 'rsort', 'usort'], true) + && count($expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->getArgs()[0]->value; + $scope = $scope->assignExpression( + $arrayArg, + $this->getArraySortPreserveListFunctionType($scope->getType($arrayArg)), + $this->getArraySortPreserveListFunctionType($scope->getNativeType($arrayArg)), + ); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['natcasesort', 'natsort', 'arsort', 'asort', 'ksort', 'krsort', 'uasort', 'uksort'], true) + && count($expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->getArgs()[0]->value; + $scope = $scope->assignExpression( + $arrayArg, + $this->getArraySortDoNotPreserveListFunctionType($scope->getType($arrayArg)), + $this->getArraySortDoNotPreserveListFunctionType($scope->getNativeType($arrayArg)), + ); + } + + if ( + $functionReflection !== null + && $functionReflection->getName() === 'extract' + ) { + $extractedArg = $expr->getArgs()[0]->value; + $extractedType = $scope->getType($extractedArg); + $constantArrays = $extractedType->getConstantArrays(); + if (count($constantArrays) > 0) { + $properties = []; + $optionalProperties = []; + $refCount = []; + foreach ($constantArrays as $constantArray) { + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + if ($keyType->isString()->no()) { + // integers as variable names not allowed + continue; + } + $key = (string) $keyType->getValue(); + $valueType = $constantArray->getValueTypes()[$i]; + $optional = $constantArray->isOptionalKey($i); + if ($optional) { + $optionalProperties[] = $key; + } + if (isset($properties[$key])) { + $properties[$key] = TypeCombinator::union($properties[$key], $valueType); + $refCount[$key]++; + } else { + $properties[$key] = $valueType; + $refCount[$key] = 1; + } + } + } + foreach ($properties as $name => $type) { + $optional = in_array($name, $optionalProperties, true) || $refCount[$name] < count($constantArrays); + $scope = $scope->assignVariable($name, $type, $type, $optional ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes()); + } + } else { + $scope = $scope->afterExtractCall(); + } + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['clearstatcache', 'unlink'], true) + ) { + $scope = $scope->afterClearstatcacheCall(); + } + + if ( + $functionReflection !== null + && str_starts_with($functionReflection->getName(), 'openssl') + ) { + $scope = $scope->afterOpenSslCall($functionReflection->getName()); + } + + } elseif ($expr instanceof MethodCall) { + $originalScope = $scope; + if ( + ($expr->var instanceof Expr\Closure || $expr->var instanceof Expr\ArrowFunction) + && $expr->name instanceof Node\Identifier + && strtolower($expr->name->name) === 'call' + && isset($expr->getArgs()[0]) + ) { + $closureCallScope = $scope->enterClosureCall( + $scope->getType($expr->getArgs()[0]->value), + $scope->getNativeType($expr->getArgs()[0]->value), + ); + } + + $result = $this->processExprNode($stmt, $expr->var, $closureCallScope ?? $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + if (isset($closureCallScope)) { + $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); + } + $parametersAcceptor = null; + $methodReflection = null; + $calledOnType = $scope->getType($expr->var); + if ($expr->name instanceof Expr) { + $methodNameResult = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = array_merge($throwPoints, $methodNameResult->getThrowPoints()); + $scope = $methodNameResult->getScope(); + } else { + $methodName = $expr->name->name; + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + if ($methodReflection !== null) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + + $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); + if ($methodThrowPoint !== null) { + $throwPoints[] = $methodThrowPoint; + } + } + } + + if ($methodReflection !== null) { + $impurePoint = SimpleImpurePoint::createFromVariant($methodReflection, $parametersAcceptor); + if ($impurePoint !== null) { + $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); + } + } else { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'methodCall', + 'call to unknown method', + false, + ); + } + + if ($parametersAcceptor !== null) { + $expr = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $expr) ?? $expr; + } + + $result = $this->processArgs( + $stmt, + $methodReflection, + $methodReflection !== null ? $scope->getNakedMethod($calledOnType, $methodReflection->getName()) : null, + $parametersAcceptor, + $expr, + $scope, + $nodeCallback, + $context, + ); + $scope = $result->getScope(); + + if ($methodReflection !== null) { + $hasSideEffects = $methodReflection->hasSideEffects(); + if ($hasSideEffects->yes() || $methodReflection->getName() === '__construct') { + $nodeCallback(new InvalidateExprNode($expr->var), $scope); + $scope = $scope->invalidateExpression($expr->var, true); + } + if ($parametersAcceptor !== null && !$methodReflection->isStatic()) { + $selfOutType = $methodReflection->getSelfOutType(); + if ($selfOutType !== null) { + $scope = $scope->assignExpression( + $expr->var, + TemplateTypeHelper::resolveTemplateTypes( + $selfOutType, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createCovariant(), + ), + $scope->getNativeType($expr->var), + ); + } + } + + if ( + $scope->isInClass() + && $scope->getClassReflection()->getName() === $methodReflection->getDeclaringClass()->getName() + /*&& ( + // should not be allowed but in practice has to be + $scope->getClassReflection()->isFinal() + || $methodReflection->isFinal()->yes() + || $methodReflection->isPrivate() + )*/ + && TypeUtils::findThisType($calledOnType) !== null + ) { + $calledMethodScope = $this->processCalledMethod($methodReflection); + if ($calledMethodScope !== null) { + $scope = $scope->mergeInitializedProperties($calledMethodScope); + } + } + } else { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } elseif ($expr instanceof Expr\NullsafeMethodCall) { + $nonNullabilityResult = $this->ensureShallowNonNullability($scope, $scope, $expr->var); + $exprResult = $this->processExprNode($stmt, new MethodCall($expr->var, $expr->name, $expr->args, array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true])), $nonNullabilityResult->getScope(), $nodeCallback, $context); + $scope = $this->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + + return new ExpressionResult( + $scope, + $exprResult->hasYield(), + $exprResult->getThrowPoints(), + $exprResult->getImpurePoints(), + static fn (): MutatingScope => $scope->filterByTruthyValue($expr), + static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + ); + } elseif ($expr instanceof StaticCall) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + if ($expr->class instanceof Expr) { + $objectClasses = $scope->getType($expr->class)->getObjectClassNames(); + if (count($objectClasses) !== 1) { + $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); + } + if (count($objectClasses) === 1) { + $objectExprResult = $this->processExprNode($stmt, new StaticCall(new Name($objectClasses[0]), $expr->name, []), $scope, static function (): void { + }, $context->enterDeep()); + $additionalThrowPoints = $objectExprResult->getThrowPoints(); + } else { + $additionalThrowPoints = [ThrowPoint::createImplicit($scope, $expr)]; + } + $classResult = $this->processExprNode($stmt, $expr->class, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $classResult->hasYield(); + $throwPoints = array_merge($throwPoints, $classResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $classResult->getImpurePoints()); + foreach ($additionalThrowPoints as $throwPoint) { + $throwPoints[] = $throwPoint; + } + $scope = $classResult->getScope(); + } + + $parametersAcceptor = null; + $methodReflection = null; + if ($expr->name instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + } elseif ($expr->class instanceof Name) { + $classType = $scope->resolveTypeByName($expr->class); + $methodName = $expr->name->name; + if ($classType->hasMethod($methodName)->yes()) { + $methodReflection = $classType->getMethod($methodName, $scope); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + + $methodThrowPoint = $this->getStaticMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); + if ($methodThrowPoint !== null) { + $throwPoints[] = $methodThrowPoint; + } + + $declaringClass = $methodReflection->getDeclaringClass(); + if ( + $declaringClass->getName() === 'Closure' + && strtolower($methodName) === 'bind' + ) { + $thisType = null; + $nativeThisType = null; + if (isset($expr->getArgs()[1])) { + $argType = $scope->getType($expr->getArgs()[1]->value); + if ($argType->isNull()->yes()) { + $thisType = null; + } else { + $thisType = $argType; + } + + $nativeArgType = $scope->getNativeType($expr->getArgs()[1]->value); + if ($nativeArgType->isNull()->yes()) { + $nativeThisType = null; + } else { + $nativeThisType = $nativeArgType; + } + } + $scopeClasses = ['static']; + if (isset($expr->getArgs()[2])) { + $argValue = $expr->getArgs()[2]->value; + $argValueType = $scope->getType($argValue); + + $directClassNames = $argValueType->getObjectClassNames(); + if (count($directClassNames) > 0) { + $scopeClasses = $directClassNames; + $thisTypes = []; + foreach ($directClassNames as $directClassName) { + $thisTypes[] = new ObjectType($directClassName); + } + $thisType = TypeCombinator::union(...$thisTypes); + } else { + $thisType = $argValueType->getClassStringObjectType(); + $scopeClasses = $thisType->getObjectClassNames(); + } + } + $closureBindScope = $scope->enterClosureBind($thisType, $nativeThisType, $scopeClasses); + } + } else { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + } + + if ($methodReflection !== null) { + $impurePoint = SimpleImpurePoint::createFromVariant($methodReflection, $parametersAcceptor); + if ($impurePoint !== null) { + $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); + } + } else { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'methodCall', + 'call to unknown method', + false, + ); + } + + if ($parametersAcceptor !== null) { + $expr = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $expr) ?? $expr; + } + $result = $this->processArgs($stmt, $methodReflection, null, $parametersAcceptor, $expr, $scope, $nodeCallback, $context, $closureBindScope ?? null); + $scope = $result->getScope(); + $scopeFunction = $scope->getFunction(); + + if ( + $methodReflection !== null + && !$methodReflection->isStatic() + && ( + $methodReflection->hasSideEffects()->yes() + || $methodReflection->getName() === '__construct' + ) + && $scopeFunction instanceof MethodReflection + && !$scopeFunction->isStatic() + && $scope->isInClass() + && ( + $scope->getClassReflection()->getName() === $methodReflection->getDeclaringClass()->getName() + || $scope->getClassReflection()->isSubclassOf($methodReflection->getDeclaringClass()->getName()) + ) + ) { + $scope = $scope->invalidateExpression(new Variable('this'), true); + } + + if ( + $methodReflection !== null + && !$methodReflection->isStatic() + && $methodReflection->getName() === '__construct' + && $scopeFunction instanceof MethodReflection + && !$scopeFunction->isStatic() + && $scope->isInClass() + && $scope->getClassReflection()->isSubclassOf($methodReflection->getDeclaringClass()->getName()) + ) { + $thisType = $scope->getType(new Variable('this')); + $methodClassReflection = $methodReflection->getDeclaringClass(); + foreach ($methodClassReflection->getNativeReflection()->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED) as $property) { + if (!$property->isPromoted() || $property->getDeclaringClass()->getName() !== $methodClassReflection->getName()) { + continue; + } + + $scope = $scope->assignInitializedProperty($thisType, $property->getName()); + } + } + + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } elseif ($expr instanceof PropertyFetch) { + $scopeBeforeVar = $scope; + $result = $this->processExprNode($stmt, $expr->var, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + if ($expr->name instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + } else { + $propertyName = $expr->name->toString(); + $propertyHolderType = $scopeBeforeVar->getType($expr->var); + $propertyReflection = $scopeBeforeVar->getPropertyReflection($propertyHolderType, $propertyName); + if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { + $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); + if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { + $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); + $throwPoints = array_merge($throwPoints, $this->getPropertyReadThrowPointsFromGetHook($scopeBeforeVar, $expr, $nativeProperty)); + } + } + } + } elseif ($expr instanceof Expr\NullsafePropertyFetch) { + $nonNullabilityResult = $this->ensureShallowNonNullability($scope, $scope, $expr->var); + $exprResult = $this->processExprNode($stmt, new PropertyFetch($expr->var, $expr->name, array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true])), $nonNullabilityResult->getScope(), $nodeCallback, $context); + $scope = $this->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + + return new ExpressionResult( + $scope, + $exprResult->hasYield(), + $exprResult->getThrowPoints(), + $exprResult->getImpurePoints(), + static fn (): MutatingScope => $scope->filterByTruthyValue($expr), + static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + ); + } elseif ($expr instanceof StaticPropertyFetch) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + if ($expr->class instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->class, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + } + if ($expr->name instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + } + } elseif ($expr instanceof Expr\Closure) { + $processClosureResult = $this->processClosureNode($stmt, $expr, $scope, $nodeCallback, $context, null); + + return new ExpressionResult( + $processClosureResult->getScope(), + false, + [], + [], + ); + } elseif ($expr instanceof Expr\ArrowFunction) { + $result = $this->processArrowFunctionNode($stmt, $expr, $scope, $nodeCallback, null); + return new ExpressionResult( + $result->getScope(), + $result->hasYield(), + [], + [], + ); + } elseif ($expr instanceof ErrorSuppress) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + } elseif ($expr instanceof Exit_) { + $hasYield = false; + $throwPoints = []; + $kind = $expr->getAttribute('kind', Exit_::KIND_EXIT); + $identifier = $kind === Exit_::KIND_DIE ? 'die' : 'exit'; + $impurePoints = [ + new ImpurePoint($scope, $expr, $identifier, $identifier, true), + ]; + if ($expr->expr !== null) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + } + } elseif ($expr instanceof Node\Scalar\InterpolatedString) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($expr->parts as $part) { + if (!$part instanceof Expr) { + continue; + } + $result = $this->processExprNode($stmt, $part, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + } + } elseif ($expr instanceof ArrayDimFetch) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + if ($expr->dim !== null) { + $result = $this->processExprNode($stmt, $expr->dim, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + } + + $result = $this->processExprNode($stmt, $expr->var, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + } elseif ($expr instanceof Array_) { + $itemNodes = []; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($expr->items as $arrayItem) { + $itemNodes[] = new LiteralArrayItem($scope, $arrayItem); + $nodeCallback($arrayItem, $scope); + if ($arrayItem->key !== null) { + $keyResult = $this->processExprNode($stmt, $arrayItem->key, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $keyResult->hasYield(); + $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); + $scope = $keyResult->getScope(); + } + + $valueResult = $this->processExprNode($stmt, $arrayItem->value, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $valueResult->hasYield(); + $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); + $scope = $valueResult->getScope(); + } + $nodeCallback(new LiteralArrayNode($expr, $itemNodes), $scope); + } elseif ($expr instanceof BooleanAnd || $expr instanceof BinaryOp\LogicalAnd) { + $leftResult = $this->processExprNode($stmt, $expr->left, $scope, $nodeCallback, $context->enterDeep()); + $rightResult = $this->processExprNode($stmt, $expr->right, $leftResult->getTruthyScope(), $nodeCallback, $context); + $rightExprType = $rightResult->getScope()->getType($expr->right); + if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { + $leftMergedWithRightScope = $leftResult->getFalseyScope(); + } else { + $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); + } + + $this->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftResult->getTruthyScope()), $scope, $context); + + return new ExpressionResult( + $leftMergedWithRightScope, + $leftResult->hasYield() || $rightResult->hasYield(), + array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), + array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr), + static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), + ); + } elseif ($expr instanceof BooleanOr || $expr instanceof BinaryOp\LogicalOr) { + $leftResult = $this->processExprNode($stmt, $expr->left, $scope, $nodeCallback, $context->enterDeep()); + $rightResult = $this->processExprNode($stmt, $expr->right, $leftResult->getFalseyScope(), $nodeCallback, $context); + $rightExprType = $rightResult->getScope()->getType($expr->right); + if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { + $leftMergedWithRightScope = $leftResult->getTruthyScope(); + } else { + $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); + } + + $this->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftResult->getFalseyScope()), $scope, $context); + + return new ExpressionResult( + $leftMergedWithRightScope, + $leftResult->hasYield() || $rightResult->hasYield(), + array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), + array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), + static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr), + ); + } elseif ($expr instanceof Coalesce) { + $nonNullabilityResult = $this->ensureNonNullability($scope, $expr->left); + $condScope = $this->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); + $condResult = $this->processExprNode($stmt, $expr->left, $condScope, $nodeCallback, $context->enterDeep()); + $scope = $this->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); + + $rightScope = $scope->filterByFalseyValue($expr); + $rightResult = $this->processExprNode($stmt, $expr->right, $rightScope, $nodeCallback, $context->enterDeep()); + $rightExprType = $scope->getType($expr->right); + if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { + $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + } else { + $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + } + + $hasYield = $condResult->hasYield() || $rightResult->hasYield(); + $throwPoints = array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()); + $impurePoints = array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()); + } elseif ($expr instanceof BinaryOp) { + $result = $this->processExprNode($stmt, $expr->left, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $result = $this->processExprNode($stmt, $expr->right, $scope, $nodeCallback, $context->enterDeep()); + if ( + ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && + !$scope->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + ) { + $throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); + } + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } elseif ($expr instanceof Expr\Include_) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + $impurePoints = $result->getImpurePoints(); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + in_array($expr->type, [Expr\Include_::TYPE_INCLUDE, Expr\Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require', + in_array($expr->type, [Expr\Include_::TYPE_INCLUDE, Expr\Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require', + true, + ); + $hasYield = $result->hasYield(); + $scope = $result->getScope()->afterExtractCall(); + } elseif ($expr instanceof Expr\Print_) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $impurePoints[] = new ImpurePoint($scope, $expr, 'print', 'print', true); + $hasYield = $result->hasYield(); + + $scope = $result->getScope(); + } elseif ($expr instanceof Cast\String_) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $hasYield = $result->hasYield(); + + $exprType = $scope->getType($expr->expr); + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); + if ($toStringMethod !== null) { + if (!$toStringMethod->hasSideEffects()->no()) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'methodCall', + sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()), + $toStringMethod->isPure()->no(), + ); + } + } + + $scope = $result->getScope(); + } elseif ( + $expr instanceof Expr\BitwiseNot + || $expr instanceof Cast + || $expr instanceof Expr\Clone_ + || $expr instanceof Expr\UnaryMinus + || $expr instanceof Expr\UnaryPlus + ) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $hasYield = $result->hasYield(); + + $scope = $result->getScope(); + } elseif ($expr instanceof Expr\Eval_) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + $impurePoints = $result->getImpurePoints(); + $impurePoints[] = new ImpurePoint($scope, $expr, 'eval', 'eval', true); + $hasYield = $result->hasYield(); + + $scope = $result->getScope(); + } elseif ($expr instanceof Expr\YieldFrom) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $result->getThrowPoints(); + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + $impurePoints = $result->getImpurePoints(); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'yieldFrom', + 'yield from', + true, + ); + $hasYield = true; + + $scope = $result->getScope(); + } elseif ($expr instanceof BooleanNot) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + } elseif ($expr instanceof Expr\ClassConstFetch) { + if ($expr->class instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->class, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + } else { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $nodeCallback($expr->class, $scope); + } + + if ($expr->name instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->name, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } else { + $nodeCallback($expr->name, $scope); + } + } elseif ($expr instanceof Expr\Empty_) { + $nonNullabilityResult = $this->ensureNonNullability($scope, $expr->expr); + $scope = $this->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $this->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); + } elseif ($expr instanceof Expr\Isset_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $nonNullabilityResults = []; + foreach ($expr->vars as $var) { + $nonNullabilityResult = $this->ensureNonNullability($scope, $var); + $scope = $this->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + $result = $this->processExprNode($stmt, $var, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $nonNullabilityResults[] = $nonNullabilityResult; + } + foreach (array_reverse($expr->vars) as $var) { + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $var); + } + foreach (array_reverse($nonNullabilityResults) as $nonNullabilityResult) { + $scope = $this->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); + } + } elseif ($expr instanceof Instanceof_) { + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + if ($expr->class instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->class, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } + } elseif ($expr instanceof List_) { + // only in assign and foreach, processed elsewhere + return new ExpressionResult($scope, false, [], []); + } elseif ($expr instanceof New_) { + $parametersAcceptor = null; + $constructorReflection = null; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $className = null; + if ($expr->class instanceof Expr || $expr->class instanceof Name) { + if ($expr->class instanceof Expr) { + $objectClasses = $scope->getType($expr)->getObjectClassNames(); + if (count($objectClasses) === 1) { + $objectExprResult = $this->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, static function (): void { + }, $context->enterDeep()); + $className = $objectClasses[0]; + $additionalThrowPoints = $objectExprResult->getThrowPoints(); + } else { + $additionalThrowPoints = [ThrowPoint::createImplicit($scope, $expr)]; + } + + $result = $this->processExprNode($stmt, $expr->class, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + foreach ($additionalThrowPoints as $throwPoint) { + $throwPoints[] = $throwPoint; + } + } else { + $className = $scope->resolveName($expr->class); + } + + $classReflection = null; + if ($className !== null && $this->reflectionProvider->hasClass($className)) { + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->hasConstructor()) { + $constructorReflection = $classReflection->getConstructor(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $constructorReflection->getVariants(), + $constructorReflection->getNamedArgumentsVariants(), + ); + $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); + if ($constructorThrowPoint !== null) { + $throwPoints[] = $constructorThrowPoint; + } + } + } else { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + + if ($constructorReflection !== null) { + if (!$constructorReflection->hasSideEffects()->no()) { + $certain = $constructorReflection->isPure()->no(); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), + $certain, + ); + } + } elseif ($classReflection === null) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + 'instantiation of unknown class', + false, + ); + } + + if ($parametersAcceptor !== null) { + $expr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; + } + + } else { + $classReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); // populates $expr->class->name + $constructorResult = null; + $this->processStmtNode($expr->class, $scope, static function (Node $node, Scope $scope) use ($nodeCallback, $classReflection, &$constructorResult): void { + $nodeCallback($node, $scope); + if (!$node instanceof MethodReturnStatementsNode) { + return; + } + if ($constructorResult !== null) { + return; + } + $currentClassReflection = $node->getClassReflection(); + if ($currentClassReflection->getName() !== $classReflection->getName()) { + return; + } + if (!$currentClassReflection->hasConstructor()) { + return; + } + if ($currentClassReflection->getConstructor()->getName() !== $node->getMethodReflection()->getName()) { + return; + } + $constructorResult = $node; + }, StatementContext::createTopLevel()); + if ($constructorResult !== null) { + $throwPoints = array_merge($throwPoints, $constructorResult->getStatementResult()->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $constructorResult->getImpurePoints()); + } + if ($classReflection->hasConstructor()) { + $constructorReflection = $classReflection->getConstructor(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $constructorReflection->getVariants(), + $constructorReflection->getNamedArgumentsVariants(), + ); + } + } + + $result = $this->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $expr, $scope, $nodeCallback, $context); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } elseif ( + $expr instanceof Expr\PreInc + || $expr instanceof Expr\PostInc + || $expr instanceof Expr\PreDec + || $expr instanceof Expr\PostDec + ) { + $result = $this->processExprNode($stmt, $expr->var, $scope, $nodeCallback, $context->enterDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + + $newExpr = $expr; + if ($expr instanceof Expr\PostInc) { + $newExpr = new Expr\PreInc($expr->var); + } elseif ($expr instanceof Expr\PostDec) { + $newExpr = new Expr\PreDec($expr->var); + } + + $scope = $this->processAssignVar( + $scope, + $stmt, + $expr->var, + $newExpr, + static function (Node $node, Scope $scope) use ($nodeCallback): void { + if (!$node instanceof PropertyAssignNode && !$node instanceof VariableAssignNode) { + return; + } + + $nodeCallback($node, $scope); + }, + $context, + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + false, + )->getScope(); + } elseif ($expr instanceof Ternary) { + $ternaryCondResult = $this->processExprNode($stmt, $expr->cond, $scope, $nodeCallback, $context->enterDeep()); + $throwPoints = $ternaryCondResult->getThrowPoints(); + $impurePoints = $ternaryCondResult->getImpurePoints(); + $ifTrueScope = $ternaryCondResult->getTruthyScope(); + $ifFalseScope = $ternaryCondResult->getFalseyScope(); + $ifTrueType = null; + if ($expr->if !== null) { + $ifResult = $this->processExprNode($stmt, $expr->if, $ifTrueScope, $nodeCallback, $context); + $throwPoints = array_merge($throwPoints, $ifResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); + $ifTrueScope = $ifResult->getScope(); + $ifTrueType = $ifTrueScope->getType($expr->if); + } + + $elseResult = $this->processExprNode($stmt, $expr->else, $ifFalseScope, $nodeCallback, $context); + $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $elseResult->getImpurePoints()); + $ifFalseScope = $elseResult->getScope(); + + $condType = $scope->getType($expr->cond); + if ($condType->isTrue()->yes()) { + $finalScope = $ifTrueScope; + } elseif ($condType->isFalse()->yes()) { + $finalScope = $ifFalseScope; + } else { + if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { + $finalScope = $ifFalseScope; + } else { + $ifFalseType = $ifFalseScope->getType($expr->else); + + if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { + $finalScope = $ifTrueScope; + } else { + $finalScope = $ifTrueScope->mergeWith($ifFalseScope); + } + } + } + + return new ExpressionResult( + $finalScope, + $ternaryCondResult->hasYield(), + $throwPoints, + $impurePoints, + static fn (): MutatingScope => $finalScope->filterByTruthyValue($expr), + static fn (): MutatingScope => $finalScope->filterByFalseyValue($expr), + ); + + } elseif ($expr instanceof Expr\Yield_) { + $throwPoints = [ + ThrowPoint::createImplicit($scope, $expr), + ]; + $impurePoints = [ + new ImpurePoint( + $scope, + $expr, + 'yield', + 'yield', + true, + ), + ]; + if ($expr->key !== null) { + $keyResult = $this->processExprNode($stmt, $expr->key, $scope, $nodeCallback, $context->enterDeep()); + $scope = $keyResult->getScope(); + $throwPoints = $keyResult->getThrowPoints(); + $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); + } + if ($expr->value !== null) { + $valueResult = $this->processExprNode($stmt, $expr->value, $scope, $nodeCallback, $context->enterDeep()); + $scope = $valueResult->getScope(); + $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); + } + $hasYield = true; + } elseif ($expr instanceof Expr\Match_) { + $deepContext = $context->enterDeep(); + $condType = $scope->getType($expr->cond); + $condResult = $this->processExprNode($stmt, $expr->cond, $scope, $nodeCallback, $deepContext); + $scope = $condResult->getScope(); + $hasYield = $condResult->hasYield(); + $throwPoints = $condResult->getThrowPoints(); + $impurePoints = $condResult->getImpurePoints(); + $matchScope = $scope->enterMatch($expr); + $armNodes = []; + $hasDefaultCond = false; + $hasAlwaysTrueCond = false; + $arms = $expr->arms; + if ($condType->isEnum()->yes()) { + // enum match analysis would work even without this if branch + // but would be much slower + // this avoids using ObjectType::$subtractedType which is slow for huge enums + // because of repeated union type normalization + $enumCases = $condType->getEnumCases(); + if (count($enumCases) > 0) { + $indexedEnumCases = []; + foreach ($enumCases as $enumCase) { + $indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase; + } + $unusedIndexedEnumCases = $indexedEnumCases; + foreach ($arms as $i => $arm) { + if ($arm->conds === null) { + continue; + } + + $condNodes = []; + $conditionCases = []; + foreach ($arm->conds as $cond) { + if (!$cond instanceof Expr\ClassConstFetch) { + continue 2; + } + if (!$cond->class instanceof Name) { + continue 2; + } + if (!$cond->name instanceof Node\Identifier) { + continue 2; + } + $fetchedClassName = $scope->resolveName($cond->class); + $loweredFetchedClassName = strtolower($fetchedClassName); + if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) { + continue 2; + } + + if (!array_key_exists($loweredFetchedClassName, $unusedIndexedEnumCases)) { + throw new ShouldNotHappenException(); + } + + $caseName = $cond->name->toString(); + if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) { + continue 2; + } + + $enumCase = $indexedEnumCases[$loweredFetchedClassName][$caseName]; + $conditionCases[] = $enumCase; + $armConditionScope = $matchScope; + if (!array_key_exists($caseName, $unusedIndexedEnumCases[$loweredFetchedClassName])) { + // force "always false" + $armConditionScope = $armConditionScope->removeTypeFromExpression( + $expr->cond, + $enumCase, + ); + } else { + $unusedCasesCount = 0; + foreach ($unusedIndexedEnumCases as $cases) { + $unusedCasesCount += count($cases); + } + if ($unusedCasesCount === 1) { + $hasAlwaysTrueCond = true; + + // force "always true" + $armConditionScope = $armConditionScope->addTypeToExpression( + $expr->cond, + $enumCase, + ); + } + } + + $this->processExprNode($stmt, $cond, $armConditionScope, $nodeCallback, $deepContext); + + $condNodes[] = new MatchExpressionArmCondition( + $cond, + $armConditionScope, + $cond->getStartLine(), + ); + + unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]); + } + + $conditionCasesCount = count($conditionCases); + if ($conditionCasesCount === 0) { + throw new ShouldNotHappenException(); + } elseif ($conditionCasesCount === 1) { + $conditionCaseType = $conditionCases[0]; + } else { + $conditionCaseType = new UnionType($conditionCases); + } + + $matchArmBodyScope = $matchScope->addTypeToExpression( + $expr->cond, + $conditionCaseType, + ); + $matchArmBody = new MatchExpressionArmBody($matchArmBodyScope, $arm->body); + $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); + + $armResult = $this->processExprNode( + $stmt, + $arm->body, + $matchArmBodyScope, + $nodeCallback, + ExpressionContext::createTopLevel(), + ); + $armScope = $armResult->getScope(); + $scope = $scope->mergeWith($armScope); + $hasYield = $hasYield || $armResult->hasYield(); + $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + + unset($arms[$i]); + } + + $remainingCases = []; + foreach ($unusedIndexedEnumCases as $cases) { + foreach ($cases as $case) { + $remainingCases[] = $case; + } + } + + $remainingCasesCount = count($remainingCases); + if ($remainingCasesCount === 0) { + $remainingType = new NeverType(); + } elseif ($remainingCasesCount === 1) { + $remainingType = $remainingCases[0]; + } else { + $remainingType = new UnionType($remainingCases); + } + + $matchScope = $matchScope->addTypeToExpression($expr->cond, $remainingType); + } + } + foreach ($arms as $i => $arm) { + if ($arm->conds === null) { + $hasDefaultCond = true; + $matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body); + $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); + $armResult = $this->processExprNode($stmt, $arm->body, $matchScope, $nodeCallback, ExpressionContext::createTopLevel()); + $matchScope = $armResult->getScope(); + $hasYield = $hasYield || $armResult->hasYield(); + $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + $scope = $scope->mergeWith($matchScope); + continue; + } + + if (count($arm->conds) === 0) { + throw new ShouldNotHappenException(); + } + + $filteringExprs = []; + $armCondScope = $matchScope; + $condNodes = []; + foreach ($arm->conds as $armCond) { + $condNodes[] = new MatchExpressionArmCondition($armCond, $armCondScope, $armCond->getStartLine()); + $armCondResult = $this->processExprNode($stmt, $armCond, $armCondScope, $nodeCallback, $deepContext); + $hasYield = $hasYield || $armCondResult->hasYield(); + $throwPoints = array_merge($throwPoints, $armCondResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $armCondResult->getImpurePoints()); + $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); + $armCondResultScope = $armCondResult->getScope(); + $armCondType = $this->treatPhpDocTypesAsCertain ? $armCondResultScope->getType($armCondExpr) : $armCondResultScope->getNativeType($armCondExpr); + if ($armCondType->isTrue()->yes()) { + $hasAlwaysTrueCond = true; + } + $armCondScope = $armCondResult->getScope()->filterByFalseyValue($armCondExpr); + $filteringExprs[] = $armCond; + } + + if (count($filteringExprs) === 1) { + $filteringExpr = new BinaryOp\Identical($expr->cond, $filteringExprs[0]); + } else { + $items = []; + foreach ($filteringExprs as $filteringExpr) { + $items[] = new Node\ArrayItem($filteringExpr); + } + $filteringExpr = new FuncCall( + new Name\FullyQualified('in_array'), + [ + new Arg($expr->cond), + new Arg(new Array_($items)), + new Arg(new ConstFetch(new Name\FullyQualified('true'))), + ], + ); + } + + $bodyScope = $this->processExprNode($stmt, $filteringExpr, $matchScope, static function (): void { + }, $deepContext)->getTruthyScope(); + $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); + $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); + + $armResult = $this->processExprNode( + $stmt, + $arm->body, + $bodyScope, + $nodeCallback, + ExpressionContext::createTopLevel(), + ); + $armScope = $armResult->getScope(); + $scope = $scope->mergeWith($armScope); + $hasYield = $hasYield || $armResult->hasYield(); + $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + $matchScope = $matchScope->filterByFalseyValue($filteringExpr); + } + + $remainingType = $matchScope->getType($expr->cond); + if (!$hasDefaultCond && !$hasAlwaysTrueCond && !$remainingType instanceof NeverType) { + $throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(UnhandledMatchError::class), $expr, false); + } + + ksort($armNodes, SORT_NUMERIC); + + $nodeCallback(new MatchExpressionNode($expr->cond, array_values($armNodes), $expr, $matchScope), $scope); + } elseif ($expr instanceof AlwaysRememberedExpr) { + $result = $this->processExprNode($stmt, $expr->getExpr(), $scope, $nodeCallback, $context); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + } elseif ($expr instanceof Expr\Throw_) { + $hasYield = false; + $result = $this->processExprNode($stmt, $expr->expr, $scope, $nodeCallback, ExpressionContext::createDeep()); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $throwPoints[] = ThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false); + } elseif ($expr instanceof FunctionCallableNode) { + $throwPoints = []; + $impurePoints = []; + $hasYield = false; + if ($expr->getName() instanceof Expr) { + $result = $this->processExprNode($stmt, $expr->getName(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + } + } elseif ($expr instanceof MethodCallableNode) { + $result = $this->processExprNode($stmt, $expr->getVar(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $result->getScope(); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + if ($expr->getName() instanceof Expr) { + $nameResult = $this->processExprNode($stmt, $expr->getVar(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $nameResult->getScope(); + $hasYield = $hasYield || $nameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints()); + } + } elseif ($expr instanceof StaticMethodCallableNode) { + $throwPoints = []; + $impurePoints = []; + $hasYield = false; + if ($expr->getClass() instanceof Expr) { + $classResult = $this->processExprNode($stmt, $expr->getClass(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $classResult->getScope(); + $hasYield = $classResult->hasYield(); + $throwPoints = $classResult->getThrowPoints(); + $impurePoints = $classResult->getImpurePoints(); + } + if ($expr->getName() instanceof Expr) { + $nameResult = $this->processExprNode($stmt, $expr->getName(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $nameResult->getScope(); + $hasYield = $hasYield || $nameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints()); + } + } elseif ($expr instanceof InstantiationCallableNode) { + $throwPoints = []; + $impurePoints = []; + $hasYield = false; + if ($expr->getClass() instanceof Expr) { + $classResult = $this->processExprNode($stmt, $expr->getClass(), $scope, $nodeCallback, ExpressionContext::createDeep()); + $scope = $classResult->getScope(); + $hasYield = $classResult->hasYield(); + $throwPoints = $classResult->getThrowPoints(); + $impurePoints = $classResult->getImpurePoints(); + } + } elseif ($expr instanceof Node\Scalar) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + } elseif ($expr instanceof ConstFetch) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $nodeCallback($expr->name, $scope); + } else { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + } + + return new ExpressionResult( + $scope, + $hasYield, + $throwPoints, + $impurePoints, + static fn (): MutatingScope => $scope->filterByTruthyValue($expr), + static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + ); + } + + private function getArrayFunctionAppendingType(FunctionReflection $functionReflection, Scope $scope, FuncCall $expr): Type + { + $arrayArg = $expr->getArgs()[0]->value; + $arrayType = $scope->getType($arrayArg); + $callArgs = array_slice($expr->getArgs(), 1); + + /** + * @param Arg[] $callArgs + * @param callable(?Type, Type, bool): void $setOffsetValueType + */ + $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null): void { + foreach ($callArgs as $callArg) { + $callArgType = $scope->getType($callArg->value); + if ($callArg->unpack) { + $constantArrays = $callArgType->getConstantArrays(); + if (count($constantArrays) === 1) { + $iterableValueTypes = $constantArrays[0]->getValueTypes(); + } else { + $iterableValueTypes = [$callArgType->getIterableValueType()]; + $nonConstantArrayWasUnpacked = true; + } + + $isOptional = !$callArgType->isIterableAtLeastOnce()->yes(); + foreach ($iterableValueTypes as $iterableValueType) { + if ($iterableValueType instanceof UnionType) { + foreach ($iterableValueType->getTypes() as $innerType) { + $setOffsetValueType(null, $innerType, $isOptional); + } + } else { + $setOffsetValueType(null, $iterableValueType, $isOptional); + } + } + continue; + } + $setOffsetValueType(null, $callArgType, false); + } + }; + + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $newArrayTypes = []; + $prepend = $functionReflection->getName() === 'array_unshift'; + foreach ($constantArrays as $constantArray) { + $arrayTypeBuilder = $prepend ? ConstantArrayTypeBuilder::createEmpty() : ConstantArrayTypeBuilder::createFromConstantArray($constantArray); + + $setOffsetValueTypes( + $scope, + $callArgs, + static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arrayTypeBuilder): void { + $arrayTypeBuilder->setOffsetValueType($offsetType, $valueType, $optional); + }, + $nonConstantArrayWasUnpacked, + ); + + if ($prepend) { + $keyTypes = $constantArray->getKeyTypes(); + $valueTypes = $constantArray->getValueTypes(); + foreach ($keyTypes as $k => $keyType) { + $arrayTypeBuilder->setOffsetValueType( + count($keyType->getConstantStrings()) === 1 ? $keyType->getConstantStrings()[0] : null, + $valueTypes[$k], + $constantArray->isOptionalKey($k), + ); + } + } + + $constantArray = $arrayTypeBuilder->getArray(); + + if ($constantArray->isConstantArray()->yes() && $nonConstantArrayWasUnpacked) { + $array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType()); + $isList = $constantArray->isList()->yes(); + $constantArray = $constantArray->isIterableAtLeastOnce()->yes() + ? TypeCombinator::intersect($array, new NonEmptyArrayType()) + : $array; + $constantArray = $isList + ? TypeCombinator::intersect($constantArray, new AccessoryArrayListType()) + : $constantArray; + } + + $newArrayTypes[] = $constantArray; + } + + return TypeCombinator::union(...$newArrayTypes); + } + + $setOffsetValueTypes( + $scope, + $callArgs, + static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arrayType): void { + $isIterableAtLeastOnce = $arrayType->isIterableAtLeastOnce()->yes() || !$optional; + $arrayType = $arrayType->setOffsetValueType($offsetType, $valueType); + if ($isIterableAtLeastOnce) { + return; + } + + $arrayType = TypeCombinator::union($arrayType, new ConstantArrayType([], [])); + }, + ); + + return $arrayType; + } + + private function getArraySortPreserveListFunctionType(Type $type): Type + { + $isIterableAtLeastOnce = $type->isIterableAtLeastOnce(); + if ($isIterableAtLeastOnce->no()) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($isIterableAtLeastOnce): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if (!$type instanceof ArrayType && !$type instanceof ConstantArrayType) { + return $type; + } + + $newArrayType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $type->getIterableValueType()), new AccessoryArrayListType()); + if ($isIterableAtLeastOnce->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); + } + + return $newArrayType; + }); + } + + private function getArraySortDoNotPreserveListFunctionType(Type $type): Type + { + $isIterableAtLeastOnce = $type->isIterableAtLeastOnce(); + if ($isIterableAtLeastOnce->no()) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($isIterableAtLeastOnce): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + $types = []; + foreach ($constantArrays as $constantArray) { + $types[] = new ConstantArrayType( + $constantArray->getKeyTypes(), + $constantArray->getValueTypes(), + $constantArray->getNextAutoIndexes(), + $constantArray->getOptionalKeys(), + $constantArray->isList()->and(TrinaryLogic::createMaybe()), + ); + } + + return TypeCombinator::union(...$types); + } + + $newArrayType = new ArrayType($type->getIterableKeyType(), $type->getIterableValueType()); + if ($isIterableAtLeastOnce->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); + } + + return $newArrayType; + }); + } + + private function getFunctionThrowPoint( + FunctionReflection $functionReflection, + ?ParametersAcceptor $parametersAcceptor, + FuncCall $funcCall, + MutatingScope $scope, + ): ?ThrowPoint + { + $normalizedFuncCall = $funcCall; + if ($parametersAcceptor !== null) { + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $funcCall); + } + + if ($normalizedFuncCall !== null) { + foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicFunctionThrowTypeExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection)) { + continue; + } + + $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $scope); + if ($throwType === null) { + return null; + } + + return ThrowPoint::createExplicit($scope, $throwType, $funcCall, false); + } + } + + $throwType = $functionReflection->getThrowType(); + if ($throwType === null && $parametersAcceptor !== null) { + $returnType = $parametersAcceptor->getReturnType(); + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + $throwType = new ObjectType(Throwable::class); + } + } + + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + return ThrowPoint::createExplicit($scope, $throwType, $funcCall, true); + } + } elseif ($this->implicitThrows) { + $requiredParameters = null; + if ($parametersAcceptor !== null) { + $requiredParameters = 0; + foreach ($parametersAcceptor->getParameters() as $parameter) { + if ($parameter->isOptional()) { + continue; + } + + $requiredParameters++; + } + } + if ( + !$functionReflection->isBuiltin() + || $requiredParameters === null + || $requiredParameters > 0 + || count($funcCall->getArgs()) > 0 + ) { + $functionReturnedType = $scope->getType($funcCall); + if (!(new ObjectType(Throwable::class))->isSuperTypeOf($functionReturnedType)->yes()) { + return ThrowPoint::createImplicit($scope, $funcCall); + } + } + } + + return null; + } + + private function getMethodThrowPoint(MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, MethodCall $methodCall, MutatingScope $scope): ?ThrowPoint + { + $normalizedMethodCall = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $methodCall); + if ($normalizedMethodCall !== null) { + foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicMethodThrowTypeExtensions() as $extension) { + if (!$extension->isMethodSupported($methodReflection)) { + continue; + } + + $throwType = $extension->getThrowTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); + if ($throwType === null) { + return null; + } + + return ThrowPoint::createExplicit($scope, $throwType, $methodCall, false); + } + } + + $throwType = $methodReflection->getThrowType(); + if ($throwType === null) { + $returnType = $parametersAcceptor->getReturnType(); + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + $throwType = new ObjectType(Throwable::class); + } + } + + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + return ThrowPoint::createExplicit($scope, $throwType, $methodCall, true); + } + } elseif ($this->implicitThrows) { + $methodReturnedType = $scope->getType($methodCall); + if (!(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { + return ThrowPoint::createImplicit($scope, $methodCall); + } + } + + return null; + } + + /** + * @param Node\Arg[] $args + */ + private function getConstructorThrowPoint(MethodReflection $constructorReflection, ParametersAcceptor $parametersAcceptor, ClassReflection $classReflection, New_ $new, Name $className, array $args, MutatingScope $scope): ?ThrowPoint + { + $methodCall = new StaticCall($className, $constructorReflection->getName(), $args); + $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); + if ($normalizedMethodCall !== null) { + foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicStaticMethodThrowTypeExtensions() as $extension) { + if (!$extension->isStaticMethodSupported($constructorReflection)) { + continue; + } + + $throwType = $extension->getThrowTypeFromStaticMethodCall($constructorReflection, $normalizedMethodCall, $scope); + if ($throwType === null) { + return null; + } + + return ThrowPoint::createExplicit($scope, $throwType, $new, false); + } + } + + if ($constructorReflection->getThrowType() !== null) { + $throwType = $constructorReflection->getThrowType(); + if (!$throwType->isVoid()->yes()) { + return ThrowPoint::createExplicit($scope, $throwType, $new, true); + } + } elseif ($this->implicitThrows) { + if ($classReflection->getName() !== Throwable::class && !$classReflection->isSubclassOf(Throwable::class)) { + return ThrowPoint::createImplicit($scope, $methodCall); + } + } + + return null; + } + + private function getStaticMethodThrowPoint(MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, StaticCall $methodCall, MutatingScope $scope): ?ThrowPoint + { + $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); + if ($normalizedMethodCall !== null) { + foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicStaticMethodThrowTypeExtensions() as $extension) { + if (!$extension->isStaticMethodSupported($methodReflection)) { + continue; + } + + $throwType = $extension->getThrowTypeFromStaticMethodCall($methodReflection, $normalizedMethodCall, $scope); + if ($throwType === null) { + return null; + } + + return ThrowPoint::createExplicit($scope, $throwType, $methodCall, false); + } + } + + if ($methodReflection->getThrowType() !== null) { + $throwType = $methodReflection->getThrowType(); + if (!$throwType->isVoid()->yes()) { + return ThrowPoint::createExplicit($scope, $throwType, $methodCall, true); + } + } elseif ($this->implicitThrows) { + $methodReturnedType = $scope->getType($methodCall); + if (!(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { + return ThrowPoint::createImplicit($scope, $methodCall); + } + } + + return null; + } + + /** + * @return ThrowPoint[] + */ + private function getPropertyReadThrowPointsFromGetHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + ): array + { + return $this->getThrowPointsFromPropertyHook($scope, $propertyFetch, $propertyReflection, 'get'); + } + + /** + * @return ThrowPoint[] + */ + private function getPropertyAssignThrowPointsFromSetHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + ): array + { + return $this->getThrowPointsFromPropertyHook($scope, $propertyFetch, $propertyReflection, 'set'); + } + + /** + * @param 'get'|'set' $hookName + * @return ThrowPoint[] + */ + private function getThrowPointsFromPropertyHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + string $hookName, + ): array + { + $scopeFunction = $scope->getFunction(); + if ( + $scopeFunction instanceof PhpMethodFromParserNodeReflection + && $scopeFunction->isPropertyHook() + && $propertyFetch->var instanceof Variable + && $propertyFetch->var->name === 'this' + && $propertyFetch->name instanceof Identifier + && $propertyFetch->name->toString() === $scopeFunction->getHookedPropertyName() + ) { + return []; + } + $declaringClass = $propertyReflection->getDeclaringClass(); + if (!$propertyReflection->hasHook($hookName)) { + if ( + $propertyReflection->isPrivate() + || $propertyReflection->isFinal()->yes() + || $declaringClass->isFinal() + ) { + return []; + } + + if ($this->implicitThrows) { + return [ThrowPoint::createImplicit($scope, $propertyFetch)]; + } + + return []; + } + + $getHook = $propertyReflection->getHook($hookName); + $throwType = $getHook->getThrowType(); + + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + return [ThrowPoint::createExplicit($scope, $throwType, $propertyFetch, true)]; + } + } elseif ($this->implicitThrows) { + return [ThrowPoint::createImplicit($scope, $propertyFetch)]; + } + + return []; + } + + /** + * @return string[] + */ + private function getAssignedVariables(Expr $expr): array + { + if ($expr instanceof Expr\Variable) { + if (is_string($expr->name)) { + return [$expr->name]; + } + + return []; + } + + if ($expr instanceof Expr\List_) { + $names = []; + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + $names = array_merge($names, $this->getAssignedVariables($item->value)); + } + + return $names; + } + + if ($expr instanceof ArrayDimFetch) { + return $this->getAssignedVariables($expr->var); + } + + return []; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function callNodeCallbackWithExpression( + callable $nodeCallback, + Expr $expr, + MutatingScope $scope, + ExpressionContext $context, + ): void + { + if ($context->isDeep()) { + $scope = $scope->exitFirstLevelStatements(); + } + $nodeCallback($expr, $scope); + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processClosureNode( + Node\Stmt $stmt, + Expr\Closure $expr, + MutatingScope $scope, + callable $nodeCallback, + ExpressionContext $context, + ?Type $passedToType, + ): ProcessClosureResult + { + foreach ($expr->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + + $byRefUses = []; + + $closureCallArgs = $expr->getAttribute(ClosureArgVisitor::ATTRIBUTE_NAME); + $callableParameters = $this->createCallableParameters( + $scope, + $expr, + $closureCallArgs, + $passedToType, + ); + + $useScope = $scope; + foreach ($expr->uses as $use) { + if ($use->byRef) { + $byRefUses[] = $use; + $useScope = $useScope->enterExpressionAssign($use->var); + + $inAssignRightSideVariableName = $context->getInAssignRightSideVariableName(); + $inAssignRightSideType = $context->getInAssignRightSideType(); + $inAssignRightSideNativeType = $context->getInAssignRightSideNativeType(); + if ( + $inAssignRightSideVariableName === $use->var->name + && $inAssignRightSideType !== null + && $inAssignRightSideNativeType !== null + ) { + if ($inAssignRightSideType instanceof ClosureType) { + $variableType = $inAssignRightSideType; + } else { + $alreadyHasVariableType = $scope->hasVariableType($inAssignRightSideVariableName); + if ($alreadyHasVariableType->no()) { + $variableType = TypeCombinator::union(new NullType(), $inAssignRightSideType); + } else { + $variableType = TypeCombinator::union($scope->getVariableType($inAssignRightSideVariableName), $inAssignRightSideType); + } + } + if ($inAssignRightSideNativeType instanceof ClosureType) { + $variableNativeType = $inAssignRightSideNativeType; + } else { + $alreadyHasVariableType = $scope->hasVariableType($inAssignRightSideVariableName); + if ($alreadyHasVariableType->no()) { + $variableNativeType = TypeCombinator::union(new NullType(), $inAssignRightSideNativeType); + } else { + $variableNativeType = TypeCombinator::union($scope->getVariableType($inAssignRightSideVariableName), $inAssignRightSideNativeType); + } + } + $scope = $scope->assignVariable($inAssignRightSideVariableName, $variableType, $variableNativeType, TrinaryLogic::createYes()); + } + } + $this->processExprNode($stmt, $use->var, $useScope, $nodeCallback, $context); + if (!$use->byRef) { + continue; + } + + $useScope = $useScope->exitExpressionAssign($use->var); + } + + if ($expr->returnType !== null) { + $nodeCallback($expr->returnType, $scope); + } + + $closureScope = $scope->enterAnonymousFunction($expr, $callableParameters); + $closureScope = $closureScope->processClosureScope($scope, null, $byRefUses); + $closureType = $closureScope->getAnonymousFunctionReflection(); + if (!$closureType instanceof ClosureType) { + throw new ShouldNotHappenException(); + } + + $nodeCallback(new InClosureNode($closureType, $expr), $closureScope); + + $executionEnds = []; + $gatheredReturnStatements = []; + $gatheredYieldStatements = []; + $closureImpurePoints = []; + $invalidateExpressions = []; + $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { + $nodeCallback($node, $scope); + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { + return; + } + + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }; + + if (count($byRefUses) === 0) { + $statementResult = $this->processStmtNodes($expr, $expr->stmts, $closureScope, $closureStmtsCallback, StatementContext::createTopLevel()); + $nodeCallback(new ClosureReturnStatementsNode( + $expr, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $closureImpurePoints), + ), $closureScope); + + return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions); + } + + $count = 0; + $closureResultScope = null; + do { + $prevScope = $closureScope; + + $intermediaryClosureScopeResult = $this->processStmtNodes($expr, $expr->stmts, $closureScope, static function (): void { + }, StatementContext::createTopLevel()); + $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); + foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { + $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); + } + + if ($expr->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) === true) { + $closureResultScope = $intermediaryClosureScope; + break; + } + + $closureScope = $scope->enterAnonymousFunction($expr, $callableParameters); + $closureScope = $closureScope->processClosureScope($intermediaryClosureScope, $prevScope, $byRefUses); + + if ($closureScope->equals($prevScope)) { + break; + } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $closureScope = $prevScope->generalizeWith($closureScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + if ($closureResultScope === null) { + $closureResultScope = $closureScope; + } + + $statementResult = $this->processStmtNodes($expr, $expr->stmts, $closureScope, $closureStmtsCallback, StatementContext::createTopLevel()); + $nodeCallback(new ClosureReturnStatementsNode( + $expr, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $closureImpurePoints), + ), $closureScope); + + return new ProcessClosureResult($scope->processClosureScope($closureResultScope, null, $byRefUses), $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions); + } + + /** + * @param InvalidateExprNode[] $invalidatedExpressions + * @param string[] $uses + */ + private function processImmediatelyCalledCallable(MutatingScope $scope, array $invalidatedExpressions, array $uses): MutatingScope + { + if ($scope->isInClass()) { + $uses[] = 'this'; + } + + $finder = new NodeFinder(); + foreach ($invalidatedExpressions as $invalidateExpression) { + $found = false; + foreach ($uses as $use) { + $result = $finder->findFirst([$invalidateExpression->getExpr()], static fn ($node) => $node instanceof Variable && $node->name === $use); + if ($result === null) { + continue; + } + + $found = true; + break; + } + + if (!$found) { + continue; + } + + $scope = $scope->invalidateExpression($invalidateExpression->getExpr(), true); + } + + return $scope; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processArrowFunctionNode( + Node\Stmt $stmt, + Expr\ArrowFunction $expr, + MutatingScope $scope, + callable $nodeCallback, + ?Type $passedToType, + ): ExpressionResult + { + foreach ($expr->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + if ($expr->returnType !== null) { + $nodeCallback($expr->returnType, $scope); + } + + $arrowFunctionCallArgs = $expr->getAttribute(ArrowFunctionArgVisitor::ATTRIBUTE_NAME); + $arrowFunctionScope = $scope->enterArrowFunction($expr, $this->createCallableParameters( + $scope, + $expr, + $arrowFunctionCallArgs, + $passedToType, + )); + $arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection(); + if (!$arrowFunctionType instanceof ClosureType) { + throw new ShouldNotHappenException(); + } + $nodeCallback(new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope); + $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $nodeCallback, ExpressionContext::createTopLevel()); + + return new ExpressionResult($scope, false, $exprResult->getThrowPoints(), $exprResult->getImpurePoints()); + } + + /** + * @param Node\Arg[] $args + * @return ParameterReflection[]|null + */ + public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType): ?array + { + $callableParameters = null; + if ($args !== null) { + $closureType = $scope->getType($closureExpr); + + if ($closureType->isCallable()->no()) { + return null; + } + + $acceptors = $closureType->getCallableParametersAcceptors($scope); + if (count($acceptors) === 1) { + $callableParameters = $acceptors[0]->getParameters(); + + foreach ($callableParameters as $index => $callableParameter) { + if (!isset($args[$index])) { + continue; + } + + $type = $scope->getType($args[$index]->value); + $callableParameters[$index] = new NativeParameterReflection( + $callableParameter->getName(), + $callableParameter->isOptional(), + $type, + $callableParameter->passedByReference(), + $callableParameter->isVariadic(), + $callableParameter->getDefaultValue(), + ); + } + } + } elseif ($passedToType !== null && !$passedToType->isCallable()->no()) { + if ($passedToType instanceof UnionType) { + $passedToType = TypeCombinator::union(...array_filter( + $passedToType->getTypes(), + static fn (Type $type) => $type->isCallable()->yes(), + )); + + if ($passedToType->isCallable()->no()) { + return null; + } + } + + $acceptors = $passedToType->getCallableParametersAcceptors($scope); + if (count($acceptors) > 0) { + foreach ($acceptors as $acceptor) { + if ($callableParameters === null) { + $callableParameters = array_map(static fn (ParameterReflection $callableParameter) => new NativeParameterReflection( + $callableParameter->getName(), + $callableParameter->isOptional(), + $callableParameter->getType(), + $callableParameter->passedByReference(), + $callableParameter->isVariadic(), + $callableParameter->getDefaultValue(), + ), $acceptor->getParameters()); + continue; + } + + $newParameters = []; + foreach ($acceptor->getParameters() as $i => $callableParameter) { + if (!array_key_exists($i, $callableParameters)) { + $newParameters[] = $callableParameter; + continue; + } + + $newParameters[] = $callableParameters[$i]->union(new NativeParameterReflection( + $callableParameter->getName(), + $callableParameter->isOptional(), + $callableParameter->getType(), + $callableParameter->passedByReference(), + $callableParameter->isVariadic(), + $callableParameter->getDefaultValue(), + )); + } + + $callableParameters = $newParameters; + } + } + } + + return $callableParameters; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processParamNode( + Node\Stmt $stmt, + Node\Param $param, + MutatingScope $scope, + callable $nodeCallback, + ): void + { + $this->processAttributeGroups($stmt, $param->attrGroups, $scope, $nodeCallback); + $nodeCallback($param, $scope); + if ($param->type !== null) { + $nodeCallback($param->type, $scope); + } + if ($param->default === null) { + return; + } + + $this->processExprNode($stmt, $param->default, $scope, $nodeCallback, ExpressionContext::createDeep()); + } + + /** + * @param AttributeGroup[] $attrGroups + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processAttributeGroups( + Node\Stmt $stmt, + array $attrGroups, + MutatingScope $scope, + callable $nodeCallback, + ): void + { + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + foreach ($attr->args as $arg) { + $this->processExprNode($stmt, $arg->value, $scope, $nodeCallback, ExpressionContext::createDeep()); + $nodeCallback($arg, $scope); + } + $nodeCallback($attr, $scope); + } + $nodeCallback($attrGroup, $scope); + } + } + + /** + * @param Node\PropertyHook[] $hooks + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processPropertyHooks( + Node\Stmt $stmt, + Identifier|Name|ComplexType|null $nativeTypeNode, + ?Type $phpDocType, + string $propertyName, + array $hooks, + MutatingScope $scope, + callable $nodeCallback, + ): void + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $classReflection = $scope->getClassReflection(); + + foreach ($hooks as $hook) { + $nodeCallback($hook, $scope); + $this->processAttributeGroups($stmt, $hook->attrGroups, $scope, $nodeCallback); + + [, $phpDocParameterTypes,,,, $phpDocThrowType,,,,,,,, $phpDocComment] = $this->getPhpDocs($scope, $hook); + + foreach ($hook->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $hook); + + $hookScope = $scope->enterPropertyHook( + $hook, + $propertyName, + $nativeTypeNode, + $phpDocType, + $phpDocParameterTypes, + $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, + $phpDocComment, + ); + $hookReflection = $hookScope->getFunction(); + if (!$hookReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } + + if (!$classReflection->hasNativeProperty($propertyName)) { + throw new ShouldNotHappenException(); + } + + $propertyReflection = $classReflection->getNativeProperty($propertyName); + + $nodeCallback(new InPropertyHookNode( + $classReflection, + $hookReflection, + $propertyReflection, + $hook, + ), $hookScope); + + $stmts = $hook->getStmts(); + if ($stmts === null) { + return; + } + + if ($hook->body instanceof Expr) { + // enrich attributes of nodes in short hook body statements + $traverser = new NodeTraverser( + new LineAttributesVisitor($hook->body->getStartLine(), $hook->body->getEndLine()), + ); + $traverser->traverse($stmts); + } + + $gatheredReturnStatements = []; + $executionEnds = []; + $methodImpurePoints = []; + $statementResult = $this->processStmtNodes(new PropertyHookStatementNode($hook), $stmts, $hookScope, static function (Node $node, Scope $scope) use ($nodeCallback, $hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $hookScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $hookImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if (!$node instanceof Return_) { + return; + } + + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel()); + + $nodeCallback(new PropertyHookReturnStatementsNode( + $hook, + $gatheredReturnStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $methodImpurePoints), + $classReflection, + $hookReflection, + $propertyReflection, + ), $hookScope); + } + } + + /** + * @param MethodReflection|FunctionReflection|null $calleeReflection + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processArgs( + Node\Stmt $stmt, + $calleeReflection, + ?ExtendedMethodReflection $nakedMethodReflection, + ?ParametersAcceptor $parametersAcceptor, + CallLike $callLike, + MutatingScope $scope, + callable $nodeCallback, + ExpressionContext $context, + ?MutatingScope $closureBindScope = null, + ): ExpressionResult + { + $args = $callLike->getArgs(); + + if ($parametersAcceptor !== null) { + $parameters = $parametersAcceptor->getParameters(); + } + + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + foreach ($args as $i => $arg) { + $assignByReference = false; + $parameter = null; + $parameterType = null; + $parameterNativeType = null; + if (isset($parameters) && $parametersAcceptor !== null) { + if (isset($parameters[$i])) { + $assignByReference = $parameters[$i]->passedByReference()->createsNewVariable(); + $parameterType = $parameters[$i]->getType(); + + if ($parameters[$i] instanceof ExtendedParameterReflection) { + $parameterNativeType = $parameters[$i]->getNativeType(); + } + $parameter = $parameters[$i]; + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = $parameters[count($parameters) - 1]; + $assignByReference = $lastParameter->passedByReference()->createsNewVariable(); + $parameterType = $lastParameter->getType(); + + if ($lastParameter instanceof ExtendedParameterReflection) { + $parameterNativeType = $lastParameter->getNativeType(); + } + $parameter = $lastParameter; + } + } + + $lookForUnset = false; + if ($assignByReference) { + $isBuiltin = false; + if ($calleeReflection instanceof FunctionReflection && $calleeReflection->isBuiltin()) { + $isBuiltin = true; + } elseif ($calleeReflection instanceof ExtendedMethodReflection && $calleeReflection->getDeclaringClass()->isBuiltin()) { + $isBuiltin = true; + } + if ( + $isBuiltin + || ($parameterNativeType === null || !$parameterNativeType->isNull()->no()) + ) { + $scope = $this->lookForSetAllowedUndefinedExpressions($scope, $arg->value); + $lookForUnset = true; + } + } + + if ($calleeReflection !== null) { + $scope = $scope->pushInFunctionCall($calleeReflection, $parameter); + } + + $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $nodeCallback($originalArg, $scope); + + $originalScope = $scope; + $scopeToPass = $scope; + if ($i === 0 && $closureBindScope !== null) { + $scopeToPass = $closureBindScope; + } + + if ($parameter instanceof ExtendedParameterReflection) { + $parameterCallImmediately = $parameter->isImmediatelyInvokedCallable(); + if ($parameterCallImmediately->maybe()) { + $callCallbackImmediately = $calleeReflection instanceof FunctionReflection; + } else { + $callCallbackImmediately = $parameterCallImmediately->yes(); + } + } else { + $callCallbackImmediately = $calleeReflection instanceof FunctionReflection; + } + if ($arg->value instanceof Expr\Closure) { + $restoreThisScope = null; + if ( + $closureBindScope === null + && $parameter instanceof ExtendedParameterReflection + && $parameter->getClosureThisType() !== null + && !$arg->value->static + ) { + $restoreThisScope = $scopeToPass; + $scopeToPass = $scopeToPass->assignVariable('this', $parameter->getClosureThisType(), new ObjectWithoutClassType(), TrinaryLogic::createYes()); + } + + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } + } + + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $context); + $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $nodeCallback, $context, $parameterType ?? null); + if ($callCallbackImmediately) { + $throwPoints = array_merge($throwPoints, array_map(static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit() ? ThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : ThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); + } + + $uses = []; + foreach ($arg->value->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $uses[] = $use->var->name; + } + + $scope = $closureResult->getScope(); + $invalidateExpressions = $closureResult->getInvalidateExpressions(); + if ($restoreThisScope !== null) { + $nodeFinder = new NodeFinder(); + $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; + foreach ($invalidateExpressions as $j => $invalidateExprNode) { + $foundThis = $nodeFinder->findFirst([$invalidateExprNode->getExpr()], $cb); + if ($foundThis === null) { + continue; + } + + unset($invalidateExpressions[$j]); + } + $invalidateExpressions = array_values($invalidateExpressions); + $scope = $scope->restoreThis($restoreThisScope); + } + + $scope = $this->processImmediatelyCalledCallable($scope, $invalidateExpressions, $uses); + } elseif ($arg->value instanceof Expr\ArrowFunction) { + if ( + $closureBindScope === null + && $parameter instanceof ExtendedParameterReflection + && $parameter->getClosureThisType() !== null + && !$arg->value->static + ) { + $scopeToPass = $scopeToPass->assignVariable('this', $parameter->getClosureThisType(), new ObjectWithoutClassType(), TrinaryLogic::createYes()); + } + + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } + } + + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $context); + $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $nodeCallback, $parameterType ?? null); + if ($callCallbackImmediately) { + $throwPoints = array_merge($throwPoints, array_map(static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit() ? ThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : ThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); + } + } else { + $exprType = $scope->getType($arg->value); + $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $nodeCallback, $context->enterDeep()); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + $scope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + + if ($exprType->isCallable()->yes()) { + $acceptors = $exprType->getCallableParametersAcceptors($scope); + if (count($acceptors) === 1) { + $scope = $this->processImmediatelyCalledCallable($scope, $acceptors[0]->getInvalidateExpressions(), $acceptors[0]->getUsedVariables()); + if ($callCallbackImmediately) { + $callableThrowPoints = array_map(static fn (SimpleThrowPoint $throwPoint) => $throwPoint->isExplicit() ? ThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : ThrowPoint::createImplicit($scope, $arg->value), $acceptors[0]->getThrowPoints()); + if (!$this->implicitThrows) { + $callableThrowPoints = array_values(array_filter($callableThrowPoints, static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit())); + } + $throwPoints = array_merge($throwPoints, $callableThrowPoints); + $impurePoints = array_merge($impurePoints, array_map(static fn (SimpleImpurePoint $impurePoint) => new ImpurePoint($scope, $arg->value, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $acceptors[0]->getImpurePoints())); + } + } + } + } + + if ($assignByReference && $lookForUnset) { + $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $arg->value); + } + + if ($calleeReflection !== null) { + $scope = $scope->popInFunctionCall(); + } + + if ($i !== 0 || $closureBindScope === null) { + continue; + } + + $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); + } + foreach ($args as $i => $arg) { + if (!isset($parameters) || $parametersAcceptor === null) { + continue; + } + + $byRefType = new MixedType(); + $assignByReference = false; + $currentParameter = null; + if (isset($parameters[$i])) { + $currentParameter = $parameters[$i]; + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $currentParameter = $parameters[count($parameters) - 1]; + } + + if ($currentParameter !== null) { + $assignByReference = $currentParameter->passedByReference()->createsNewVariable(); + if ($assignByReference) { + if ($currentParameter instanceof ExtendedParameterReflection && $currentParameter->getOutType() !== null) { + $byRefType = $currentParameter->getOutType(); + } elseif ( + $calleeReflection instanceof MethodReflection + && !$calleeReflection->getDeclaringClass()->isBuiltin() + ) { + $byRefType = $currentParameter->getType(); + } elseif ( + $calleeReflection instanceof FunctionReflection + && !$calleeReflection->isBuiltin() + ) { + $byRefType = $currentParameter->getType(); + } + } + } + + if ($assignByReference) { + if ($currentParameter === null) { + throw new ShouldNotHappenException(); + } + + $argValue = $arg->value; + if (!$argValue instanceof Variable || $argValue->name !== 'this') { + $paramOutType = $this->getParameterOutExtensionsType($callLike, $calleeReflection, $currentParameter, $scope); + if ($paramOutType !== null) { + $byRefType = $paramOutType; + } + + $result = $this->processAssignVar( + $scope, + $stmt, + $argValue, + new TypeExpr($byRefType), + static function (Node $node, Scope $scope) use ($nodeCallback): void { + if (!$node instanceof PropertyAssignNode && !$node instanceof VariableAssignNode) { + return; + } + + $nodeCallback($node, $scope); + }, + $context, + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + true, + ); + $scope = $result->getScope(); + } + } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { + $argType = $scope->getType($arg->value); + if (!$argType->isObject()->no()) { + $nakedReturnType = null; + if ($nakedMethodReflection !== null) { + $nakedParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $args, + $nakedMethodReflection->getVariants(), + $nakedMethodReflection->getNamedArgumentsVariants(), + ); + $nakedReturnType = $nakedParametersAcceptor->getReturnType(); + } + if ( + $nakedReturnType === null + || !(new ThisType($nakedMethodReflection->getDeclaringClass()))->isSuperTypeOf($nakedReturnType)->yes() + || $nakedMethodReflection->isPure()->no() + ) { + $nodeCallback(new InvalidateExprNode($arg->value), $scope); + $scope = $scope->invalidateExpression($arg->value, true); + } + } elseif (!(new ResourceType())->isSuperTypeOf($argType)->no()) { + $nodeCallback(new InvalidateExprNode($arg->value), $scope); + $scope = $scope->invalidateExpression($arg->value, true); + } + } + } + + return new ExpressionResult($scope, $hasYield, $throwPoints, $impurePoints); + } + + /** + * @param MethodReflection|FunctionReflection|null $calleeReflection + */ + private function getParameterTypeFromParameterClosureTypeExtension(CallLike $callLike, $calleeReflection, ParameterReflection $parameter, MutatingScope $scope): ?Type + { + if ($callLike instanceof FuncCall && $calleeReflection instanceof FunctionReflection) { + foreach ($this->parameterClosureTypeExtensionProvider->getFunctionParameterClosureTypeExtensions() as $functionParameterClosureTypeExtension) { + if ($functionParameterClosureTypeExtension->isFunctionSupported($calleeReflection, $parameter)) { + return $functionParameterClosureTypeExtension->getTypeFromFunctionCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } elseif ($calleeReflection instanceof MethodReflection) { + if ($callLike instanceof StaticCall) { + foreach ($this->parameterClosureTypeExtensionProvider->getStaticMethodParameterClosureTypeExtensions() as $staticMethodParameterClosureTypeExtension) { + if ($staticMethodParameterClosureTypeExtension->isStaticMethodSupported($calleeReflection, $parameter)) { + return $staticMethodParameterClosureTypeExtension->getTypeFromStaticMethodCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } elseif ($callLike instanceof MethodCall) { + foreach ($this->parameterClosureTypeExtensionProvider->getMethodParameterClosureTypeExtensions() as $methodParameterClosureTypeExtension) { + if ($methodParameterClosureTypeExtension->isMethodSupported($calleeReflection, $parameter)) { + return $methodParameterClosureTypeExtension->getTypeFromMethodCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } + } + + return null; + } + + /** + * @param MethodReflection|FunctionReflection|null $calleeReflection + */ + private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflection, ParameterReflection $currentParameter, MutatingScope $scope): ?Type + { + $paramOutTypes = []; + if ($callLike instanceof FuncCall && $calleeReflection instanceof FunctionReflection) { + foreach ($this->parameterOutTypeExtensionProvider->getFunctionParameterOutTypeExtensions() as $functionParameterOutTypeExtension) { + if (!$functionParameterOutTypeExtension->isFunctionSupported($calleeReflection, $currentParameter)) { + continue; + } + + $resolvedType = $functionParameterOutTypeExtension->getParameterOutTypeFromFunctionCall($calleeReflection, $callLike, $currentParameter, $scope); + if ($resolvedType === null) { + continue; + } + $paramOutTypes[] = $resolvedType; + } + } elseif ($callLike instanceof MethodCall && $calleeReflection instanceof MethodReflection) { + foreach ($this->parameterOutTypeExtensionProvider->getMethodParameterOutTypeExtensions() as $methodParameterOutTypeExtension) { + if (!$methodParameterOutTypeExtension->isMethodSupported($calleeReflection, $currentParameter)) { + continue; + } + + $resolvedType = $methodParameterOutTypeExtension->getParameterOutTypeFromMethodCall($calleeReflection, $callLike, $currentParameter, $scope); + if ($resolvedType === null) { + continue; + } + $paramOutTypes[] = $resolvedType; + } + } elseif ($callLike instanceof StaticCall && $calleeReflection instanceof MethodReflection) { + foreach ($this->parameterOutTypeExtensionProvider->getStaticMethodParameterOutTypeExtensions() as $staticMethodParameterOutTypeExtension) { + if (!$staticMethodParameterOutTypeExtension->isStaticMethodSupported($calleeReflection, $currentParameter)) { + continue; + } + + $resolvedType = $staticMethodParameterOutTypeExtension->getParameterOutTypeFromStaticMethodCall($calleeReflection, $callLike, $currentParameter, $scope); + if ($resolvedType === null) { + continue; + } + $paramOutTypes[] = $resolvedType; + } + } + + if (count($paramOutTypes) === 1) { + return $paramOutTypes[0]; + } + + if (count($paramOutTypes) > 1) { + return TypeCombinator::union(...$paramOutTypes); + } + + return null; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback + */ + private function processAssignVar( + MutatingScope $scope, + Node\Stmt $stmt, + Expr $var, + Expr $assignedExpr, + callable $nodeCallback, + ExpressionContext $context, + Closure $processExprCallback, + bool $enterExpressionAssign, + ): ExpressionResult + { + $nodeCallback($var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope); + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $isAssignOp = $assignedExpr instanceof Expr\AssignOp && !$enterExpressionAssign; + if ($var instanceof Variable && is_string($var->name)) { + $result = $processExprCallback($scope); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + if (in_array($var->name, Scope::SUPERGLOBAL_VARIABLES, true)) { + $impurePoints[] = new ImpurePoint($scope, $var, 'superglobal', 'assign to superglobal variable', true); + } + $assignedExpr = $this->unwrapAssign($assignedExpr); + $type = $scope->getType($assignedExpr); + + $conditionalExpressions = []; + if ($assignedExpr instanceof Ternary) { + $if = $assignedExpr->if; + if ($if === null) { + $if = $assignedExpr->cond; + } + $condScope = $this->processExprNode($stmt, $assignedExpr->cond, $scope, static function (): void { + }, ExpressionContext::createDeep())->getScope(); + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); + $truthyType = $truthyScope->getType($if); + $falseyType = $falsyScope->getType($assignedExpr->else); + + if ( + $truthyType->isSuperTypeOf($falseyType)->no() + && $falseyType->isSuperTypeOf($truthyType)->no() + ) { + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + } + } + + $scope = $result->getScope(); + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + + $truthyType = TypeCombinator::removeFalsey($type); + $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); + + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + + $nodeCallback(new VariableAssignNode($var, $assignedExpr, $isAssignOp), $result->getScope()); + $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); + foreach ($conditionalExpressions as $exprString => $holders) { + $scope = $scope->addConditionalExpressions($exprString, $holders); + } + } elseif ($var instanceof ArrayDimFetch) { + $dimFetchStack = []; + $originalVar = $var; + $assignedPropertyExpr = $assignedExpr; + while ($var instanceof ArrayDimFetch) { + $varForSetOffsetValue = $var->var; + if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { + $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + } + $assignedPropertyExpr = new SetOffsetValueTypeExpr( + $varForSetOffsetValue, + $var->dim, + $assignedPropertyExpr, + ); + $dimFetchStack[] = $var; + $var = $var->var; + } + + // 1. eval root expr + if ($enterExpressionAssign) { + $scope = $scope->enterExpressionAssign($var); + } + $result = $this->processExprNode($stmt, $var, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $result->hasYield(); + $throwPoints = $result->getThrowPoints(); + $impurePoints = $result->getImpurePoints(); + $scope = $result->getScope(); + if ($enterExpressionAssign) { + $scope = $scope->exitExpressionAssign($var); + } + + // 2. eval dimensions + $offsetTypes = []; + $offsetNativeTypes = []; + $dimFetchStack = array_reverse($dimFetchStack); + $lastDimKey = array_key_last($dimFetchStack); + foreach ($dimFetchStack as $key => $dimFetch) { + $dimExpr = $dimFetch->dim; + + // Callback was already called for last dim at the beginning of the method. + if ($key !== $lastDimKey) { + $nodeCallback($dimFetch, $enterExpressionAssign ? $scope->enterExpressionAssign($dimFetch) : $scope); + } + + if ($dimExpr === null) { + $offsetTypes[] = null; + $offsetNativeTypes[] = null; + + } else { + $offsetTypes[] = $scope->getType($dimExpr); + $offsetNativeTypes[] = $scope->getNativeType($dimExpr); + + if ($enterExpressionAssign) { + $scope->enterExpressionAssign($dimExpr); + } + $result = $this->processExprNode($stmt, $dimExpr, $scope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $scope = $result->getScope(); + + if ($enterExpressionAssign) { + $scope = $scope->exitExpressionAssign($dimExpr); + } + } + } + + $valueToWrite = $scope->getType($assignedExpr); + $nativeValueToWrite = $scope->getNativeType($assignedExpr); + $originalValueToWrite = $valueToWrite; + $originalNativeValueToWrite = $nativeValueToWrite; + + // 3. eval assigned expr + $result = $processExprCallback($scope); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + + $varType = $scope->getType($var); + $varNativeType = $scope->getNativeType($var); + + // 4. compose types + if ($varType instanceof ErrorType) { + $varType = new ConstantArrayType([], []); + } + if ($varNativeType instanceof ErrorType) { + $varNativeType = new ConstantArrayType([], []); + } + $offsetValueType = $varType; + $offsetNativeValueType = $varNativeType; + + $valueToWrite = $this->produceArrayDimFetchAssignValueToWrite($offsetTypes, $offsetValueType, $valueToWrite); + + if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) { + $nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite); + } else { + $rewritten = false; + foreach ($offsetTypes as $i => $offsetType) { + $offsetNativeType = $offsetNativeTypes[$i]; + if ($offsetType === null) { + if ($offsetNativeType !== null) { + throw new ShouldNotHappenException(); + } + + continue; + } elseif ($offsetNativeType === null) { + throw new ShouldNotHappenException(); + } + if ($offsetType->equals($offsetNativeType)) { + continue; + } + + $nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite); + $rewritten = true; + break; + } + + if (!$rewritten) { + $nativeValueToWrite = $valueToWrite; + } + } + + if ($varType->isArray()->yes() || !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->yes()) { + if ($var instanceof Variable && is_string($var->name)) { + $nodeCallback(new VariableAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + $scope = $scope->assignVariable($var->name, $valueToWrite, $nativeValueToWrite, TrinaryLogic::createYes()); + } else { + if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { + $nodeCallback(new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { + $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + } + } + $scope = $scope->assignExpression( + $var, + $valueToWrite, + $nativeValueToWrite, + ); + } + + if ($originalVar->dim instanceof Variable || $originalVar->dim instanceof Node\Scalar) { + $currentVarType = $scope->getType($originalVar); + if (!$originalValueToWrite->isSuperTypeOf($currentVarType)->yes()) { + $scope = $scope->assignExpression( + $originalVar, + $originalValueToWrite, + $originalNativeValueToWrite, + ); + } + } + } else { + if ($var instanceof Variable) { + $nodeCallback(new VariableAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + } elseif ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { + $nodeCallback(new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { + $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + } + } + } + + if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { + $throwPoints = array_merge($throwPoints, $this->processExprNode( + $stmt, + new MethodCall($var, 'offsetSet'), + $scope, + static function (): void { + }, + $context, + )->getThrowPoints()); + } + } elseif ($var instanceof PropertyFetch) { + $objectResult = $this->processExprNode($stmt, $var->var, $scope, $nodeCallback, $context); + $hasYield = $objectResult->hasYield(); + $throwPoints = $objectResult->getThrowPoints(); + $impurePoints = $objectResult->getImpurePoints(); + $scope = $objectResult->getScope(); + + $propertyName = null; + if ($var->name instanceof Node\Identifier) { + $propertyName = $var->name->name; + } else { + $propertyNameResult = $this->processExprNode($stmt, $var->name, $scope, $nodeCallback, $context); + $hasYield = $hasYield || $propertyNameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $propertyNameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $propertyNameResult->getImpurePoints()); + $scope = $propertyNameResult->getScope(); + } + + $result = $processExprCallback($scope); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + + if ($var->name instanceof Expr && $this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = ThrowPoint::createImplicit($scope, $var); + } + + $propertyHolderType = $scope->getType($var->var); + if ($propertyName !== null && $propertyHolderType->hasProperty($propertyName)->yes()) { + $propertyReflection = $propertyHolderType->getProperty($propertyName, $scope); + $assignedExprType = $scope->getType($assignedExpr); + $nodeCallback(new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scope); + if ($propertyReflection->canChangeTypeAfterAssignment()) { + if ($propertyReflection->hasNativeType() && $scope->isDeclareStrictTypes()) { + $propertyNativeType = $propertyReflection->getNativeType(); + + $scope = $scope->assignExpression($var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType(true), $propertyNativeType), TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType(true), $propertyNativeType)); + } else { + $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + } + } + $declaringClass = $propertyReflection->getDeclaringClass(); + if ($declaringClass->hasNativeProperty($propertyName)) { + $nativeProperty = $declaringClass->getNativeProperty($propertyName); + if ( + !$nativeProperty->getNativeType()->accepts($assignedExprType, true)->yes() + ) { + $throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $assignedExpr, false); + } + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints = array_merge($throwPoints, $this->getPropertyAssignThrowPointsFromSetHook($scope, $var, $nativeProperty)); + } + if ($enterExpressionAssign) { + $scope = $scope->assignInitializedProperty($propertyHolderType, $propertyName); + } + } + } else { + // fallback + $assignedExprType = $scope->getType($assignedExpr); + $nodeCallback(new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scope); + $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + // simulate dynamic property assign by __set to get throw points + if (!$propertyHolderType->hasMethod('__set')->no()) { + $throwPoints = array_merge($throwPoints, $this->processExprNode( + $stmt, + new MethodCall($var->var, '__set'), + $scope, + static function (): void { + }, + $context, + )->getThrowPoints()); + } + } + + } elseif ($var instanceof Expr\StaticPropertyFetch) { + if ($var->class instanceof Node\Name) { + $propertyHolderType = $scope->resolveTypeByName($var->class); + } else { + $this->processExprNode($stmt, $var->class, $scope, $nodeCallback, $context); + $propertyHolderType = $scope->getType($var->class); + } + + $propertyName = null; + if ($var->name instanceof Node\Identifier) { + $propertyName = $var->name->name; + } else { + $propertyNameResult = $this->processExprNode($stmt, $var->name, $scope, $nodeCallback, $context); + $hasYield = $propertyNameResult->hasYield(); + $throwPoints = $propertyNameResult->getThrowPoints(); + $impurePoints = $propertyNameResult->getImpurePoints(); + $scope = $propertyNameResult->getScope(); + } + + $result = $processExprCallback($scope); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + + if ($propertyName !== null) { + $propertyReflection = $scope->getPropertyReflection($propertyHolderType, $propertyName); + $assignedExprType = $scope->getType($assignedExpr); + $nodeCallback(new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scope); + if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { + if ($propertyReflection->hasNativeType() && $scope->isDeclareStrictTypes()) { + $propertyNativeType = $propertyReflection->getNativeType(); + + $scope = $scope->assignExpression($var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType(true), $propertyNativeType), TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType(true), $propertyNativeType)); + } else { + $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + } + } + } else { + // fallback + $assignedExprType = $scope->getType($assignedExpr); + $nodeCallback(new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scope); + $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + } + } elseif ($var instanceof List_) { + $result = $processExprCallback($scope); + $hasYield = $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $scope = $result->getScope(); + foreach ($var->items as $i => $arrayItem) { + if ($arrayItem === null) { + continue; + } + + $itemScope = $scope; + if ($enterExpressionAssign) { + $itemScope = $itemScope->enterExpressionAssign($arrayItem->value); + } + $itemScope = $this->lookForSetAllowedUndefinedExpressions($itemScope, $arrayItem->value); + $nodeCallback($arrayItem, $itemScope); + if ($arrayItem->key !== null) { + $keyResult = $this->processExprNode($stmt, $arrayItem->key, $itemScope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $keyResult->hasYield(); + $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); + $itemScope = $keyResult->getScope(); + } + + $valueResult = $this->processExprNode($stmt, $arrayItem->value, $itemScope, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $valueResult->hasYield(); + $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); + + if ($arrayItem->key === null) { + $dimExpr = new Node\Scalar\Int_($i); + } else { + $dimExpr = $arrayItem->key; + } + $result = $this->processAssignVar( + $scope, + $stmt, + $arrayItem->value, + new GetOffsetValueTypeExpr($assignedExpr, $dimExpr), + $nodeCallback, + $context, + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + $enterExpressionAssign, + ); + $scope = $result->getScope(); + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + } + } elseif ($var instanceof ExistingArrayDimFetch) { + $dimFetchStack = []; + $assignedPropertyExpr = $assignedExpr; + while ($var instanceof ExistingArrayDimFetch) { + $varForSetOffsetValue = $var->getVar(); + if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { + $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + } + $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( + $varForSetOffsetValue, + $var->getDim(), + $assignedPropertyExpr, + ); + $dimFetchStack[] = $var; + $var = $var->getVar(); + } + + $offsetTypes = []; + $offsetNativeTypes = []; + foreach (array_reverse($dimFetchStack) as $dimFetch) { + $dimExpr = $dimFetch->getDim(); + $offsetTypes[] = $scope->getType($dimExpr); + $offsetNativeTypes[] = $scope->getNativeType($dimExpr); + } + + $valueToWrite = $scope->getType($assignedExpr); + $nativeValueToWrite = $scope->getNativeType($assignedExpr); + $varType = $scope->getType($var); + $varNativeType = $scope->getNativeType($var); + + $offsetValueType = $varType; + $offsetNativeValueType = $varNativeType; + $offsetValueTypeStack = [$offsetValueType]; + $offsetValueNativeTypeStack = [$offsetNativeValueType]; + foreach (array_slice($offsetTypes, 0, -1) as $offsetType) { + $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); + $offsetValueTypeStack[] = $offsetValueType; + } + foreach (array_slice($offsetNativeTypes, 0, -1) as $offsetNativeType) { + $offsetNativeValueType = $offsetNativeValueType->getOffsetValueType($offsetNativeType); + $offsetValueNativeTypeStack[] = $offsetNativeValueType; + } + + foreach (array_reverse($offsetTypes) as $offsetType) { + /** @var Type $offsetValueType */ + $offsetValueType = array_pop($offsetValueTypeStack); + $valueToWrite = $offsetValueType->setExistingOffsetValueType($offsetType, $valueToWrite); + } + foreach (array_reverse($offsetNativeTypes) as $offsetNativeType) { + /** @var Type $offsetNativeValueType */ + $offsetNativeValueType = array_pop($offsetValueNativeTypeStack); + $nativeValueToWrite = $offsetNativeValueType->setExistingOffsetValueType($offsetNativeType, $nativeValueToWrite); + } + + if ($var instanceof Variable && is_string($var->name)) { + $nodeCallback(new VariableAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + $scope = $scope->assignVariable($var->name, $valueToWrite, $nativeValueToWrite, TrinaryLogic::createYes()); + } else { + if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { + $nodeCallback(new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + } + $scope = $scope->assignExpression( + $var, + $valueToWrite, + $nativeValueToWrite, + ); + } + } + + return new ExpressionResult($scope, $hasYield, $throwPoints, $impurePoints); + } + + /** + * @param list $offsetTypes + */ + private function produceArrayDimFetchAssignValueToWrite(array $offsetTypes, Type $offsetValueType, Type $valueToWrite): Type + { + $offsetValueTypeStack = [$offsetValueType]; + foreach (array_slice($offsetTypes, 0, -1) as $offsetType) { + if ($offsetType === null) { + $offsetValueType = new ConstantArrayType([], []); + + } else { + $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); + if ($offsetValueType instanceof ErrorType) { + $offsetValueType = new ConstantArrayType([], []); + } + } + + $offsetValueTypeStack[] = $offsetValueType; + } + + foreach (array_reverse($offsetTypes) as $i => $offsetType) { + /** @var Type $offsetValueType */ + $offsetValueType = array_pop($offsetValueTypeStack); + if ( + !$offsetValueType instanceof MixedType + && !$offsetValueType->isConstantArray()->yes() + ) { + $types = [ + new ArrayType(new MixedType(), new MixedType()), + new ObjectType(ArrayAccess::class), + new NullType(), + ]; + if ($offsetType !== null && $offsetType->isInteger()->yes()) { + $types[] = new StringType(); + } + $offsetValueType = TypeCombinator::intersect($offsetValueType, TypeCombinator::union(...$types)); + } + $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $i === 0); + } + + return $valueToWrite; + } + + private function unwrapAssign(Expr $expr): Expr + { + if ($expr instanceof Assign) { + return $this->unwrapAssign($expr->expr); + } + + return $expr; + } + + /** + * @param array $conditionalExpressions + * @return array + */ + private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType): array + { + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { + if (!$expr instanceof Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + if ($expr->name === $variableName) { + continue; + } + + if (!isset($conditionalExpressions[$exprString])) { + $conditionalExpressions[$exprString] = []; + } + + $holder = new ConditionalExpressionHolder([ + '$' . $variableName => ExpressionTypeHolder::createYes(new Variable($variableName), $variableType), + ], ExpressionTypeHolder::createYes( + $expr, + TypeCombinator::intersect($scope->getType($expr), $exprType), + )); + $conditionalExpressions[$exprString][$holder->getKey()] = $holder; + } + + return $conditionalExpressions; + } + + /** + * @param array $conditionalExpressions + * @return array + */ + private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType): array + { + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { + if (!$expr instanceof Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + if ($expr->name === $variableName) { + continue; + } + + if (!isset($conditionalExpressions[$exprString])) { + $conditionalExpressions[$exprString] = []; + } + + $holder = new ConditionalExpressionHolder([ + '$' . $variableName => ExpressionTypeHolder::createYes(new Variable($variableName), $variableType), + ], ExpressionTypeHolder::createYes( + $expr, + TypeCombinator::remove($scope->getType($expr), $exprType), + )); + $conditionalExpressions[$exprString][$holder->getKey()] = $holder; + } + + return $conditionalExpressions; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processStmtVarAnnotation(MutatingScope $scope, Node\Stmt $stmt, ?Expr $defaultExpr, callable $nodeCallback): MutatingScope + { + $function = $scope->getFunction(); + $variableLessTags = []; + + foreach ($stmt->getComments() as $comment) { + if (!$comment instanceof Doc) { + continue; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $comment->getText(), + ); + + $assignedVariable = null; + if ( + $stmt instanceof Node\Stmt\Expression + && ($stmt->expr instanceof Assign || $stmt->expr instanceof AssignRef) + && $stmt->expr->var instanceof Variable + && is_string($stmt->expr->var->name) + ) { + $assignedVariable = $stmt->expr->var->name; + } + + foreach ($resolvedPhpDoc->getVarTags() as $name => $varTag) { + if (is_int($name)) { + $variableLessTags[] = $varTag; + continue; + } + + if ($name === $assignedVariable) { + continue; + } + + $certainty = $scope->hasVariableType($name); + if ($certainty->no()) { + continue; + } + + if ($scope->isInClass() && $scope->getFunction() === null) { + continue; + } + + if ($scope->canAnyVariableExist()) { + $certainty = TrinaryLogic::createYes(); + } + + $variableNode = new Variable($name, $stmt->getAttributes()); + $originalType = $scope->getVariableType($name); + if (!$originalType->equals($varTag->getType())) { + $nodeCallback(new VarTagChangedExpressionTypeNode($varTag, $variableNode), $scope); + } + + $scope = $scope->assignVariable( + $name, + $varTag->getType(), + $scope->getNativeType($variableNode), + $certainty, + ); + } + } + + if (count($variableLessTags) === 1 && $defaultExpr !== null) { + $originalType = $scope->getType($defaultExpr); + $varTag = $variableLessTags[0]; + if (!$originalType->equals($varTag->getType())) { + $nodeCallback(new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope); + } + $scope = $scope->assignExpression($defaultExpr, $varTag->getType(), new MixedType()); + } + + return $scope; + } + + /** + * @param array $variableNames + */ + private function processVarAnnotation(MutatingScope $scope, array $variableNames, Node\Stmt $node, bool &$changed = false): MutatingScope + { + $function = $scope->getFunction(); + $varTags = []; + foreach ($node->getComments() as $comment) { + if (!$comment instanceof Doc) { + continue; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $comment->getText(), + ); + foreach ($resolvedPhpDoc->getVarTags() as $key => $varTag) { + $varTags[$key] = $varTag; + } + } + + if (count($varTags) === 0) { + return $scope; + } + + foreach ($variableNames as $variableName) { + if (!isset($varTags[$variableName])) { + continue; + } + + $variableType = $varTags[$variableName]->getType(); + $changed = true; + $scope = $scope->assignVariable($variableName, $variableType, new MixedType(), TrinaryLogic::createYes()); + } + + if (count($variableNames) === 1 && count($varTags) === 1 && isset($varTags[0])) { + $variableType = $varTags[0]->getType(); + $changed = true; + $scope = $scope->assignVariable($variableNames[0], $variableType, new MixedType(), TrinaryLogic::createYes()); + } + + return $scope; + } + + private function enterForeach(MutatingScope $scope, MutatingScope $originalScope, Foreach_ $stmt): MutatingScope + { + if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { + $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); + } + $iterateeType = $originalScope->getType($stmt->expr); + if ( + ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) + && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) + ) { + $keyVarName = null; + if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) { + $keyVarName = $stmt->keyVar->name; + } + $scope = $scope->enterForeach( + $originalScope, + $stmt->expr, + $stmt->valueVar->name, + $keyVarName, + ); + $vars = [$stmt->valueVar->name]; + if ($keyVarName !== null) { + $vars[] = $keyVarName; + } + } else { + $scope = $this->processAssignVar( + $scope, + $stmt, + $stmt->valueVar, + new GetIterableValueTypeExpr($stmt->expr), + static function (): void { + }, + ExpressionContext::createDeep(), + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + true, + )->getScope(); + $vars = $this->getAssignedVariables($stmt->valueVar); + if ( + $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) + ) { + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $stmt->keyVar->name); + $vars[] = $stmt->keyVar->name; + } elseif ($stmt->keyVar !== null) { + $scope = $this->processAssignVar( + $scope, + $stmt, + $stmt->keyVar, + new GetIterableKeyTypeExpr($stmt->expr), + static function (): void { + }, + ExpressionContext::createDeep(), + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, false, [], []), + true, + )->getScope(); + $vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar)); + } + } + + $constantArrays = $iterateeType->getConstantArrays(); + if ( + $stmt->getDocComment() === null + && $iterateeType->isConstantArray()->yes() + && count($constantArrays) === 1 + && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) + && $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) + ) { + $valueConditionalHolders = []; + $arrayDimFetchConditionalHolders = []; + foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { + $valueType = $constantArrays[0]->getValueTypes()[$i]; + $holder = new ConditionalExpressionHolder([ + '$' . $stmt->keyVar->name => ExpressionTypeHolder::createYes(new Variable($stmt->keyVar->name), $keyType), + ], new ExpressionTypeHolder($stmt->valueVar, $valueType, TrinaryLogic::createYes())); + $valueConditionalHolders[$holder->getKey()] = $holder; + $arrayDimFetchHolder = new ConditionalExpressionHolder([ + '$' . $stmt->keyVar->name => ExpressionTypeHolder::createYes(new Variable($stmt->keyVar->name), $keyType), + ], new ExpressionTypeHolder(new ArrayDimFetch($stmt->expr, $stmt->keyVar), $valueType, TrinaryLogic::createYes())); + $arrayDimFetchConditionalHolders[$arrayDimFetchHolder->getKey()] = $arrayDimFetchHolder; + } + + $scope = $scope->addConditionalExpressions( + '$' . $stmt->valueVar->name, + $valueConditionalHolders, + ); + if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { + $scope = $scope->addConditionalExpressions( + sprintf('$%s[$%s]', $stmt->expr->name, $stmt->keyVar->name), + $arrayDimFetchConditionalHolders, + ); + } + } + + return $this->processVarAnnotation($scope, $vars, $stmt); + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processTraitUse(Node\Stmt\TraitUse $node, MutatingScope $classScope, callable $nodeCallback): void + { + $parentTraitNames = []; + $parent = $classScope->getParentScope(); + while ($parent !== null) { + if ($parent->isInTrait()) { + $parentTraitNames[] = $parent->getTraitReflection()->getName(); + } + $parent = $parent->getParentScope(); + } + + foreach ($node->traits as $trait) { + $traitName = (string) $trait; + if (in_array($traitName, $parentTraitNames, true)) { + continue; + } + if (!$this->reflectionProvider->hasClass($traitName)) { + continue; + } + $traitReflection = $this->reflectionProvider->getClass($traitName); + $traitFileName = $traitReflection->getFileName(); + if ($traitFileName === null) { + continue; // trait from eval or from PHP itself + } + $fileName = $this->fileHelper->normalizePath($traitFileName); + if (!isset($this->analysedFiles[$fileName])) { + continue; + } + $adaptations = []; + foreach ($node->adaptations as $adaptation) { + if ($adaptation->trait === null) { + $adaptations[] = $adaptation; + continue; + } + if ($adaptation->trait->toLowerString() !== $trait->toLowerString()) { + continue; + } + + $adaptations[] = $adaptation; + } + $parserNodes = $this->parser->parseFile($fileName); + $this->processNodesForTraitUse($parserNodes, $traitReflection, $classScope, $adaptations, $nodeCallback); + } + } + + /** + * @param Node[]|Node|scalar|null $node + * @param Node\Stmt\TraitUseAdaptation[] $adaptations + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processNodesForTraitUse($node, ClassReflection $traitReflection, MutatingScope $scope, array $adaptations, callable $nodeCallback): void + { + if ($node instanceof Node) { + if ($node instanceof Node\Stmt\Trait_ && $traitReflection->getName() === (string) $node->namespacedName && $traitReflection->getNativeReflection()->getStartLine() === $node->getStartLine()) { + $methodModifiers = []; + $methodNames = []; + foreach ($adaptations as $adaptation) { + if (!$adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { + continue; + } + + $methodName = $adaptation->method->toLowerString(); + if ($adaptation->newModifier !== null) { + $methodModifiers[$methodName] = $adaptation->newModifier; + } + + if ($adaptation->newName === null) { + continue; + } + + $methodNames[$methodName] = $adaptation->newName; + } + + $stmts = $node->stmts; + foreach ($stmts as $i => $stmt) { + if (!$stmt instanceof Node\Stmt\ClassMethod) { + continue; + } + $methodName = $stmt->name->toLowerString(); + $methodAst = clone $stmt; + $stmts[$i] = $methodAst; + if (array_key_exists($methodName, $methodModifiers)) { + $methodAst->flags = ($methodAst->flags & ~ Modifiers::VISIBILITY_MASK) | $methodModifiers[$methodName]; + } + + if (!array_key_exists($methodName, $methodNames)) { + continue; + } + + $methodAst->setAttribute('originalTraitMethodName', $methodAst->name->toLowerString()); + $methodAst->name = $methodNames[$methodName]; + } + + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + $traitScope = $scope->enterTrait($traitReflection); + $nodeCallback(new InTraitNode($node, $traitReflection, $scope->getClassReflection()), $traitScope); + $this->processStmtNodes($node, $stmts, $traitScope, $nodeCallback, StatementContext::createTopLevel()); + return; + } + if ($node instanceof Node\Stmt\ClassLike) { + return; + } + if ($node instanceof Node\FunctionLike) { + return; + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->{$subNodeName}; + $this->processNodesForTraitUse($subNode, $traitReflection, $scope, $adaptations, $nodeCallback); + } + } elseif (is_array($node)) { + foreach ($node as $subNode) { + $this->processNodesForTraitUse($subNode, $traitReflection, $scope, $adaptations, $nodeCallback); + } + } + } + + private function processCalledMethod(MethodReflection $methodReflection): ?MutatingScope + { + $declaringClass = $methodReflection->getDeclaringClass(); + if ($declaringClass->isAnonymous()) { + return null; + } + if ($declaringClass->getFileName() === null) { + return null; + } + + $stackName = sprintf('%s::%s', $declaringClass->getName(), $methodReflection->getName()); + if (array_key_exists($stackName, $this->calledMethodResults)) { + return $this->calledMethodResults[$stackName]; + } + + if (array_key_exists($stackName, $this->calledMethodStack)) { + return null; + } + + if (count($this->calledMethodStack) > 0) { + return null; + } + + $this->calledMethodStack[$stackName] = true; + + $fileName = $this->fileHelper->normalizePath($declaringClass->getFileName()); + if (!isset($this->analysedFiles[$fileName])) { + return null; + } + $parserNodes = $this->parser->parseFile($fileName); + + $returnStatement = null; + $this->processNodesForCalledMethod($parserNodes, $fileName, $methodReflection, static function (Node $node, Scope $scope) use ($methodReflection, &$returnStatement): void { + if (!$node instanceof MethodReturnStatementsNode) { + return; + } + + if ($node->getClassReflection()->getName() !== $methodReflection->getDeclaringClass()->getName()) { + return; + } + + if ($returnStatement !== null) { + return; + } + + $returnStatement = $node; + }); + + $calledMethodEndScope = null; + if ($returnStatement !== null) { + foreach ($returnStatement->getExecutionEnds() as $executionEnd) { + $statementResult = $executionEnd->getStatementResult(); + $endNode = $executionEnd->getNode(); + if ($endNode instanceof Node\Stmt\Expression) { + $exprType = $statementResult->getScope()->getType($endNode->expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + continue; + } + } + if ($calledMethodEndScope === null) { + $calledMethodEndScope = $statementResult->getScope(); + continue; + } + + $calledMethodEndScope = $calledMethodEndScope->mergeWith($statementResult->getScope()); + } + foreach ($returnStatement->getReturnStatements() as $statement) { + if ($calledMethodEndScope === null) { + $calledMethodEndScope = $statement->getScope(); + continue; + } + + $calledMethodEndScope = $calledMethodEndScope->mergeWith($statement->getScope()); + } + } + + unset($this->calledMethodStack[$stackName]); + + $this->calledMethodResults[$stackName] = $calledMethodEndScope; + + return $calledMethodEndScope; + } + + /** + * @param Node[]|Node|scalar|null $node + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processNodesForCalledMethod($node, string $fileName, MethodReflection $methodReflection, callable $nodeCallback): void + { + if ($node instanceof Node) { + $declaringClass = $methodReflection->getDeclaringClass(); + if ( + $node instanceof Node\Stmt\Class_ + && $node->namespacedName !== null + && $declaringClass->getName() === (string) $node->namespacedName + && $declaringClass->getNativeReflection()->getStartLine() === $node->getStartLine() + ) { + + $stmts = $node->stmts; + foreach ($stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\ClassMethod) { + continue; + } + + if ($stmt->name->toString() !== $methodReflection->getName()) { + continue; + } + + if ($stmt->getEndLine() - $stmt->getStartLine() > 50) { + continue; + } + + $scope = $this->scopeFactory->create(ScopeContext::create($fileName))->enterClass($declaringClass); + $this->processStmtNode($stmt, $scope, $nodeCallback, StatementContext::createTopLevel()); + } + return; + } + if ($node instanceof Node\Stmt\ClassLike) { + return; + } + if ($node instanceof Node\FunctionLike) { + return; + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->{$subNodeName}; + $this->processNodesForCalledMethod($subNode, $fileName, $methodReflection, $nodeCallback); + } + } elseif (is_array($node)) { + foreach ($node as $subNode) { + $this->processNodesForCalledMethod($subNode, $fileName, $methodReflection, $nodeCallback); + } + } + } + + /** + * @return array{TemplateTypeMap, array, array, array, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array, array<(string|int), VarTag>, bool} + */ + public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array + { + $templateTypeMap = TemplateTypeMap::createEmpty(); + $phpDocParameterTypes = []; + $phpDocImmediatelyInvokedCallableParameters = []; + $phpDocClosureThisTypeParameters = []; + $phpDocReturnType = null; + $phpDocThrowType = null; + $deprecatedDescription = null; + $isDeprecated = false; + $isInternal = false; + $isFinal = false; + $isPure = null; + $isAllowedPrivateMutation = false; + $acceptsNamedArguments = true; + $isReadOnly = $scope->isInClass() && $scope->getClassReflection()->isImmutable(); + $asserts = Assertions::createEmpty(); + $selfOutType = null; + $docComment = $node->getDocComment() !== null + ? $node->getDocComment()->getText() + : null; + + $file = $scope->getFile(); + $class = $scope->isInClass() ? $scope->getClassReflection()->getName() : null; + $trait = $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null; + $resolvedPhpDoc = null; + $functionName = null; + $phpDocParameterOutTypes = []; + + if ($node instanceof Node\Stmt\ClassMethod) { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + $functionName = $node->name->name; + $positionalParameterNames = array_map(static function (Node\Param $param): string { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + return $param->var->name; + }, $node->getParams()); + $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForMethod( + $docComment, + $file, + $scope->getClassReflection(), + $trait, + $node->name->name, + $positionalParameterNames, + ); + + if ($node->name->toLowerString() === '__construct') { + foreach ($node->params as $param) { + if ($param->flags === 0) { + continue; + } + + if ($param->getDocComment() === null) { + continue; + } + + if ( + !$param->var instanceof Variable + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $paramPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $class, + $trait, + '__construct', + $param->getDocComment()->getText(), + ); + $varTags = $paramPhpDoc->getVarTags(); + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } elseif (isset($varTags[$param->var->name])) { + $phpDocType = $varTags[$param->var->name]->getType(); + } else { + continue; + } + + $phpDocParameterTypes[$param->var->name] = $phpDocType; + } + } + } elseif ($node instanceof Node\Stmt\Function_) { + $functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + $functionName = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } + } + + if ($docComment !== null && $resolvedPhpDoc === null) { + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $class, + $trait, + $functionName, + $docComment, + ); + } + + $varTags = []; + if ($resolvedPhpDoc !== null) { + $templateTypeMap = $resolvedPhpDoc->getTemplateTypeMap(); + $phpDocImmediatelyInvokedCallableParameters = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); + foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { + if (array_key_exists($paramName, $phpDocParameterTypes)) { + continue; + } + $paramType = $paramTag->getType(); + if ($scope->isInClass()) { + $paramType = $this->transformStaticType($scope->getClassReflection(), $paramType); + } + $phpDocParameterTypes[$paramName] = $paramType; + } + foreach ($resolvedPhpDoc->getParamClosureThisTags() as $paramName => $paramClosureThisTag) { + if (array_key_exists($paramName, $phpDocClosureThisTypeParameters)) { + continue; + } + $paramClosureThisType = $paramClosureThisTag->getType(); + if ($scope->isInClass()) { + $paramClosureThisType = $this->transformStaticType($scope->getClassReflection(), $paramClosureThisType); + } + $phpDocClosureThisTypeParameters[$paramName] = $paramClosureThisType; + } + + foreach ($resolvedPhpDoc->getParamOutTags() as $paramName => $paramOutTag) { + $phpDocParameterOutTypes[$paramName] = $paramOutTag->getType(); + } + if ($node instanceof Node\FunctionLike) { + $nativeReturnType = $scope->getFunctionType($node->getReturnType(), false, false); + $phpDocReturnType = $this->getPhpDocReturnType($resolvedPhpDoc, $nativeReturnType); + if ($phpDocReturnType !== null && $scope->isInClass()) { + $phpDocReturnType = $this->transformStaticType($scope->getClassReflection(), $phpDocReturnType); + } + } + $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; + $deprecatedDescription = $resolvedPhpDoc->getDeprecatedTag() !== null ? $resolvedPhpDoc->getDeprecatedTag()->getMessage() : null; + $isDeprecated = $resolvedPhpDoc->isDeprecated(); + $isInternal = $resolvedPhpDoc->isInternal(); + $isFinal = $resolvedPhpDoc->isFinal(); + $isPure = $resolvedPhpDoc->isPure(); + $isAllowedPrivateMutation = $resolvedPhpDoc->isAllowedPrivateMutation(); + $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); + if ($acceptsNamedArguments && $scope->isInClass()) { + $acceptsNamedArguments = $scope->getClassReflection()->acceptsNamedArguments(); + } + $isReadOnly = $isReadOnly || $resolvedPhpDoc->isReadOnly(); + $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); + $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; + $varTags = $resolvedPhpDoc->getVarTags(); + } + + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation]; + } + + private function transformStaticType(ClassReflection $declaringClass, Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($declaringClass): Type { + if ($type instanceof StaticType) { + $changedType = $type->changeBaseClass($declaringClass); + if ($declaringClass->isFinal() && !$type instanceof ThisType) { + $changedType = $changedType->getStaticObjectType(); + } + return $traverse($changedType); + } + + return $traverse($type); + }); + } + + private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $nativeReturnType): ?Type + { + $returnTag = $resolvedPhpDoc->getReturnTag(); + + if ($returnTag === null) { + return null; + } + + $phpDocReturnType = $returnTag->getType(); + + if ($returnTag->isExplicit()) { + return $phpDocReturnType; + } + + if ($nativeReturnType->isSuperTypeOf(TemplateTypeHelper::resolveToBounds($phpDocReturnType))->yes()) { + return $phpDocReturnType; + } + + return null; + } + + /** + * @param array $nodes + * @return list + */ + private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): array + { + $stmts = []; + $isPassedUnreachableStatement = false; + foreach ($nodes as $node) { + if ($earlyBinding && ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\HaltCompiler)) { + continue; + } + if ($isPassedUnreachableStatement && $node instanceof Node\Stmt) { + $stmts[] = $node; + continue; + } + if ($node instanceof Node\Stmt\Nop) { + continue; + } + if (!$node instanceof Node\Stmt) { + continue; + } + $stmts[] = $node; + $isPassedUnreachableStatement = true; + } + return $stmts; + } + +} diff --git a/src/Analyser/NullsafeOperatorHelper.php b/src/Analyser/NullsafeOperatorHelper.php new file mode 100644 index 00000000..31609519 --- /dev/null +++ b/src/Analyser/NullsafeOperatorHelper.php @@ -0,0 +1,84 @@ +getType($expr))) { + // We're in most likely in context of a null-safe operator ($scope->moreSpecificType is defined for $expr) + // Modifying the expression would not bring any value or worse ruin the context information + return $expr; + } + + return self::getNullsafeShortcircuitedExpr($expr); + } + + /** + * @internal Use NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope + */ + public static function getNullsafeShortcircuitedExpr(Expr $expr): Expr + { + if ($expr instanceof Expr\NullsafeMethodCall) { + return new Expr\MethodCall(self::getNullsafeShortcircuitedExpr($expr->var), $expr->name, $expr->args); + } + + if ($expr instanceof Expr\MethodCall) { + $var = self::getNullsafeShortcircuitedExpr($expr->var); + if ($expr->var === $var) { + return $expr; + } + + return new Expr\MethodCall($var, $expr->name, $expr->getArgs()); + } + + if ($expr instanceof Expr\StaticCall && $expr->class instanceof Expr) { + $class = self::getNullsafeShortcircuitedExpr($expr->class); + if ($expr->class === $class) { + return $expr; + } + + return new Expr\StaticCall($class, $expr->name, $expr->getArgs()); + } + + if ($expr instanceof Expr\ArrayDimFetch) { + $var = self::getNullsafeShortcircuitedExpr($expr->var); + if ($expr->var === $var) { + return $expr; + } + + return new Expr\ArrayDimFetch($var, $expr->dim); + } + + if ($expr instanceof Expr\NullsafePropertyFetch) { + return new Expr\PropertyFetch(self::getNullsafeShortcircuitedExpr($expr->var), $expr->name); + } + + if ($expr instanceof Expr\PropertyFetch) { + $var = self::getNullsafeShortcircuitedExpr($expr->var); + if ($expr->var === $var) { + return $expr; + } + + return new Expr\PropertyFetch($var, $expr->name); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + $class = self::getNullsafeShortcircuitedExpr($expr->class); + if ($expr->class === $class) { + return $expr; + } + + return new Expr\StaticPropertyFetch($class, $expr->name); + } + + return $expr; + } + +} diff --git a/src/Analyser/OutOfClassScope.php b/src/Analyser/OutOfClassScope.php new file mode 100644 index 00000000..cc9dbd87 --- /dev/null +++ b/src/Analyser/OutOfClassScope.php @@ -0,0 +1,58 @@ +isPublic(); + } + + public function canReadProperty(ExtendedPropertyReflection $propertyReflection): bool + { + return $propertyReflection->isPublic(); + } + + public function canWriteProperty(ExtendedPropertyReflection $propertyReflection): bool + { + return $propertyReflection->isPublic() + && !$propertyReflection->isProtectedSet() + && !$propertyReflection->isPrivateSet(); + } + + public function canCallMethod(MethodReflection $methodReflection): bool + { + return $methodReflection->isPublic(); + } + + public function canAccessConstant(ClassConstantReflection $constantReflection): bool + { + return $constantReflection->isPublic(); + } + +} diff --git a/src/Analyser/ProcessClosureResult.php b/src/Analyser/ProcessClosureResult.php new file mode 100644 index 00000000..8d4e0ec3 --- /dev/null +++ b/src/Analyser/ProcessClosureResult.php @@ -0,0 +1,54 @@ +scope; + } + + /** + * @return ThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + /** + * @return InvalidateExprNode[] + */ + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + +} diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php new file mode 100644 index 00000000..3d2c5e5f --- /dev/null +++ b/src/Analyser/ResultCache/ResultCache.php @@ -0,0 +1,136 @@ +> $errors + * @param array> $locallyIgnoredErrors + * @param array $linesToIgnore + * @param array $unmatchedLineIgnores + * @param array> $collectedData + * @param array> $dependencies + * @param array> $exportedNodes + * @param array $projectExtensionFiles + */ + public function __construct( + private array $filesToAnalyse, + private bool $fullAnalysis, + private int $lastFullAnalysisTime, + private array $meta, + private array $errors, + private array $locallyIgnoredErrors, + private array $linesToIgnore, + private array $unmatchedLineIgnores, + private array $collectedData, + private array $dependencies, + private array $exportedNodes, + private array $projectExtensionFiles, + ) + { + } + + /** + * @return string[] + */ + public function getFilesToAnalyse(): array + { + return $this->filesToAnalyse; + } + + public function isFullAnalysis(): bool + { + return $this->fullAnalysis; + } + + public function getLastFullAnalysisTime(): int + { + return $this->lastFullAnalysisTime; + } + + /** + * @return mixed[] + */ + public function getMeta(): array + { + return $this->meta; + } + + /** + * @return array> + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * @return array> + */ + public function getLocallyIgnoredErrors(): array + { + return $this->locallyIgnoredErrors; + } + + /** + * @return array + */ + public function getLinesToIgnore(): array + { + return $this->linesToIgnore; + } + + /** + * @return array + */ + public function getUnmatchedLineIgnores(): array + { + return $this->unmatchedLineIgnores; + } + + /** + * @return array> + */ + public function getCollectedData(): array + { + return $this->collectedData; + } + + /** + * @return array> + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + /** + * @return array> + */ + public function getExportedNodes(): array + { + return $this->exportedNodes; + } + + /** + * @return array + */ + public function getProjectExtensionFiles(): array + { + return $this->projectExtensionFiles; + } + +} diff --git a/src/Analyser/ResultCache/ResultCacheClearer.php b/src/Analyser/ResultCache/ResultCacheClearer.php new file mode 100644 index 00000000..8cd4be74 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheClearer.php @@ -0,0 +1,29 @@ +cacheFilePath); + if (!is_file($this->cacheFilePath)) { + return $dir; + } + + @unlink($this->cacheFilePath); + + return $dir; + } + +} diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php new file mode 100644 index 00000000..1695a4f8 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -0,0 +1,1068 @@ + */ + private array $fileHashes = []; + + /** @var array */ + private array $alreadyProcessed = []; + + /** + * @param string[] $analysedPaths + * @param string[] $composerAutoloaderProjectPaths + * @param string[] $bootstrapFiles + * @param string[] $scanFiles + * @param string[] $scanDirectories + */ + public function __construct( + private Container $container, + private ExportedNodeFetcher $exportedNodeFetcher, + private FileFinder $scanFileFinder, + private ReflectionProvider $reflectionProvider, + private StubFilesProvider $stubFilesProvider, + private FileHelper $fileHelper, + private string $cacheFilePath, + private array $analysedPaths, + private array $composerAutoloaderProjectPaths, + private string $usedLevel, + private ?string $cliAutoloadFile, + private array $bootstrapFiles, + private array $scanFiles, + private array $scanDirectories, + private bool $checkDependenciesOfProjectExtensionFiles, + ) + { + } + + /** + * @param string[] $allAnalysedFiles + * @param mixed[]|null $projectConfigArray + */ + public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?array $projectConfigArray, Output $output): ResultCache + { + $startTime = microtime(true); + if ($debug) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because of debug mode.'); + } + return new ResultCache($allAnalysedFiles, true, time(), $this->getMeta($allAnalysedFiles, $projectConfigArray), [], [], [], [], [], [], [], []); + } + if ($onlyFiles) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because only files were passed as analysed paths.'); + } + return new ResultCache($allAnalysedFiles, true, time(), $this->getMeta($allAnalysedFiles, $projectConfigArray), [], [], [], [], [], [], [], []); + } + + $cacheFilePath = $this->cacheFilePath; + if (!is_file($cacheFilePath)) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because the cache file does not exist.'); + } + return new ResultCache($allAnalysedFiles, true, time(), $this->getMeta($allAnalysedFiles, $projectConfigArray), [], [], [], [], [], [], [], []); + } + + try { + $data = require $cacheFilePath; + } catch (Throwable $e) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage())); + } + + @unlink($cacheFilePath); + + return new ResultCache($allAnalysedFiles, true, time(), $this->getMeta($allAnalysedFiles, $projectConfigArray), [], [], [], [], [], [], [], []); + } + + if (!is_array($data)) { + @unlink($cacheFilePath); + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because the cache file is corrupted.'); + } + + return new ResultCache($allAnalysedFiles, true, time(), $this->getMeta($allAnalysedFiles, $projectConfigArray), [], [], [], [], [], [], [], []); + } + + $meta = $this->getMeta($allAnalysedFiles, $projectConfigArray); + if ($this->isMetaDifferent($data['meta'], $meta)) { + if ($output->isVeryVerbose()) { + $diffs = $this->getMetaKeyDifferences($data['meta'], $meta); + $output->writeLineFormatted('Result cache not used because the metadata do not match: ' . implode(', ', $diffs)); + } + return new ResultCache($allAnalysedFiles, true, time(), $meta, [], [], [], [], [], [], [], []); + } + + if (time() - $data['lastFullAnalysisTime'] >= 60 * 60 * 24 * 7) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because it\'s more than 7 days since last full analysis.'); + } + // run full analysis if the result cache is older than 7 days + return new ResultCache($allAnalysedFiles, true, time(), $meta, [], [], [], [], [], [], [], []); + } + + /** + * @var string $fileHash + * @var bool $isAnalysed + */ + foreach ($data['projectExtensionFiles'] as $extensionFile => [$fileHash, $isAnalysed]) { + if (!$isAnalysed) { + continue; + } + if (!is_file($extensionFile)) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache not used because extension file %s was not found.', $extensionFile)); + } + return new ResultCache($allAnalysedFiles, true, time(), $meta, [], [], [], [], [], [], [], []); + } + + if ($this->getFileHash($extensionFile) === $fileHash) { + continue; + } + + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache not used because extension file %s hash does not match.', $extensionFile)); + } + + return new ResultCache($allAnalysedFiles, true, time(), $meta, [], [], [], [], [], [], [], []); + } + + $invertedDependencies = $data['dependencies']; + $deletedFiles = array_fill_keys(array_keys($invertedDependencies), true); + $filesToAnalyse = []; + $invertedDependenciesToReturn = []; + $errors = $data['errorsCallback'](); + $locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback'](); + $linesToIgnore = $data['linesToIgnore']; + $unmatchedLineIgnores = $data['unmatchedLineIgnores']; + $collectedData = $data['collectedDataCallback'](); + $exportedNodes = $data['exportedNodesCallback'](); + $filteredErrors = []; + $filteredLocallyIgnoredErrors = []; + $filteredLinesToIgnore = []; + $filteredUnmatchedLineIgnores = []; + $filteredCollectedData = []; + $filteredExportedNodes = []; + $newFileAppeared = false; + + foreach ($this->getStubFiles() as $stubFile) { + if (!array_key_exists($stubFile, $errors)) { + continue; + } + + $filteredErrors[$stubFile] = $errors[$stubFile]; + } + + foreach ($allAnalysedFiles as $analysedFile) { + if (array_key_exists($analysedFile, $errors)) { + $filteredErrors[$analysedFile] = $errors[$analysedFile]; + } + if (array_key_exists($analysedFile, $locallyIgnoredErrors)) { + $filteredLocallyIgnoredErrors[$analysedFile] = $locallyIgnoredErrors[$analysedFile]; + } + if (array_key_exists($analysedFile, $linesToIgnore)) { + $filteredLinesToIgnore[$analysedFile] = $linesToIgnore[$analysedFile]; + } + if (array_key_exists($analysedFile, $unmatchedLineIgnores)) { + $filteredUnmatchedLineIgnores[$analysedFile] = $unmatchedLineIgnores[$analysedFile]; + } + if (array_key_exists($analysedFile, $collectedData)) { + $filteredCollectedData[$analysedFile] = $collectedData[$analysedFile]; + } + if (array_key_exists($analysedFile, $exportedNodes)) { + $filteredExportedNodes[$analysedFile] = $exportedNodes[$analysedFile]; + } + if (!array_key_exists($analysedFile, $invertedDependencies)) { + // new file + $filesToAnalyse[] = $analysedFile; + $newFileAppeared = true; + continue; + } + + unset($deletedFiles[$analysedFile]); + + $analysedFileData = $invertedDependencies[$analysedFile]; + $cachedFileHash = $analysedFileData['fileHash']; + $dependentFiles = $analysedFileData['dependentFiles']; + $invertedDependenciesToReturn[$analysedFile] = $dependentFiles; + $currentFileHash = $this->getFileHash($analysedFile); + + if ($cachedFileHash === $currentFileHash) { + continue; + } + + $filesToAnalyse[] = $analysedFile; + if (!array_key_exists($analysedFile, $filteredExportedNodes)) { + continue; + } + + $cachedFileExportedNodes = $filteredExportedNodes[$analysedFile]; + $exportedNodesChanged = $this->exportedNodesChanged($analysedFile, $cachedFileExportedNodes); + if ($exportedNodesChanged === null) { + continue; + } + + if ($exportedNodesChanged) { + $newFileAppeared = true; + } + + foreach ($dependentFiles as $dependentFile) { + if (!is_file($dependentFile)) { + continue; + } + $filesToAnalyse[] = $dependentFile; + } + } + + foreach (array_keys($deletedFiles) as $deletedFile) { + if (!array_key_exists($deletedFile, $invertedDependencies)) { + continue; + } + + $deletedFileData = $invertedDependencies[$deletedFile]; + $dependentFiles = $deletedFileData['dependentFiles']; + foreach ($dependentFiles as $dependentFile) { + if (!is_file($dependentFile)) { + continue; + } + $filesToAnalyse[] = $dependentFile; + } + } + + if ($newFileAppeared) { + foreach (array_keys($filteredErrors) as $fileWithError) { + $filesToAnalyse[] = $fileWithError; + } + } + + $filesToAnalyse = array_unique($filesToAnalyse); + $filesToAnalyseCount = count($filesToAnalyse); + + if ($output->isVeryVerbose()) { + $elapsed = microtime(true) - $startTime; + $elapsedString = $elapsed > 5 + ? sprintf(' in %f seconds', round($elapsed, 1)) + : ''; + + $output->writeLineFormatted(sprintf( + 'Result cache restored%s. %d %s will be reanalysed.', + $elapsedString, + $filesToAnalyseCount, + $filesToAnalyseCount === 1 ? 'file' : 'files', + )); + } + + return new ResultCache($filesToAnalyse, false, $data['lastFullAnalysisTime'], $meta, $filteredErrors, $filteredLocallyIgnoredErrors, $filteredLinesToIgnore, $filteredUnmatchedLineIgnores, $filteredCollectedData, $invertedDependenciesToReturn, $filteredExportedNodes, $data['projectExtensionFiles']); + } + + /** + * @param mixed[] $cachedMeta + * @param mixed[] $currentMeta + */ + private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool + { + $projectConfig = $currentMeta['projectConfig']; + if ($projectConfig !== null) { + ksort($currentMeta['projectConfig']); + + $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); + } + + return $cachedMeta !== $currentMeta; + } + + /** + * @param mixed[] $cachedMeta + * @param mixed[] $currentMeta + * + * @return string[] + */ + private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): array + { + $diffs = []; + foreach ($cachedMeta as $key => $value) { + if (!array_key_exists($key, $currentMeta)) { + $diffs[] = $key; + continue; + } + + if ($value === $currentMeta[$key]) { + continue; + } + + $diffs[] = $key; + } + + if ($diffs === []) { + // when none of the keys is different, + // the order of the keys is the problem + $diffs[] = 'keyOrder'; + } + + return $diffs; + } + + /** + * @param array $cachedFileExportedNodes + * @return bool|null null means nothing changed, true means new root symbol appeared, false means nested node changed + */ + private function exportedNodesChanged(string $analysedFile, array $cachedFileExportedNodes): ?bool + { + $fileExportedNodes = $this->exportedNodeFetcher->fetchNodes($analysedFile); + + $cachedSymbols = []; + foreach ($cachedFileExportedNodes as $cachedFileExportedNode) { + $cachedSymbols[$cachedFileExportedNode->getType()][] = $cachedFileExportedNode->getName(); + } + + $fileSymbols = []; + foreach ($fileExportedNodes as $fileExportedNode) { + $fileSymbols[$fileExportedNode->getType()][] = $fileExportedNode->getName(); + } + + if ($cachedSymbols !== $fileSymbols) { + return true; + } + + if (count($fileExportedNodes) !== count($cachedFileExportedNodes)) { + return true; + } + + foreach ($fileExportedNodes as $i => $fileExportedNode) { + $cachedExportedNode = $cachedFileExportedNodes[$i]; + if (!$cachedExportedNode->equals($fileExportedNode)) { + return false; + } + } + + return null; + } + + public function process(AnalyserResult $analyserResult, ResultCache $resultCache, Output $output, bool $onlyFiles, bool $save): ResultCacheProcessResult + { + $internalErrors = $analyserResult->getInternalErrors(); + $freshErrorsByFile = []; + foreach ($analyserResult->getErrors() as $error) { + $freshErrorsByFile[$error->getFilePath()][] = $error; + } + + $freshLocallyIgnoredErrorsByFile = []; + foreach ($analyserResult->getLocallyIgnoredErrors() as $error) { + $freshLocallyIgnoredErrorsByFile[$error->getFilePath()][] = $error; + } + + $freshCollectedDataByFile = []; + foreach ($analyserResult->getCollectedData() as $collectedData) { + $freshCollectedDataByFile[$collectedData->getFilePath()][] = $collectedData; + } + + $meta = $resultCache->getMeta(); + $projectConfigArray = $meta['projectConfig']; + if ($projectConfigArray !== null) { + $meta['projectConfig'] = Neon::encode($projectConfigArray); + } + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + if ($onlyFiles) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); + } + return false; + } + if ($dependencies === null) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because of error in dependencies.'); + } + return false; + } + + if (count($internalErrors) > 0) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because of internal errors.'); + } + return false; + } + + foreach ($errorsByFile as $errors) { + foreach ($errors as $error) { + if (!$error->hasNonIgnorableException()) { + continue; + } + + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache was not saved because of non-ignorable exception: %s', $error->getMessage())); + } + + return false; + } + } + + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $exportedNodes, $projectExtensionFiles, $meta); + + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache is saved.'); + } + + return true; + }; + + if ($resultCache->isFullAnalysis()) { + $saved = false; + if ($save !== false) { + $projectExtensionFiles = []; + if ($analyserResult->getDependencies() !== null) { + $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); + } + $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles); + } else { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because it was not requested.'); + } + } + + return new ResultCacheProcessResult($analyserResult, $saved); + } + + $errorsByFile = $this->mergeErrors($resultCache, $freshErrorsByFile); + $locallyIgnoredErrorsByFile = $this->mergeLocallyIgnoredErrors($resultCache, $freshLocallyIgnoredErrorsByFile); + $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); + $dependencies = $this->mergeDependencies($resultCache, $analyserResult->getDependencies()); + $exportedNodes = $this->mergeExportedNodes($resultCache, $analyserResult->getExportedNodes()); + $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); + $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); + + $saved = false; + if ($save !== false) { + $projectExtensionFiles = []; + foreach ($resultCache->getProjectExtensionFiles() as $file => [$hash, $isAnalysed, $className]) { + if ($isAnalysed) { + continue; + } + + // keep the same file hashes from the old run + // so that the message "When you edit them and re-run PHPStan, the result cache will get stale." + // keeps being shown on subsequent runs + $projectExtensionFiles[$file] = [$hash, false, $className]; + } + if ($dependencies !== null) { + foreach ($this->getProjectExtensionFiles($projectConfigArray, $dependencies) as $file => [$hash, $isAnalysed, $className]) { + if (!$isAnalysed) { + continue; + } + + $projectExtensionFiles[$file] = [$hash, true, $className]; + } + } + $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $exportedNodes, $projectExtensionFiles); + } + + $flatErrors = []; + foreach ($errorsByFile as $fileErrors) { + foreach ($fileErrors as $fileError) { + $flatErrors[] = $fileError; + } + } + + $flatLocallyIgnoredErrors = []; + foreach ($locallyIgnoredErrorsByFile as $fileErrors) { + foreach ($fileErrors as $fileError) { + $flatLocallyIgnoredErrors[] = $fileError; + } + } + + $flatCollectedData = []; + foreach ($collectedDataByFile as $fileCollectedData) { + foreach ($fileCollectedData as $collectedData) { + $flatCollectedData[] = $collectedData; + } + } + + return new ResultCacheProcessResult(new AnalyserResult( + $flatErrors, + $analyserResult->getFilteredPhpErrors(), + $analyserResult->getAllPhpErrors(), + $flatLocallyIgnoredErrors, + $linesToIgnore, + $unmatchedLineIgnores, + $internalErrors, + $flatCollectedData, + $dependencies, + $exportedNodes, + $analyserResult->hasReachedInternalErrorsCountLimit(), + $analyserResult->getPeakMemoryUsageBytes(), + ), $saved); + } + + /** + * @param array> $freshErrorsByFile + * @return array> + */ + private function mergeErrors(ResultCache $resultCache, array $freshErrorsByFile): array + { + $errorsByFile = $resultCache->getErrors(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshErrorsByFile)) { + unset($errorsByFile[$file]); + continue; + } + $errorsByFile[$file] = $freshErrorsByFile[$file]; + } + + return $errorsByFile; + } + + /** + * @param array> $freshLocallyIgnoredErrorsByFile + * @return array> + */ + private function mergeLocallyIgnoredErrors(ResultCache $resultCache, array $freshLocallyIgnoredErrorsByFile): array + { + $errorsByFile = $resultCache->getLocallyIgnoredErrors(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshLocallyIgnoredErrorsByFile)) { + unset($errorsByFile[$file]); + continue; + } + $errorsByFile[$file] = $freshLocallyIgnoredErrorsByFile[$file]; + } + + return $errorsByFile; + } + + /** + * @param array> $freshCollectedDataByFile + * @return array> + */ + private function mergeCollectedData(ResultCache $resultCache, array $freshCollectedDataByFile): array + { + $collectedDataByFile = $resultCache->getCollectedData(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshCollectedDataByFile)) { + unset($collectedDataByFile[$file]); + continue; + } + $collectedDataByFile[$file] = $freshCollectedDataByFile[$file]; + } + + return $collectedDataByFile; + } + + /** + * @param array>|null $freshDependencies + * @return array>|null + */ + private function mergeDependencies(ResultCache $resultCache, ?array $freshDependencies): ?array + { + if ($freshDependencies === null) { + return null; + } + + $cachedDependencies = []; + $resultCacheDependencies = $resultCache->getDependencies(); + $filesNoOneIsDependingOn = array_fill_keys(array_keys($resultCacheDependencies), true); + foreach ($resultCacheDependencies as $file => $filesDependingOnFile) { + foreach ($filesDependingOnFile as $fileDependingOnFile) { + $cachedDependencies[$fileDependingOnFile][] = $file; + unset($filesNoOneIsDependingOn[$fileDependingOnFile]); + } + } + + foreach (array_keys($filesNoOneIsDependingOn) as $file) { + if (array_key_exists($file, $cachedDependencies)) { + throw new ShouldNotHappenException(); + } + + $cachedDependencies[$file] = []; + } + + $newDependencies = $cachedDependencies; + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshDependencies)) { + unset($newDependencies[$file]); + continue; + } + + $newDependencies[$file] = $freshDependencies[$file]; + } + + return $newDependencies; + } + + /** + * @param array> $freshExportedNodes + * @return array> + */ + private function mergeExportedNodes(ResultCache $resultCache, array $freshExportedNodes): array + { + $newExportedNodes = $resultCache->getExportedNodes(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshExportedNodes)) { + unset($newExportedNodes[$file]); + continue; + } + + $newExportedNodes[$file] = $freshExportedNodes[$file]; + } + + return $newExportedNodes; + } + + /** + * @param array $freshLinesToIgnore + * @return array + */ + private function mergeLinesToIgnore(ResultCache $resultCache, array $freshLinesToIgnore): array + { + $newLinesToIgnore = $resultCache->getLinesToIgnore(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshLinesToIgnore)) { + unset($newLinesToIgnore[$file]); + continue; + } + + $newLinesToIgnore[$file] = $freshLinesToIgnore[$file]; + } + + return $newLinesToIgnore; + } + + /** + * @param array $freshUnmatchedLineIgnores + * @return array + */ + private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $freshUnmatchedLineIgnores): array + { + $newUnmatchedLineIgnores = $resultCache->getUnmatchedLineIgnores(); + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshUnmatchedLineIgnores)) { + unset($newUnmatchedLineIgnores[$file]); + continue; + } + + $newUnmatchedLineIgnores[$file] = $freshUnmatchedLineIgnores[$file]; + } + + return $newUnmatchedLineIgnores; + } + + /** + * @param array> $errors + * @param array> $locallyIgnoredErrors + * @param array $linesToIgnore + * @param array $unmatchedLineIgnores + * @param array> $collectedData + * @param array> $dependencies + * @param array> $exportedNodes + * @param array $projectExtensionFiles + * @param mixed[] $meta + */ + private function save( + int $lastFullAnalysisTime, + array $errors, + array $locallyIgnoredErrors, + array $linesToIgnore, + array $unmatchedLineIgnores, + array $collectedData, + array $dependencies, + array $exportedNodes, + array $projectExtensionFiles, + array $meta, + ): void + { + $invertedDependencies = []; + $filesNoOneIsDependingOn = array_fill_keys(array_keys($dependencies), true); + foreach ($dependencies as $file => $fileDependencies) { + foreach ($fileDependencies as $fileDep) { + if (!array_key_exists($fileDep, $invertedDependencies)) { + $invertedDependencies[$fileDep] = [ + 'fileHash' => $this->getFileHash($fileDep), + 'dependentFiles' => [], + ]; + unset($filesNoOneIsDependingOn[$fileDep]); + } + $invertedDependencies[$fileDep]['dependentFiles'][] = $file; + } + } + + foreach (array_keys($filesNoOneIsDependingOn) as $file) { + if (array_key_exists($file, $invertedDependencies)) { + throw new ShouldNotHappenException(); + } + + if (!is_file($file)) { + continue; + } + + $invertedDependencies[$file] = [ + 'fileHash' => $this->getFileHash($file), + 'dependentFiles' => [], + ]; + } + + ksort($errors); + ksort($locallyIgnoredErrors); + ksort($linesToIgnore); + ksort($unmatchedLineIgnores); + ksort($collectedData); + ksort($invertedDependencies); + + foreach ($invertedDependencies as $file => $fileData) { + $dependentFiles = $fileData['dependentFiles']; + sort($dependentFiles); + $invertedDependencies[$file]['dependentFiles'] = $dependentFiles; + } + + ksort($exportedNodes); + + $file = $this->cacheFilePath; + + FileWriter::write( + $file, + " " . var_export($lastFullAnalysisTime, true) . ", + 'meta' => " . var_export($meta, true) . ", + 'projectExtensionFiles' => " . var_export($projectExtensionFiles, true) . ", + 'errorsCallback' => static function (): array { return " . var_export($errors, true) . "; }, + 'locallyIgnoredErrorsCallback' => static function (): array { return " . var_export($locallyIgnoredErrors, true) . "; }, + 'linesToIgnore' => " . var_export($linesToIgnore, true) . ", + 'unmatchedLineIgnores' => " . var_export($unmatchedLineIgnores, true) . ", + 'collectedDataCallback' => static function (): array { return " . var_export($collectedData, true) . "; }, + 'dependencies' => " . var_export($invertedDependencies, true) . ", + 'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . '; }, +]; +', + ); + } + + /** + * @param mixed[]|null $projectConfig + * @param array $dependencies + * @return array + */ + private function getProjectExtensionFiles(?array $projectConfig, array $dependencies): array + { + $this->alreadyProcessed = []; + $projectExtensionFiles = []; + if ($projectConfig !== null) { + $vendorDirs = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloaderProjectPath) { + $composer = ComposerHelper::getComposerConfig($autoloaderProjectPath); + if ($composer === null) { + continue; + } + $vendorDirectory = ComposerHelper::getVendorDirFromComposerConfig($autoloaderProjectPath, $composer); + $vendorDirs[] = $this->fileHelper->normalizePath($vendorDirectory); + } + + $classes = ProjectConfigHelper::getServiceClassNames($projectConfig); + foreach ($classes as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($class); + $fileName = $classReflection->getFileName(); + if ($fileName === null) { + continue; + } + + if (str_starts_with($fileName, 'phar://')) { + continue; + } + + $allServiceFiles = $this->getAllDependencies($fileName, $dependencies); + if (count($allServiceFiles) === 0) { + $normalizedFileName = $this->fileHelper->normalizePath($fileName); + foreach ($vendorDirs as $vendorDir) { + if (str_starts_with($normalizedFileName, $vendorDir)) { + continue 2; + } + } + $projectExtensionFiles[$fileName] = [$this->getFileHash($fileName), false, $class]; + continue; + } + + foreach ($allServiceFiles as $serviceFile) { + if (array_key_exists($serviceFile, $projectExtensionFiles)) { + continue; + } + + $projectExtensionFiles[$serviceFile] = [$this->getFileHash($serviceFile), true, $class]; + } + } + } + + return $projectExtensionFiles; + } + + /** + * @param array> $dependencies + * @return array + */ + private function getAllDependencies(string $fileName, array $dependencies): array + { + if (!array_key_exists($fileName, $dependencies)) { + return []; + } + + if (array_key_exists($fileName, $this->alreadyProcessed)) { + return []; + } + + $this->alreadyProcessed[$fileName] = true; + + $files = [$fileName]; + + if ($this->checkDependenciesOfProjectExtensionFiles) { + foreach ($dependencies[$fileName] as $fileDep) { + foreach ($this->getAllDependencies($fileDep, $dependencies) as $fileDep2) { + $files[] = $fileDep2; + } + } + } + + return $files; + } + + /** + * @param string[] $allAnalysedFiles + * @param mixed[]|null $projectConfigArray + * @return mixed[] + */ + private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): array + { + $extensions = array_values(array_filter(get_loaded_extensions(), static fn (string $extension): bool => $extension !== 'xdebug')); + sort($extensions); + + if ($projectConfigArray !== null) { + unset($projectConfigArray['parameters']['editorUrl']); + unset($projectConfigArray['parameters']['editorUrlTitle']); + unset($projectConfigArray['parameters']['errorFormat']); + unset($projectConfigArray['parameters']['ignoreErrors']); + unset($projectConfigArray['parameters']['reportUnmatchedIgnoredErrors']); + unset($projectConfigArray['parameters']['tipsOfTheDay']); + unset($projectConfigArray['parameters']['parallel']); + unset($projectConfigArray['parameters']['internalErrorsCountLimit']); + unset($projectConfigArray['parameters']['cache']); + unset($projectConfigArray['parameters']['memoryLimitFile']); + unset($projectConfigArray['parameters']['pro']); + unset($projectConfigArray['parametersSchema']); + + ksort($projectConfigArray); + } + + return [ + 'cacheVersion' => self::CACHE_VERSION, + 'phpstanVersion' => ComposerHelper::getPhpStanVersion(), + 'metaExtensions' => $this->getMetaFromPhpStanExtensions(), + 'phpVersion' => PHP_VERSION_ID, + 'projectConfig' => $projectConfigArray, + 'analysedPaths' => $this->analysedPaths, + 'scannedFiles' => $this->getScannedFiles($allAnalysedFiles), + 'composerLocks' => $this->getComposerLocks(), + 'composerInstalled' => $this->getComposerInstalled(), + 'executedFilesHashes' => $this->getExecutedFileHashes(), + 'phpExtensions' => $extensions, + 'stubFiles' => $this->getStubFiles(), + 'level' => $this->usedLevel, + ]; + } + + private function getFileHash(string $path): string + { + if (array_key_exists($path, $this->fileHashes)) { + return $this->fileHashes[$path]; + } + + $hash = sha1_file($path); + if ($hash === false) { + throw new CouldNotReadFileException($path); + } + $this->fileHashes[$path] = $hash; + + return $hash; + } + + /** + * @param string[] $allAnalysedFiles + * @return array + */ + private function getScannedFiles(array $allAnalysedFiles): array + { + $scannedFiles = $this->scanFiles; + foreach ($this->scanFileFinder->findFiles($this->scanDirectories)->getFiles() as $file) { + $scannedFiles[] = $file; + } + + $scannedFiles = array_unique($scannedFiles); + + $hashes = []; + foreach (array_diff($scannedFiles, $allAnalysedFiles) as $file) { + $hashes[$file] = $this->getFileHash($file); + } + + ksort($hashes); + + return $hashes; + } + + /** + * @return array + */ + private function getExecutedFileHashes(): array + { + $hashes = []; + if ($this->cliAutoloadFile !== null) { + $hashes[$this->cliAutoloadFile] = $this->getFileHash($this->cliAutoloadFile); + } + + foreach ($this->bootstrapFiles as $bootstrapFile) { + $hashes[$bootstrapFile] = $this->getFileHash($bootstrapFile); + } + + ksort($hashes); + + return $hashes; + } + + /** + * @return array + */ + private function getComposerLocks(): array + { + $locks = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloadPath) { + $lockPath = $autoloadPath . '/composer.lock'; + if (!is_file($lockPath)) { + continue; + } + + $locks[$lockPath] = $this->getFileHash($lockPath); + } + + return $locks; + } + + /** + * @return array + */ + private function getComposerInstalled(): array + { + $data = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloadPath) { + $composer = ComposerHelper::getComposerConfig($autoloadPath); + + if ($composer === null) { + continue; + } + + $filePath = ComposerHelper::getVendorDirFromComposerConfig($autoloadPath, $composer) . '/composer/installed.php'; + if (!is_file($filePath)) { + continue; + } + + $installed = require $filePath; + $rootName = $installed['root']['name']; + unset($installed['root']); + unset($installed['versions'][$rootName]); + + $data[$filePath] = $installed; + } + + return $data; + } + + /** + * @return array + */ + private function getStubFiles(): array + { + $stubFiles = []; + foreach ($this->stubFilesProvider->getProjectStubFiles() as $stubFile) { + $stubFiles[$stubFile] = $this->getFileHash($stubFile); + } + + ksort($stubFiles); + + return $stubFiles; + } + + /** + * @return array + * @throws ShouldNotHappenException + */ + private function getMetaFromPhpStanExtensions(): array + { + $meta = []; + + /** @var ResultCacheMetaExtension $extension */ + foreach ($this->container->getServicesByTag(ResultCacheMetaExtension::EXTENSION_TAG) as $extension) { + if (array_key_exists($extension->getKey(), $meta)) { + throw new ShouldNotHappenException(sprintf( + 'Duplicate ResultCacheMetaExtension with key "%s" found.', + $extension->getKey(), + )); + } + + $meta[$extension->getKey()] = $extension->getHash(); + } + + ksort($meta); + + return $meta; + } + +} diff --git a/src/Analyser/ResultCache/ResultCacheManagerFactory.php b/src/Analyser/ResultCache/ResultCacheManagerFactory.php new file mode 100644 index 00000000..f19d2c92 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheManagerFactory.php @@ -0,0 +1,11 @@ +analyserResult; + } + + public function isSaved(): bool + { + return $this->saved; + } + +} diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php new file mode 100644 index 00000000..0360caca --- /dev/null +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -0,0 +1,83 @@ + + */ + public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + { + if ( + $expr->left instanceof Variable + && is_string($expr->left->name) + && $expr->right instanceof Variable + && is_string($expr->right->name) + && $expr->left->name === $expr->right->name + ) { + return new TypeResult(new ConstantBooleanType(true), []); + } + + $leftType = $scope->getType($expr->left); + $rightType = $scope->getType($expr->right); + + if (!$scope instanceof MutatingScope) { + return $this->initializerExprTypeResolver->resolveIdenticalType($leftType, $rightType); + } + + if ( + ( + $expr->left instanceof Node\Expr\PropertyFetch + || $expr->left instanceof Node\Expr\StaticPropertyFetch + ) + && $rightType->isNull()->yes() + && !$scope->hasPropertyNativeType($expr->left) + ) { + return new TypeResult(new BooleanType(), []); + } + + if ( + ( + $expr->right instanceof Node\Expr\PropertyFetch + || $expr->right instanceof Node\Expr\StaticPropertyFetch + ) + && $leftType->isNull()->yes() + && !$scope->hasPropertyNativeType($expr->right) + ) { + return new TypeResult(new BooleanType(), []); + } + + return $this->initializerExprTypeResolver->resolveIdenticalType($leftType, $rightType); + } + + /** + * @return TypeResult + */ + public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr): TypeResult + { + $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + $identicalType = $identicalResult->type; + if ($identicalType instanceof ConstantBooleanType) { + return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); + } + + return new TypeResult(new BooleanType(), []); + } + +} diff --git a/src/Analyser/RuleErrorTransformer.php b/src/Analyser/RuleErrorTransformer.php new file mode 100644 index 00000000..337a5209 --- /dev/null +++ b/src/Analyser/RuleErrorTransformer.php @@ -0,0 +1,89 @@ + $nodeType + */ + public function transform( + RuleError $ruleError, + Scope $scope, + string $nodeType, + int $nodeLine, + ): Error + { + $line = $nodeLine; + $canBeIgnored = true; + $fileName = $scope->getFileDescription(); + $filePath = $scope->getFile(); + $traitFilePath = null; + $tip = null; + $identifier = null; + $metadata = []; + if ($scope->isInTrait()) { + $traitReflection = $scope->getTraitReflection(); + if ($traitReflection->getFileName() !== null) { + $traitFilePath = $traitReflection->getFileName(); + } + } + + if ( + $ruleError instanceof LineRuleError + && $ruleError->getLine() !== -1 + ) { + $line = $ruleError->getLine(); + } + if ( + $ruleError instanceof FileRuleError + && $ruleError->getFile() !== '' + ) { + $fileName = $ruleError->getFileDescription(); + $filePath = $ruleError->getFile(); + $traitFilePath = null; + } + + if ($ruleError instanceof TipRuleError) { + $tip = $ruleError->getTip(); + } + + if ($ruleError instanceof IdentifierRuleError) { + $identifier = $ruleError->getIdentifier(); + } + + if ($ruleError instanceof MetadataRuleError) { + $metadata = $ruleError->getMetadata(); + } + + if ($ruleError instanceof NonIgnorableRuleError) { + $canBeIgnored = false; + } + + return new Error( + $ruleError->getMessage(), + $fileName, + $line, + $canBeIgnored, + $filePath, + $traitFilePath, + $tip, + $nodeLine, + $nodeType, + $identifier, + $metadata, + ); + } + +} diff --git a/src/Analyser/Scope.php b/src/Analyser/Scope.php new file mode 100644 index 00000000..cfebde4c --- /dev/null +++ b/src/Analyser/Scope.php @@ -0,0 +1,143 @@ +getTraitReflection() + */ + public function isInTrait(): bool; + + public function getTraitReflection(): ?ClassReflection; + + public function getFunction(): ?PhpFunctionFromParserNodeReflection; + + public function getFunctionName(): ?string; + + public function getParentScope(): ?self; + + public function hasVariableType(string $variableName): TrinaryLogic; + + public function getVariableType(string $variableName): Type; + + public function canAnyVariableExist(): bool; + + /** + * @return array + */ + public function getDefinedVariables(): array; + + /** + * @return array + */ + public function getMaybeDefinedVariables(): array; + + public function hasConstant(Name $name): bool; + + public function getPropertyReflection(Type $typeWithProperty, string $propertyName): ?ExtendedPropertyReflection; + + public function getMethodReflection(Type $typeWithMethod, string $methodName): ?ExtendedMethodReflection; + + public function getConstantReflection(Type $typeWithConstant, string $constantName): ?ClassConstantReflection; + + public function getIterableKeyType(Type $iteratee): Type; + + public function getIterableValueType(Type $iteratee): Type; + + public function isInAnonymousFunction(): bool; + + public function getAnonymousFunctionReflection(): ?ParametersAcceptor; + + public function getAnonymousFunctionReturnType(): ?Type; + + public function getType(Expr $node): Type; + + public function getNativeType(Expr $expr): Type; + + public function getKeepVoidType(Expr $node): Type; + + public function resolveName(Name $name): string; + + public function resolveTypeByName(Name $name): TypeWithClassName; + + /** + * @param mixed $value + */ + public function getTypeFromValue($value): Type; + + public function hasExpressionType(Expr $node): TrinaryLogic; + + public function isInClassExists(string $className): bool; + + public function isInFunctionExists(string $functionName): bool; + + public function isInClosureBind(): bool; + + /** @return list */ + public function getFunctionCallStack(): array; + + /** @return list */ + public function getFunctionCallStackWithParameters(): array; + + public function isParameterValueNullable(Param $parameter): bool; + + /** + * @param Node\Name|Node\Identifier|Node\ComplexType|null $type + */ + public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type; + + public function isInExpressionAssign(Expr $expr): bool; + + public function isUndefinedExpressionAllowed(Expr $expr): bool; + + public function filterByTruthyValue(Expr $expr): self; + + public function filterByFalseyValue(Expr $expr): self; + + public function isInFirstLevelStatement(): bool; + + public function getPhpVersion(): PhpVersions; + +} diff --git a/src/Analyser/ScopeContext.php b/src/Analyser/ScopeContext.php new file mode 100644 index 00000000..6a0e72ab --- /dev/null +++ b/src/Analyser/ScopeContext.php @@ -0,0 +1,94 @@ +file, null, null); + } + + public function enterClass(ClassReflection $classReflection): self + { + if ($this->classReflection !== null && !$classReflection->isAnonymous()) { + throw new ShouldNotHappenException(); + } + if ($classReflection->isTrait()) { + throw new ShouldNotHappenException(); + } + return new self($this->file, $classReflection, null); + } + + public function enterTrait(ClassReflection $traitReflection): self + { + if ($this->classReflection === null) { + throw new ShouldNotHappenException(); + } + if (!$traitReflection->isTrait()) { + throw new ShouldNotHappenException(); + } + + return new self($this->file, $this->classReflection, $traitReflection); + } + + public function equals(self $otherContext): bool + { + if ($this->file !== $otherContext->file) { + return false; + } + + if ($this->getClassReflection() === null) { + return $otherContext->getClassReflection() === null; + } elseif ($otherContext->getClassReflection() === null) { + return false; + } + + $isSameClass = $this->getClassReflection()->getName() === $otherContext->getClassReflection()->getName(); + + if ($this->getTraitReflection() === null) { + return $otherContext->getTraitReflection() === null && $isSameClass; + } elseif ($otherContext->getTraitReflection() === null) { + return false; + } + + $isSameTrait = $this->getTraitReflection()->getName() === $otherContext->getTraitReflection()->getName(); + + return $isSameClass && $isSameTrait; + } + + public function getFile(): string + { + return $this->file; + } + + public function getClassReflection(): ?ClassReflection + { + return $this->classReflection; + } + + public function getTraitReflection(): ?ClassReflection + { + return $this->traitReflection; + } + +} diff --git a/src/Analyser/ScopeFactory.php b/src/Analyser/ScopeFactory.php new file mode 100644 index 00000000..f48c218f --- /dev/null +++ b/src/Analyser/ScopeFactory.php @@ -0,0 +1,21 @@ +internalScopeFactory->create($context); + } + +} diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php new file mode 100644 index 00000000..ad2ca1aa --- /dev/null +++ b/src/Analyser/SpecifiedTypes.php @@ -0,0 +1,229 @@ + */ + private array $newConditionalExpressionHolders = []; + + private ?Expr $rootExpr = null; + + /** + * @api + * @param array $sureTypes + * @param array $sureNotTypes + */ + public function __construct( + private array $sureTypes = [], + private array $sureNotTypes = [], + ) + { + } + + /** + * Normally, $sureTypes in truthy context are used to intersect with the pre-existing type. + * And $sureNotTypes are used to remove type from the pre-existing type. + * + * Example: By default, non-empty-string intersected with '' (ConstantStringType) will lead to NeverType. + * Because it's not possible to narrow non-empty-string to an empty string. + * + * In rare cases, a type-specifying extension might want to overwrite the pre-existing types + * without taking the pre-existing types into consideration. + * + * In that case it should also call setAlwaysOverwriteTypes() on + * the returned object. + * + * ! Only do this if you're certain. Otherwise, this is a source of common bugs. ! + * + * @api + */ + public function setAlwaysOverwriteTypes(): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->overwrite = true; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * @api + */ + public function setRootExpr(?Expr $rootExpr): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $rootExpr; + + return $self; + } + + /** + * @param array $newConditionalExpressionHolders + */ + public function setNewConditionalExpressionHolders(array $newConditionalExpressionHolders): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * @api + * @return array + */ + public function getSureTypes(): array + { + return $this->sureTypes; + } + + /** + * @api + * @return array + */ + public function getSureNotTypes(): array + { + return $this->sureNotTypes; + } + + public function shouldOverwrite(): bool + { + return $this->overwrite; + } + + /** + * @return array + */ + public function getNewConditionalExpressionHolders(): array + { + return $this->newConditionalExpressionHolders; + } + + public function getRootExpr(): ?Expr + { + return $this->rootExpr; + } + + /** @api */ + public function intersectWith(SpecifiedTypes $other): self + { + $sureTypeUnion = []; + $sureNotTypeUnion = []; + $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); + + foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { + if (!isset($other->sureTypes[$exprString])) { + continue; + } + + $sureTypeUnion[$exprString] = [ + $exprNode, + TypeCombinator::union($type, $other->sureTypes[$exprString][1]), + ]; + } + + foreach ($this->sureNotTypes as $exprString => [$exprNode, $type]) { + if (!isset($other->sureNotTypes[$exprString])) { + continue; + } + + $sureNotTypeUnion[$exprString] = [ + $exprNode, + TypeCombinator::intersect($type, $other->sureNotTypes[$exprString][1]), + ]; + } + + $result = new self($sureTypeUnion, $sureNotTypeUnion); + if ($this->overwrite && $other->overwrite) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($rootExpr); + } + + /** @api */ + public function unionWith(SpecifiedTypes $other): self + { + $sureTypeUnion = $this->sureTypes + $other->sureTypes; + $sureNotTypeUnion = $this->sureNotTypes + $other->sureNotTypes; + $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); + + foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { + if (!isset($other->sureTypes[$exprString])) { + continue; + } + + $sureTypeUnion[$exprString] = [ + $exprNode, + TypeCombinator::intersect($type, $other->sureTypes[$exprString][1]), + ]; + } + + foreach ($this->sureNotTypes as $exprString => [$exprNode, $type]) { + if (!isset($other->sureNotTypes[$exprString])) { + continue; + } + + $sureNotTypeUnion[$exprString] = [ + $exprNode, + TypeCombinator::union($type, $other->sureNotTypes[$exprString][1]), + ]; + } + + $result = new self($sureTypeUnion, $sureNotTypeUnion); + if ($this->overwrite || $other->overwrite) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($rootExpr); + } + + public function normalize(Scope $scope): self + { + $sureTypes = $this->sureTypes; + + foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { + if (!isset($sureTypes[$exprString])) { + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($exprNode), $sureNotType)]; + continue; + } + + $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); + } + + $result = new self($sureTypes, []); + if ($this->overwrite) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($this->rootExpr); + } + + private function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr + { + if ($rootExprA === $rootExprB) { + return $rootExprA; + } + + if ($rootExprA === null || $rootExprB === null) { + return $rootExprA ?? $rootExprB; + } + + return null; + } + +} diff --git a/src/Analyser/StatementContext.php b/src/Analyser/StatementContext.php new file mode 100644 index 00000000..9d340d5f --- /dev/null +++ b/src/Analyser/StatementContext.php @@ -0,0 +1,53 @@ +isTopLevel; + } + + public function enterDeep(): self + { + if ($this->isTopLevel) { + return self::createDeep(); + } + + return $this; + } + +} diff --git a/src/Analyser/StatementExitPoint.php b/src/Analyser/StatementExitPoint.php new file mode 100644 index 00000000..2cb0cedb --- /dev/null +++ b/src/Analyser/StatementExitPoint.php @@ -0,0 +1,28 @@ +statement; + } + + public function getScope(): MutatingScope + { + return $this->scope; + } + +} diff --git a/src/Analyser/StatementResult.php b/src/Analyser/StatementResult.php new file mode 100644 index 00000000..ce056788 --- /dev/null +++ b/src/Analyser/StatementResult.php @@ -0,0 +1,194 @@ +scope; + } + + public function hasYield(): bool + { + return $this->hasYield; + } + + public function isAlwaysTerminating(): bool + { + return $this->isAlwaysTerminating; + } + + public function filterOutLoopExitPoints(): self + { + if (!$this->isAlwaysTerminating) { + return $this; + } + + foreach ($this->exitPoints as $exitPoint) { + $statement = $exitPoint->getStatement(); + if (!$statement instanceof Stmt\Break_ && !$statement instanceof Stmt\Continue_) { + continue; + } + + $num = $statement->num; + if (!$num instanceof Int_) { + return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints); + } + + if ($num->value !== 1) { + continue; + } + + return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints); + } + + return $this; + } + + /** + * @return StatementExitPoint[] + */ + public function getExitPoints(): array + { + return $this->exitPoints; + } + + /** + * @param class-string|class-string $stmtClass + * @return list + */ + public function getExitPointsByType(string $stmtClass): array + { + $exitPoints = []; + foreach ($this->exitPoints as $exitPoint) { + $statement = $exitPoint->getStatement(); + if (!$statement instanceof $stmtClass) { + continue; + } + + $value = $statement->num; + if ($value === null) { + $exitPoints[] = $exitPoint; + continue; + } + + if (!$value instanceof Int_) { + $exitPoints[] = $exitPoint; + continue; + } + + $value = $value->value; + if ($value !== 1) { + continue; + } + + $exitPoints[] = $exitPoint; + } + + return $exitPoints; + } + + /** + * @return list + */ + public function getExitPointsForOuterLoop(): array + { + $exitPoints = []; + foreach ($this->exitPoints as $exitPoint) { + $statement = $exitPoint->getStatement(); + if (!$statement instanceof Stmt\Continue_ && !$statement instanceof Stmt\Break_) { + $exitPoints[] = $exitPoint; + continue; + } + if ($statement->num === null) { + continue; + } + if (!$statement->num instanceof Int_) { + continue; + } + $value = $statement->num->value; + if ($value === 1) { + continue; + } + + $newNode = null; + if ($value > 2) { + $newNode = new Int_($value - 1); + } + if ($statement instanceof Stmt\Continue_) { + $newStatement = new Stmt\Continue_($newNode); + } else { + $newStatement = new Stmt\Break_($newNode); + } + + $exitPoints[] = new StatementExitPoint($newStatement, $exitPoint->getScope()); + } + + return $exitPoints; + } + + /** + * @return ThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + /** + * Top-level StatementResult represents the state of the code + * at the end of control flow statements like If_ or TryCatch. + * + * It shows how Scope etc. looks like after If_ no matter + * which code branch was executed. + * + * For If_, "end statements" contain the state of the code + * at the end of each branch - if, elseifs, else, including the last + * statement node in each branch. + * + * For nested ifs, end statements try to contain the last non-control flow + * statement like Return_ or Throw_, instead of If_, TryCatch, or Foreach_. + * + * @return EndStatementResult[] + */ + public function getEndStatements(): array + { + return $this->endStatements; + } + +} diff --git a/src/Analyser/ThrowPoint.php b/src/Analyser/ThrowPoint.php new file mode 100644 index 00000000..c20efde7 --- /dev/null +++ b/src/Analyser/ThrowPoint.php @@ -0,0 +1,80 @@ +scope; + } + + public function getType(): Type + { + return $this->type; + } + + /** + * @return Node\Expr|Node\Stmt + */ + public function getNode() + { + return $this->node; + } + + public function isExplicit(): bool + { + return $this->explicit; + } + + public function canContainAnyThrowable(): bool + { + return $this->canContainAnyThrowable; + } + + public function subtractCatchType(Type $catchType): self + { + return new self($this->scope, TypeCombinator::remove($this->type, $catchType), $this->node, $this->explicit, $this->canContainAnyThrowable); + } + +} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php new file mode 100644 index 00000000..015be7a8 --- /dev/null +++ b/src/Analyser/TypeSpecifier.php @@ -0,0 +1,2451 @@ +setTypeSpecifier($this); + } + } + + /** @api */ + public function specifyTypesInCondition( + Scope $scope, + Expr $expr, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if ($expr instanceof Instanceof_) { + $exprNode = $expr->expr; + if ($expr->class instanceof Name) { + $className = (string) $expr->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $scope->isInClass()) { + $type = new ObjectType($scope->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $scope->isInClass()) { + $type = new StaticType($scope->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $scope->isInClass() + && $scope->getClassReflection()->getParentClass() !== null + ) { + $type = new ObjectType($scope->getClassReflection()->getParentClass()->getName()); + } else { + $type = new NonexistentParentClassType(); + } + } else { + $type = new ObjectType($className); + } + return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + } + + $classType = $scope->getType($expr->class); + $uncertainty = false; + $type = TypeTraverser::map($classType, static function (Type $type, callable $traverse) use (&$uncertainty): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type->getObjectClassNames() !== []) { + $uncertainty = true; + return $type; + } + if ($type instanceof GenericClassStringType) { + $uncertainty = true; + return $type->getGenericType(); + } + if ($type instanceof ConstantStringType) { + return new ObjectType($type->getValue()); + } + return new MixedType(); + }); + + if (!$type->isSuperTypeOf(new MixedType())->yes()) { + if ($context->true()) { + $type = TypeCombinator::intersect( + $type, + new ObjectWithoutClassType(), + ); + return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + } elseif ($context->false() && !$uncertainty) { + $exprType = $scope->getType($expr->expr); + if (!$type->isSuperTypeOf($exprType)->yes()) { + return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + } + } + } + if ($context->true()) { + return $this->create($exprNode, new ObjectWithoutClassType(), $context, $scope)->setRootExpr($exprNode); + } + } elseif ($expr instanceof Node\Expr\BinaryOp\Identical) { + return $this->resolveIdentical($expr, $scope, $context); + + } elseif ($expr instanceof Node\Expr\BinaryOp\NotIdentical) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BooleanNot(new Node\Expr\BinaryOp\Identical($expr->left, $expr->right)), + $context, + )->setRootExpr($expr); + } elseif ($expr instanceof Expr\Cast\Bool_) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BinaryOp\Equal($expr->expr, new ConstFetch(new Name\FullyQualified('true'))), + $context, + )->setRootExpr($expr); + } elseif ($expr instanceof Expr\Cast\String_) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BinaryOp\NotEqual($expr->expr, new Node\Scalar\String_('')), + $context, + )->setRootExpr($expr); + } elseif ($expr instanceof Expr\Cast\Int_) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BinaryOp\NotEqual($expr->expr, new Node\Scalar\LNumber(0)), + $context, + )->setRootExpr($expr); + } elseif ($expr instanceof Expr\Cast\Double) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BinaryOp\NotEqual($expr->expr, new Node\Scalar\DNumber(0.0)), + $context, + )->setRootExpr($expr); + } elseif ($expr instanceof Node\Expr\BinaryOp\Equal) { + return $this->resolveEqual($expr, $scope, $context); + } elseif ($expr instanceof Node\Expr\BinaryOp\NotEqual) { + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BooleanNot(new Node\Expr\BinaryOp\Equal($expr->left, $expr->right)), + $context, + )->setRootExpr($expr); + + } elseif ($expr instanceof Node\Expr\BinaryOp\Smaller || $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual) { + + if ( + $expr->left instanceof FuncCall + && count($expr->left->getArgs()) >= 1 + && $expr->left->name instanceof Name + && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + && ( + !$expr->right instanceof FuncCall + || !$expr->right->name instanceof Name + || !in_array(strtolower((string) $expr->right->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + ) + ) { + $inverseOperator = $expr instanceof Node\Expr\BinaryOp\Smaller + ? new Node\Expr\BinaryOp\SmallerOrEqual($expr->right, $expr->left) + : new Node\Expr\BinaryOp\Smaller($expr->right, $expr->left); + + return $this->specifyTypesInCondition( + $scope, + new Node\Expr\BooleanNot($inverseOperator), + $context, + )->setRootExpr($expr); + } + + $orEqual = $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual; + $offset = $orEqual ? 0 : 1; + $leftType = $scope->getType($expr->left); + $result = (new SpecifiedTypes([], []))->setRootExpr($expr); + + if ( + !$context->null() + && $expr->right instanceof FuncCall + && count($expr->right->getArgs()) >= 1 + && $expr->right->name instanceof Name + && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) + && $leftType->isInteger()->yes() + ) { + $argType = $scope->getType($expr->right->getArgs()[0]->value); + + if ($argType instanceof UnionType) { + $sizeType = null; + if ($leftType instanceof ConstantIntegerType) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + } + } elseif ($leftType instanceof IntegerRangeType) { + $sizeType = $leftType; + } + + $narrowed = $this->narrowUnionByArraySize($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($narrowed !== null) { + return $narrowed; + } + } + + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + if ($context->truthy() && $argType->isArray()->maybe()) { + $countables = []; + if ($argType instanceof UnionType) { + $countableInterface = new ObjectType(Countable::class); + foreach ($argType->getTypes() as $innerType) { + if ($innerType->isArray()->yes()) { + $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); + $countables[] = $innerType; + } + + if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { + continue; + } + + $countables[] = $innerType; + } + } + + if (count($countables) > 0) { + $countableType = TypeCombinator::union(...$countables); + + return $this->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); + } + } + + if ($argType->isArray()->yes()) { + $newType = new NonEmptyArrayType(); + if ($context->true() && $argType->isList()->yes()) { + $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); + } + + $result = $result->unionWith( + $this->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), + ); + } + } + } + + if ( + !$context->null() + && $expr->right instanceof FuncCall + && count($expr->right->getArgs()) >= 3 + && $expr->right->name instanceof Name + && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + return $this->specifyTypesInCondition( + $scope, + new Expr\BinaryOp\NotIdentical($expr->right, new ConstFetch(new Name('false'))), + $context, + )->setRootExpr($expr); + } + + if ( + !$context->null() + && $expr->right instanceof FuncCall + && count($expr->right->getArgs()) === 1 + && $expr->right->name instanceof Name + && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) + && $leftType->isInteger()->yes() + ) { + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + $argType = $scope->getType($expr->right->getArgs()[0]->value); + if ($argType->isString()->yes()) { + $accessory = new AccessoryNonEmptyStringType(); + + if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } + + $result = $result->unionWith($this->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + } + } + } + + if ($leftType instanceof ConstantIntegerType) { + if ($expr->right instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset + 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset - 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PreInc || $expr->right instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset), + $context, + )); + } + } + + $rightType = $scope->getType($expr->right); + if ($rightType instanceof ConstantIntegerType) { + if ($expr->left instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset + 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset - 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PreInc || $expr->left instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset), + $context, + )); + } + } + + if ($context->true()) { + if (!$expr->left instanceof Node\Scalar) { + $result = $result->unionWith( + $this->create( + $expr->left, + $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Node\Scalar) { + $result = $result->unionWith( + $this->create( + $expr->right, + $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + } elseif ($context->false()) { + if (!$expr->left instanceof Node\Scalar) { + $result = $result->unionWith( + $this->create( + $expr->left, + $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Node\Scalar) { + $result = $result->unionWith( + $this->create( + $expr->right, + $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + } + + return $result; + + } elseif ($expr instanceof Node\Expr\BinaryOp\Greater) { + return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); + + } elseif ($expr instanceof Node\Expr\BinaryOp\GreaterOrEqual) { + return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); + + } elseif ($expr instanceof FuncCall && $expr->name instanceof Name) { + if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + foreach ($this->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection, $expr, $context)) { + continue; + } + + return $extension->specifyTypes($functionReflection, $expr, $scope, $context); + } + + // lazy create parametersAcceptor, as creation can be expensive + $parametersAcceptor = null; + if (count($expr->getArgs()) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + + $specifiedTypes = $this->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + $assertions = $functionReflection->getAsserts(); + if ($assertions->getAll() !== []) { + $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + $specifiedTypes = $this->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + } + + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } elseif ($expr instanceof MethodCall && $expr->name instanceof Node\Identifier) { + $methodCalledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($methodCalledOnType, $expr->name->name); + if ($methodReflection !== null) { + $referencedClasses = $methodCalledOnType->getObjectClassNames(); + if ( + count($referencedClasses) === 1 + && $this->reflectionProvider->hasClass($referencedClasses[0]) + ) { + $methodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); + foreach ($this->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { + if (!$extension->isMethodSupported($methodReflection, $expr, $context)) { + continue; + } + + return $extension->specifyTypes($methodReflection, $expr, $scope, $context); + } + } + + // lazy create parametersAcceptor, as creation can be expensive + $parametersAcceptor = null; + if (count($expr->getArgs()) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); + + $specifiedTypes = $this->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + $assertions = $methodReflection->getAsserts(); + if ($assertions->getAll() !== []) { + $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + $specifiedTypes = $this->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + } + + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } elseif ($expr instanceof StaticCall && $expr->name instanceof Node\Identifier) { + if ($expr->class instanceof Name) { + $calleeType = $scope->resolveTypeByName($expr->class); + } else { + $calleeType = $scope->getType($expr->class); + } + + $staticMethodReflection = $scope->getMethodReflection($calleeType, $expr->name->name); + if ($staticMethodReflection !== null) { + $referencedClasses = $calleeType->getObjectClassNames(); + if ( + count($referencedClasses) === 1 + && $this->reflectionProvider->hasClass($referencedClasses[0]) + ) { + $staticMethodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); + foreach ($this->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { + if (!$extension->isStaticMethodSupported($staticMethodReflection, $expr, $context)) { + continue; + } + + return $extension->specifyTypes($staticMethodReflection, $expr, $scope, $context); + } + } + + // lazy create parametersAcceptor, as creation can be expensive + $parametersAcceptor = null; + if (count($expr->getArgs()) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); + + $specifiedTypes = $this->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + $assertions = $staticMethodReflection->getAsserts(); + if ($assertions->getAll() !== []) { + $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + $specifiedTypes = $this->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + } + + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } elseif ($expr instanceof BooleanAnd || $expr instanceof LogicalAnd) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $leftTypes = $this->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); + $rightScope = $scope->filterByTruthyValue($expr->left); + $rightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); + $types = $context->true() ? $leftTypes->unionWith($rightTypes) : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); + if ($context->false()) { + return (new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ))->setNewConditionalExpressionHolders(array_merge( + $this->processBooleanNotSureConditionalTypes($scope, $leftTypes, $rightTypes), + $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes), + $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes), + $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes), + ))->setRootExpr($expr); + } + + return $types; + } elseif ($expr instanceof BooleanOr || $expr instanceof LogicalOr) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $leftTypes = $this->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); + $rightScope = $scope->filterByFalseyValue($expr->left); + $rightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); + $types = $context->true() ? $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)) : $leftTypes->unionWith($rightTypes); + if ($context->true()) { + return (new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ))->setNewConditionalExpressionHolders(array_merge( + $this->processBooleanNotSureConditionalTypes($scope, $leftTypes, $rightTypes), + $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes), + $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes), + $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes), + ))->setRootExpr($expr); + } + + return $types; + } elseif ($expr instanceof Node\Expr\BooleanNot && !$context->null()) { + return $this->specifyTypesInCondition($scope, $expr->expr, $context->negate())->setRootExpr($expr); + } elseif ($expr instanceof Node\Expr\Assign) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + if ($context->null()) { + return $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); + } + + return $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); + } elseif ( + $expr instanceof Expr\Isset_ + && count($expr->vars) > 0 + && !$context->null() + ) { + // rewrite multi param isset() to and-chained single param isset() + if (count($expr->vars) > 1) { + $issets = []; + foreach ($expr->vars as $var) { + $issets[] = new Expr\Isset_([$var], $expr->getAttributes()); + } + + $first = array_shift($issets); + $andChain = null; + foreach ($issets as $isset) { + if ($andChain === null) { + $andChain = new BooleanAnd($first, $isset); + continue; + } + + $andChain = new BooleanAnd($andChain, $isset); + } + + if ($andChain === null) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); + } + + $issetExpr = $expr->vars[0]; + + if (!$context->true()) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $isset = $scope->issetCheck($issetExpr, static fn () => true); + + if ($isset === false) { + return new SpecifiedTypes(); + } + + $type = $scope->getType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->create( + $issetExpr, + new NullType(), + $context->negate(), + $scope, + )->setRootExpr($expr); + + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; + } + + // variable cannot exist in !isset() + return $exprType->unionWith($this->create( + new IssetExpr($issetExpr), + new NullType(), + $context, + $scope, + ))->setRootExpr($expr); + } + + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->create( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $scope, + ))->setRootExpr($expr); + } + + // variable cannot exist in !isset() + return $this->create( + new IssetExpr($issetExpr), + new NullType(), + $context, + $scope, + )->setRootExpr($expr); + } + + if ($isNullable && $isset === true) { + return $exprType; + } + + return new SpecifiedTypes(); + } + + $tmpVars = [$issetExpr]; + while ( + $issetExpr instanceof ArrayDimFetch + || $issetExpr instanceof PropertyFetch + || ( + $issetExpr instanceof StaticPropertyFetch + && $issetExpr->class instanceof Expr + ) + ) { + if ($issetExpr instanceof StaticPropertyFetch) { + /** @var Expr $issetExpr */ + $issetExpr = $issetExpr->class; + } else { + $issetExpr = $issetExpr->var; + } + $tmpVars[] = $issetExpr; + } + $vars = array_reverse($tmpVars); + + $types = new SpecifiedTypes(); + foreach ($vars as $var) { + + if ($var instanceof Expr\Variable && is_string($var->name)) { + if ($scope->hasVariableType($var->name)->no()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + } + + if ( + $var instanceof ArrayDimFetch + && $var->dim !== null + && !$scope->getType($var->var) instanceof MixedType + ) { + $dimType = $scope->getType($var->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $types = $types->unionWith( + $this->create( + $var->var, + new HasOffsetType($dimType), + $context, + $scope, + )->setRootExpr($expr), + ); + } else { + $varType = $scope->getType($var->var); + $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); + if ($narrowedKey !== null) { + $types = $types->unionWith( + $this->create( + $var->dim, + $narrowedKey, + $context, + $scope, + )->setRootExpr($expr), + ); + } + } + } + + if ( + $var instanceof PropertyFetch + && $var->name instanceof Node\Identifier + ) { + $types = $types->unionWith( + $this->create($var->var, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), + ); + } elseif ( + $var instanceof StaticPropertyFetch + && $var->class instanceof Expr + && $var->name instanceof Node\VarLikeIdentifier + ) { + $types = $types->unionWith( + $this->create($var->class, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), + ); + } + + $types = $types->unionWith( + $this->create($var, new NullType(), TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr), + ); + } + + return $types; + } elseif ( + $expr instanceof Expr\BinaryOp\Coalesce + && !$context->null() + ) { + if (!$context->true()) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $isset = $scope->issetCheck($expr->left, static fn () => true); + + if ($isset !== true) { + return new SpecifiedTypes(); + } + + return $this->create( + $expr->left, + new NullType(), + $context->negate(), + $scope, + )->setRootExpr($expr); + } + + if ((new ConstantBooleanType(false))->isSuperTypeOf($scope->getType($expr->right)->toBoolean())->yes()) { + return $this->create( + $expr->left, + new NullType(), + TypeSpecifierContext::createFalse(), + $scope, + )->setRootExpr($expr); + } + + } elseif ( + $expr instanceof Expr\Empty_ + ) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $isset = $scope->issetCheck($expr->expr, static fn () => true); + if ($isset === false) { + return new SpecifiedTypes(); + } + + return $this->specifyTypesInCondition($scope, new BooleanOr( + new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), + new Expr\BooleanNot($expr->expr), + ), $context)->setRootExpr($expr); + } elseif ($expr instanceof Expr\ErrorSuppress) { + return $this->specifyTypesInCondition($scope, $expr->expr, $context)->setRootExpr($expr); + } elseif ( + $expr instanceof Expr\Ternary + && !$context->null() + && $scope->getType($expr->else)->isFalse()->yes() + ) { + $conditionExpr = $expr->cond; + if ($expr->if !== null) { + $conditionExpr = new BooleanAnd($conditionExpr, $expr->if); + } + + return $this->specifyTypesInCondition($scope, $conditionExpr, $context)->setRootExpr($expr); + + } elseif ($expr instanceof Expr\NullsafePropertyFetch && !$context->null()) { + $types = $this->specifyTypesInCondition( + $scope, + new BooleanAnd( + new Expr\BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), + new PropertyFetch($expr->var, $expr->name), + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); + } elseif ($expr instanceof Expr\NullsafeMethodCall && !$context->null()) { + $types = $this->specifyTypesInCondition( + $scope, + new BooleanAnd( + new Expr\BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), + new MethodCall($expr->var, $expr->name, $expr->args), + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); + } elseif ( + $expr instanceof Expr\New_ + && $expr->class instanceof Name + && $this->reflectionProvider->hasClass($expr->class->toString()) + ) { + $classReflection = $this->reflectionProvider->getClass($expr->class->toString()); + + if ($classReflection->hasConstructor()) { + $methodReflection = $classReflection->getConstructor(); + $asserts = $methodReflection->getAsserts(); + + if ($asserts->getAll() !== []) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); + + $asserts = $asserts->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + + $specifiedTypes = $this->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + } + } elseif (!$context->null()) { + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + private function narrowUnionByArraySize(FuncCall $countFuncCall, UnionType $argType, ?Type $sizeType, TypeSpecifierContext $context, Scope $scope, ?Expr $rootExpr): ?SpecifiedTypes + { + if ($sizeType === null) { + return null; + } + + if (count($countFuncCall->getArgs()) === 1) { + $isNormalCount = TrinaryLogic::createYes(); + } else { + $mode = $scope->getType($countFuncCall->getArgs()[1]->value); + $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($argType->getIterableValueType()->isArray()->negate()); + } + + if ( + $isNormalCount->yes() + && $argType->isConstantArray()->yes() + ) { + $result = []; + foreach ($argType->getTypes() as $innerType) { + $arraySize = $innerType->getArraySize(); + $isSize = $sizeType->isSuperTypeOf($arraySize); + if ($context->truthy()) { + if ($isSize->no()) { + continue; + } + + $constArray = $this->turnListIntoConstantArray($countFuncCall, $innerType, $sizeType, $scope); + if ($constArray !== null) { + $innerType = $constArray; + } + } + if ($context->falsey()) { + if (!$isSize->yes()) { + continue; + } + } + + $result[] = $innerType; + } + + return $this->create($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$result), $context, $scope)->setRootExpr($rootExpr); + } + + return null; + } + + private function turnListIntoConstantArray(FuncCall $countFuncCall, Type $type, Type $sizeType, Scope $scope): ?Type + { + $argType = $scope->getType($countFuncCall->getArgs()[0]->value); + + if (count($countFuncCall->getArgs()) === 1) { + $isNormalCount = TrinaryLogic::createYes(); + } else { + $mode = $scope->getType($countFuncCall->getArgs()[1]->value); + $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($argType->getIterableValueType()->isArray()->negate()); + } + + if ( + $isNormalCount->yes() + && $type->isList()->yes() + && $sizeType instanceof ConstantIntegerType + && $sizeType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + ) { + // turn optional offsets non-optional + $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $sizeType->getValue(); $i++) { + $offsetType = new ConstantIntegerType($i); + $valueTypesBuilder->setOffsetValueType($offsetType, $type->getOffsetValueType($offsetType)); + } + return $valueTypesBuilder->getArray(); + } + + if ( + $isNormalCount->yes() + && $type->isList()->yes() + && $sizeType instanceof IntegerRangeType + && $sizeType->getMin() !== null + ) { + // turn optional offsets non-optional + $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $sizeType->getMin(); $i++) { + $offsetType = new ConstantIntegerType($i); + $valueTypesBuilder->setOffsetValueType($offsetType, $type->getOffsetValueType($offsetType)); + } + if ($sizeType->getMax() !== null) { + for ($i = $sizeType->getMin(); $i < $sizeType->getMax(); $i++) { + $offsetType = new ConstantIntegerType($i); + $valueTypesBuilder->setOffsetValueType($offsetType, $type->getOffsetValueType($offsetType), true); + } + } elseif ($type->isConstantArray()->yes()) { + for ($i = $sizeType->getMin();; $i++) { + $offsetType = new ConstantIntegerType($i); + $hasOffset = $type->hasOffsetValueType($offsetType); + if ($hasOffset->no()) { + break; + } + $valueTypesBuilder->setOffsetValueType($offsetType, $type->getOffsetValueType($offsetType), !$hasOffset->yes()); + } + } else { + return null; + } + + $arrayType = $valueTypesBuilder->getArray(); + if ($arrayType->isIterableAtLeastOnce()->yes()) { + return $arrayType; + } + } + + return null; + } + + private function specifyTypesForConstantBinaryExpression( + Expr $exprNode, + Type $constantType, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + if (!$context->null() && $constantType->isFalse()->yes()) { + $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + if ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch) { + return $types; + } + + return $types->unionWith($this->specifyTypesInCondition( + $scope, + $exprNode, + $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), + )->setRootExpr($rootExpr)); + } + + if (!$context->null() && $constantType->isTrue()->yes()) { + $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + if ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch) { + return $types; + } + + return $types->unionWith($this->specifyTypesInCondition( + $scope, + $exprNode, + $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), + )->setRootExpr($rootExpr)); + } + + return null; + } + + private function specifyTypesForConstantStringBinaryExpression( + Expr $exprNode, + Type $constantType, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + $scalarValues = $constantType->getConstantScalarValues(); + if (count($scalarValues) !== 1 || !is_string($scalarValues[0])) { + return null; + } + $constantStringValue = $scalarValues[0]; + + if ( + $exprNode instanceof FuncCall + && $exprNode->name instanceof Name + && strtolower($exprNode->name->toString()) === 'gettype' + && isset($exprNode->getArgs()[0]) + ) { + $type = null; + if ($constantStringValue === 'string') { + $type = new StringType(); + } + if ($constantStringValue === 'array') { + $type = new ArrayType(new MixedType(), new MixedType()); + } + if ($constantStringValue === 'boolean') { + $type = new BooleanType(); + } + if (in_array($constantStringValue, ['resource', 'resource (closed)'], true)) { + $type = new ResourceType(); + } + if ($constantStringValue === 'integer') { + $type = new IntegerType(); + } + if ($constantStringValue === 'double') { + $type = new FloatType(); + } + if ($constantStringValue === 'NULL') { + $type = new NullType(); + } + if ($constantStringValue === 'object') { + $type = new ObjectWithoutClassType(); + } + + if ($type !== null) { + $callType = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $argType = $this->create($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); + return $callType->unionWith($argType); + } + } + + if ( + $context->true() + && $exprNode instanceof FuncCall + && $exprNode->name instanceof Name + && strtolower((string) $exprNode->name) === 'get_parent_class' + && isset($exprNode->getArgs()[0]) + ) { + $argType = $scope->getType($exprNode->getArgs()[0]->value); + $objectType = new ObjectType($constantStringValue); + $classStringType = new GenericClassStringType($objectType); + + if ($argType->isString()->yes()) { + return $this->create( + $exprNode->getArgs()[0]->value, + $classStringType, + $context, + $scope, + )->setRootExpr($rootExpr); + } + + if ($argType->isObject()->yes()) { + return $this->create( + $exprNode->getArgs()[0]->value, + $objectType, + $context, + $scope, + )->setRootExpr($rootExpr); + } + + return $this->create( + $exprNode->getArgs()[0]->value, + TypeCombinator::union($objectType, $classStringType), + $context, + $scope, + )->setRootExpr($rootExpr); + } + + return null; + } + + private function handleDefaultTruthyOrFalseyContext(TypeSpecifierContext $context, Expr $expr, Scope $scope): SpecifiedTypes + { + if ($context->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + if (!$context->truthy()) { + $type = StaticTypeFactory::truthy(); + return $this->create($expr, $type, TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr); + } elseif (!$context->falsey()) { + $type = StaticTypeFactory::falsey(); + return $this->create($expr, $type, TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + private function specifyTypesFromConditionalReturnType( + TypeSpecifierContext $context, + Expr\CallLike $call, + ParametersAcceptor $parametersAcceptor, + Scope $scope, + ): ?SpecifiedTypes + { + if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { + return null; + } + + $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); + if (!$returnType instanceof ConditionalTypeForParameter) { + return null; + } + + if ($context->true()) { + $leftType = new ConstantBooleanType(true); + $rightType = new ConstantBooleanType(false); + } elseif ($context->false()) { + $leftType = new ConstantBooleanType(false); + $rightType = new ConstantBooleanType(true); + } elseif ($context->null()) { + $leftType = new MixedType(); + $rightType = new NeverType(); + } else { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } else { + continue; + } + + $argsMap['$' . $paramName] = $arg->value; + } + + return $this->getConditionalSpecifiedTypes($returnType, $leftType, $rightType, $scope, $argsMap); + } + + /** + * @param array $argsMap + */ + public function getConditionalSpecifiedTypes( + ConditionalTypeForParameter $conditionalType, + Type $leftType, + Type $rightType, + Scope $scope, + array $argsMap, + ): ?SpecifiedTypes + { + $parameterName = $conditionalType->getParameterName(); + if (!array_key_exists($parameterName, $argsMap)) { + return null; + } + + $targetType = $conditionalType->getTarget(); + $ifType = $conditionalType->getIf(); + $elseType = $conditionalType->getElse(); + + if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); + } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + } else { + return null; + } + + $specifiedTypes = $this->create( + $argsMap[$parameterName], + $targetType, + $context, + $scope, + ); + + if ($targetType instanceof ConstantBooleanType) { + if (!$targetType->getValue()) { + $context = $context->negate(); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->specifyTypesInCondition($scope, $argsMap[$parameterName], $context)); + } + + return $specifiedTypes; + } + + private function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, Scope $scope): ?SpecifiedTypes + { + if ($context->null()) { + $asserts = $assertions->getAsserts(); + } elseif ($context->true()) { + $asserts = $assertions->getAssertsIfTrue(); + } elseif ($context->false()) { + $asserts = $assertions->getAssertsIfFalse(); + } else { + throw new ShouldNotHappenException(); + } + + if (count($asserts) === 0) { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = $parameters[count($parameters) - 1]; + $paramName = $lastParameter->getName(); + } else { + continue; + } + + $argsMap[$paramName][] = $arg->value; + } + + if ($call instanceof MethodCall) { + $argsMap['this'] = [$call->var]; + } + + /** @var SpecifiedTypes|null $types */ + $types = null; + + foreach ($asserts as $assert) { + foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { + $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $argsMap)) { + $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[$parameterName])); + $type = $type->toConditional($argType); + } + } + + return $traverse($type); + }); + + $assertExpr = $assert->getParameter()->getExpr($parameterExpr); + + $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $containsUnresolvedTemplate = false; + TypeTraverser::map( + $assert->getOriginalType(), + static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { + if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { + $resolvedType = $templateTypeMap->getType($type->getName()); + if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + $containsUnresolvedTemplate = true; + return $type; + } + } + + return $traverse($type); + }, + ); + + $newTypes = $this->create( + $assertExpr, + $assertedType, + $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), + $scope, + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; + + if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { + continue; + } + + $subContext = $assertedType->getValue() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + if ($assert->isNegated()) { + $subContext = $subContext->negate(); + } + + $types = $types->unionWith($this->specifyTypesInCondition( + $scope, + $assertExpr, + $subContext, + )); + } + } + + return $types; + } + + /** + * @return array + */ + private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes): array + { + $conditionExpressionTypes = []; + foreach ($leftTypes->getSureTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( + $expr, + TypeCombinator::remove($scope->getType($expr), $type), + ); + } + + if (count($conditionExpressionTypes) > 0) { + $holders = []; + foreach ($rightTypes->getSureTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + if (!isset($holders[$exprString])) { + $holders[$exprString] = []; + } + + $conditions = $conditionExpressionTypes; + foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { + $conditionExpr = $conditionExprTypeHolder->getExpr(); + if (!$conditionExpr instanceof Expr\Variable) { + continue; + } + if (!is_string($conditionExpr->name)) { + continue; + } + if ($conditionExpr->name !== $expr->name) { + continue; + } + + unset($conditions[$conditionExprString]); + } + + if (count($conditions) === 0) { + continue; + } + + $holder = new ConditionalExpressionHolder( + $conditions, + new ExpressionTypeHolder($expr, TypeCombinator::intersect($scope->getType($expr), $type), TrinaryLogic::createYes()), + ); + $holders[$exprString][$holder->getKey()] = $holder; + } + + return $holders; + } + + return []; + } + + /** + * @return array + */ + private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes): array + { + $conditionExpressionTypes = []; + foreach ($leftTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( + $expr, + TypeCombinator::intersect($scope->getType($expr), $type), + ); + } + + if (count($conditionExpressionTypes) > 0) { + $holders = []; + foreach ($rightTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + if (!isset($holders[$exprString])) { + $holders[$exprString] = []; + } + + $conditions = $conditionExpressionTypes; + foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { + $conditionExpr = $conditionExprTypeHolder->getExpr(); + if (!$conditionExpr instanceof Expr\Variable) { + continue; + } + if (!is_string($conditionExpr->name)) { + continue; + } + if ($conditionExpr->name !== $expr->name) { + continue; + } + + unset($conditions[$conditionExprString]); + } + + if (count($conditions) === 0) { + continue; + } + + $holder = new ConditionalExpressionHolder( + $conditions, + new ExpressionTypeHolder($expr, TypeCombinator::remove($scope->getType($expr), $type), TrinaryLogic::createYes()), + ); + $holders[$exprString][$holder->getKey()] = $holder; + } + + return $holders; + } + + return []; + } + + /** + * @return array{Expr, ConstantScalarType, Type}|null + */ + private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array + { + $leftType = $scope->getType($binaryOperation->left); + $rightType = $scope->getType($binaryOperation->right); + + $rightExpr = $binaryOperation->right; + if ($rightExpr instanceof AlwaysRememberedExpr) { + $rightExpr = $rightExpr->getExpr(); + } + + $leftExpr = $binaryOperation->left; + if ($leftExpr instanceof AlwaysRememberedExpr) { + $leftExpr = $leftExpr->getExpr(); + } + + if ( + $leftType instanceof ConstantScalarType + && !$rightExpr instanceof ConstFetch + && !$rightExpr instanceof ClassConstFetch + ) { + return [$binaryOperation->right, $leftType, $rightType]; + } elseif ( + $rightType instanceof ConstantScalarType + && !$leftExpr instanceof ConstFetch + && !$leftExpr instanceof ClassConstFetch + ) { + return [$binaryOperation->left, $rightType, $leftType]; + } + + return null; + } + + /** @api */ + public function create( + Expr $expr, + Type $type, + TypeSpecifierContext $context, + Scope $scope, + ): SpecifiedTypes + { + if ($expr instanceof Instanceof_ || $expr instanceof Expr\List_) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + $specifiedExprs = []; + if ($expr instanceof AlwaysRememberedExpr) { + $specifiedExprs[] = $expr; + $expr = $expr->expr; + } + + if ($expr instanceof Expr\Assign) { + $specifiedExprs[] = $expr->var; + $specifiedExprs[] = $expr->expr; + + while ($expr->expr instanceof Expr\Assign) { + $specifiedExprs[] = $expr->expr->var; + $expr = $expr->expr; + } + } elseif ($expr instanceof Expr\AssignOp\Coalesce) { + $specifiedExprs[] = $expr->var; + } else { + $specifiedExprs[] = $expr; + } + + $types = null; + + foreach ($specifiedExprs as $specifiedExpr) { + $newTypes = $this->createForExpr($specifiedExpr, $type, $context, $scope); + + if ($types === null) { + $types = $newTypes; + } else { + $types = $types->unionWith($newTypes); + } + } + + return $types; + } + + private function createForExpr( + Expr $expr, + Type $type, + TypeSpecifierContext $context, + Scope $scope, + ): SpecifiedTypes + { + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$scope->getType($expr)->isNull()->no(); + } elseif ($context->false()) { + $containsNull = !TypeCombinator::containsNull($type) && !$scope->getType($expr)->isNull()->no(); + } + + $originalExpr = $expr; + if (isset($containsNull) && !$containsNull) { + $expr = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($expr); + } + + if ( + !$context->null() + && $expr instanceof Expr\BinaryOp\Coalesce + ) { + $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); + if (($context->true() && $rightIsSuperType->no()) || ($context->false() && $rightIsSuperType->yes())) { + $expr = $expr->left; + } + } + + if ( + $expr instanceof FuncCall + && $expr->name instanceof Name + ) { + $has = $this->reflectionProvider->hasFunction($expr->name, $scope); + if (!$has) { + // backwards compatibility with previous behaviour + return new SpecifiedTypes([], []); + } + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return new SpecifiedTypes([], []); + } + + if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { + return new SpecifiedTypes([], []); + } + } + + if ( + $expr instanceof MethodCall + && $expr->name instanceof Node\Identifier + ) { + $methodName = $expr->name->toString(); + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); + } + + return new SpecifiedTypes([], []); + } + } + + if ( + $expr instanceof StaticCall + && $expr->name instanceof Node\Identifier + ) { + $methodName = $expr->name->toString(); + if ($expr->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($expr->class); + } else { + $calledOnType = $scope->getType($expr->class); + } + + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); + } + + return new SpecifiedTypes([], []); + } + } + + $sureTypes = []; + $sureNotTypes = []; + $exprString = $this->exprPrinter->printExpr($expr); + $originalExprString = $this->exprPrinter->printExpr($originalExpr); + if ($context->false()) { + $sureNotTypes[$exprString] = [$expr, $type]; + if ($exprString !== $originalExprString) { + $sureNotTypes[$originalExprString] = [$originalExpr, $type]; + } + } elseif ($context->true()) { + $sureTypes[$exprString] = [$expr, $type]; + if ($exprString !== $originalExprString) { + $sureTypes[$originalExprString] = [$originalExpr, $type]; + } + } + + $types = new SpecifiedTypes($sureTypes, $sureNotTypes); + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type)->unionWith($types); + } + + return $types; + } + + private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes + { + if ($expr instanceof Expr\NullsafePropertyFetch) { + if ($type !== null) { + $propertyFetchTypes = $this->create(new PropertyFetch($expr->var, $expr->name), $type, $context, $scope); + } else { + $propertyFetchTypes = $this->create(new PropertyFetch($expr->var, $expr->name), new NullType(), TypeSpecifierContext::createFalse(), $scope); + } + + return $propertyFetchTypes->unionWith( + $this->create($expr->var, new NullType(), TypeSpecifierContext::createFalse(), $scope), + ); + } + + if ($expr instanceof Expr\NullsafeMethodCall) { + if ($type !== null) { + $methodCallTypes = $this->create(new MethodCall($expr->var, $expr->name, $expr->args), $type, $context, $scope); + } else { + $methodCallTypes = $this->create(new MethodCall($expr->var, $expr->name, $expr->args), new NullType(), TypeSpecifierContext::createFalse(), $scope); + } + + return $methodCallTypes->unionWith( + $this->create($expr->var, new NullType(), TypeSpecifierContext::createFalse(), $scope), + ); + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->createNullsafeTypes($expr->var, $scope, $context, null); + } + + if ($expr instanceof Expr\MethodCall) { + return $this->createNullsafeTypes($expr->var, $scope, $context, null); + } + + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->createNullsafeTypes($expr->var, $scope, $context, null); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->createNullsafeTypes($expr->class, $scope, $context, null); + } + + if ($expr instanceof Expr\StaticCall && $expr->class instanceof Expr) { + return $this->createNullsafeTypes($expr->class, $scope, $context, null); + } + + return new SpecifiedTypes([], []); + } + + private function createRangeTypes(?Expr $rootExpr, Expr $expr, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { + $sureNotTypes = []; + + if ($type instanceof IntegerRangeType || $type instanceof ConstantIntegerType) { + $exprString = $this->exprPrinter->printExpr($expr); + if ($context->false()) { + $sureNotTypes[$exprString] = [$expr, $type]; + } elseif ($context->true()) { + $inverted = TypeCombinator::remove(new IntegerType(), $type); + $sureNotTypes[$exprString] = [$expr, $inverted]; + } + } + + return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($rootExpr); + } + + /** + * @return FunctionTypeSpecifyingExtension[] + */ + private function getFunctionTypeSpecifyingExtensions(): array + { + return $this->functionTypeSpecifyingExtensions; + } + + /** + * @return MethodTypeSpecifyingExtension[] + */ + private function getMethodTypeSpecifyingExtensionsForClass(string $className): array + { + if ($this->methodTypeSpecifyingExtensionsByClass === null) { + $byClass = []; + foreach ($this->methodTypeSpecifyingExtensions as $extension) { + $byClass[$extension->getClass()][] = $extension; + } + + $this->methodTypeSpecifyingExtensionsByClass = $byClass; + } + return $this->getTypeSpecifyingExtensionsForType($this->methodTypeSpecifyingExtensionsByClass, $className); + } + + /** + * @return StaticMethodTypeSpecifyingExtension[] + */ + private function getStaticMethodTypeSpecifyingExtensionsForClass(string $className): array + { + if ($this->staticMethodTypeSpecifyingExtensionsByClass === null) { + $byClass = []; + foreach ($this->staticMethodTypeSpecifyingExtensions as $extension) { + $byClass[$extension->getClass()][] = $extension; + } + + $this->staticMethodTypeSpecifyingExtensionsByClass = $byClass; + } + return $this->getTypeSpecifyingExtensionsForType($this->staticMethodTypeSpecifyingExtensionsByClass, $className); + } + + /** + * @param MethodTypeSpecifyingExtension[][]|StaticMethodTypeSpecifyingExtension[][] $extensions + * @return mixed[] + */ + private function getTypeSpecifyingExtensionsForType(array $extensions, string $className): array + { + $extensionsForClass = [[]]; + $class = $this->reflectionProvider->getClass($className); + foreach (array_merge([$className], $class->getParentClassesNames(), $class->getNativeReflection()->getInterfaceNames()) as $extensionClassName) { + if (!isset($extensions[$extensionClassName])) { + continue; + } + + $extensionsForClass[] = $extensions[$extensionClassName]; + } + + return array_merge(...$extensionsForClass); + } + + public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + if ($expressions !== null) { + $exprNode = $expressions[0]; + $constantType = $expressions[1]; + $otherType = $expressions[2]; + + if (!$context->null() && $constantType->getValue() === null) { + $trueTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantArrayType([], []), + ]; + return $this->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + } + + if (!$context->null() && $constantType->getValue() === false) { + return $this->specifyTypesInCondition( + $scope, + $exprNode, + $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), + )->setRootExpr($expr); + } + + if (!$context->null() && $constantType->getValue() === true) { + return $this->specifyTypesInCondition( + $scope, + $exprNode, + $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), + )->setRootExpr($expr); + } + + if (!$context->null() && $constantType->getValue() === 0 && !$otherType->isInteger()->yes() && !$otherType->isBoolean()->yes()) { + /* There is a difference between php 7.x and 8.x on the equality + * behavior between zero and the empty string, so to be conservative + * we leave it untouched regardless of the language version */ + if ($context->true()) { + $trueTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new StringType(), + ]; + } else { + $trueTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType('0'), + ]; + } + return $this->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + } + + if (!$context->null() && $constantType->getValue() === '') { + /* There is a difference between php 7.x and 8.x on the equality + * behavior between zero and the empty string, so to be conservative + * we leave it untouched regardless of the language version */ + if ($context->true()) { + $trueTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + ]; + } else { + $trueTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantStringType(''), + ]; + } + return $this->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + } + + if ( + $exprNode instanceof FuncCall + && $exprNode->name instanceof Name + && in_array(strtolower($exprNode->name->toString()), ['gettype', 'get_class', 'get_debug_type'], true) + && isset($exprNode->getArgs()[0]) + && $constantType->isString()->yes() + ) { + return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + } + + if ( + $context->true() + && $exprNode instanceof FuncCall + && $exprNode->name instanceof Name + && $exprNode->name->toLowerString() === 'preg_match' + && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes() + ) { + return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + } + } + + $leftType = $scope->getType($expr->left); + $rightType = $scope->getType($expr->right); + + $leftBooleanType = $leftType->toBoolean(); + if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { + return $this->specifyTypesInCondition( + $scope, + new Expr\BinaryOp\Identical( + new ConstFetch(new Name($leftBooleanType->getValue() ? 'true' : 'false')), + $expr->right, + ), + $context, + )->setRootExpr($expr); + } + + $rightBooleanType = $rightType->toBoolean(); + if ($rightBooleanType instanceof ConstantBooleanType && $leftType->isBoolean()->yes()) { + return $this->specifyTypesInCondition( + $scope, + new Expr\BinaryOp\Identical( + $expr->left, + new ConstFetch(new Name($rightBooleanType->getValue() ? 'true' : 'false')), + ), + $context, + )->setRootExpr($expr); + } + + if ( + !$context->null() + && $rightType->isArray()->yes() + && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() + ) { + return $this->create($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + } + + if ( + !$context->null() + && $leftType->isArray()->yes() + && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() + ) { + return $this->create($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + } + + if ( + ($leftType->isString()->yes() && $rightType->isString()->yes()) + || ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) + || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) + || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) + ) { + return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + } + + $leftExprString = $this->exprPrinter->printExpr($expr->left); + $rightExprString = $this->exprPrinter->printExpr($expr->right); + if ($leftExprString === $rightExprString) { + if (!$expr->left instanceof Expr\Variable || !$expr->right instanceof Expr\Variable) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + } + + $leftTypes = $this->create($expr->left, $leftType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->create($expr->right, $rightType, $context, $scope)->setRootExpr($expr); + + return $context->true() + ? $leftTypes->unionWith($rightTypes) + : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($scope)); + } + + public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + // Normalize to: fn() === expr + $leftExpr = $expr->left; + $rightExpr = $expr->right; + if ($rightExpr instanceof FuncCall && !$leftExpr instanceof FuncCall) { + [$leftExpr, $rightExpr] = [$rightExpr, $leftExpr]; + } + + $unwrappedLeftExpr = $leftExpr; + if ($leftExpr instanceof AlwaysRememberedExpr) { + $unwrappedLeftExpr = $leftExpr->getExpr(); + } + $unwrappedRightExpr = $rightExpr; + if ($rightExpr instanceof AlwaysRememberedExpr) { + $unwrappedRightExpr = $rightExpr->getExpr(); + } + + $rightType = $scope->getType($rightExpr); + + // (count($a) === $b) + if ( + !$context->null() + && $unwrappedLeftExpr instanceof FuncCall + && count($unwrappedLeftExpr->getArgs()) >= 1 + && $unwrappedLeftExpr->name instanceof Name + && in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true) + && $rightType->isInteger()->yes() + ) { + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { + return $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + } + + $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); + if ($isZero->yes()) { + $funcTypes = $this->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr); + + if ($context->truthy() && !$argType->isArray()->yes()) { + $newArgType = new UnionType([ + new ObjectType(Countable::class), + new ConstantArrayType([], []), + ]); + } else { + $newArgType = new ConstantArrayType([], []); + } + + return $funcTypes->unionWith( + $this->create($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), + ); + } + + if ($argType instanceof UnionType) { + $narrowed = $this->narrowUnionByArraySize($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); + if ($narrowed !== null) { + return $narrowed; + } + } + + if ($context->truthy()) { + if ($argType->isArray()->yes()) { + if ( + $argType->isConstantArray()->yes() + && $rightType->isSuperTypeOf($argType->getArraySize())->no() + ) { + return $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + } + + $funcTypes = $this->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $constArray = $this->turnListIntoConstantArray($unwrappedLeftExpr, $argType, $rightType, $scope); + if ($constArray !== null) { + return $funcTypes->unionWith( + $this->create($unwrappedLeftExpr->getArgs()[0]->value, $constArray, $context, $scope)->setRootExpr($expr), + ); + } elseif (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { + return $funcTypes->unionWith( + $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + ); + } + + return $funcTypes; + } + } + } + + // strlen($a) === $b + if ( + !$context->null() + && $unwrappedLeftExpr instanceof FuncCall + && count($unwrappedLeftExpr->getArgs()) === 1 + && $unwrappedLeftExpr->name instanceof Name + && in_array(strtolower((string) $unwrappedLeftExpr->name), ['strlen', 'mb_strlen'], true) + && $rightType->isInteger()->yes() + ) { + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { + return $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + } + + $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); + if ($isZero->yes()) { + $funcTypes = $this->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr); + return $funcTypes->unionWith( + $this->create($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), + ); + } + + if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { + $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + if ($argType->isString()->yes()) { + $funcTypes = $this->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr); + + $accessory = new AccessoryNonEmptyStringType(); + if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } + $valueTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); + + return $funcTypes->unionWith($valueTypes); + } + } + } + + // preg_match($a) === $b + if ( + $context->true() + && $unwrappedLeftExpr instanceof FuncCall + && $unwrappedLeftExpr->name instanceof Name + && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' + && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() + ) { + return $this->specifyTypesInCondition( + $scope, + $leftExpr, + $context, + )->setRootExpr($expr); + } + + // get_class($a) === 'Foo' + if ( + $context->true() + && $unwrappedLeftExpr instanceof FuncCall + && $unwrappedLeftExpr->name instanceof Name + && in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true) + && isset($unwrappedLeftExpr->getArgs()[0]) + ) { + if ($rightType->getClassStringObjectType()->isObject()->yes()) { + return $this->create( + $unwrappedLeftExpr->getArgs()[0]->value, + $rightType->getClassStringObjectType(), + $context, + $scope, + )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + } + } + + // get_class($a) === 'Foo' + if ( + $context->truthy() + && $unwrappedLeftExpr instanceof FuncCall + && $unwrappedLeftExpr->name instanceof Name + && in_array(strtolower($unwrappedLeftExpr->name->toString()), [ + 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', + 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', + 'ucwords', 'mb_convert_case', 'mb_convert_kana', + ], true) + && isset($unwrappedLeftExpr->getArgs()[0]) + && $rightType->isNonEmptyString()->yes() + ) { + $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + + if ($argType->isString()->yes()) { + if ($rightType->isNonFalsyString()->yes()) { + return $this->create( + $unwrappedLeftExpr->getArgs()[0]->value, + TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), + $context, + $scope, + )->setRootExpr($expr); + } + + return $this->create( + $unwrappedLeftExpr->getArgs()[0]->value, + TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), + $context, + $scope, + )->setRootExpr($expr); + } + } + + if ($rightType->isString()->yes()) { + $types = null; + foreach ($rightType->getConstantStrings() as $constantString) { + $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($unwrappedLeftExpr, $constantString, $context, $scope, $expr); + + if ($specifiedType === null) { + continue; + } + if ($types === null) { + $types = $specifiedType; + continue; + } + + $types = $types->intersectWith($specifiedType); + } + + if ($types !== null) { + if ($leftExpr !== $unwrappedLeftExpr) { + $types = $types->unionWith($this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + } + return $types; + } + } + + $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + if ($expressions !== null) { + $exprNode = $expressions[0]; + $constantType = $expressions[1]; + + $unwrappedExprNode = $exprNode; + if ($exprNode instanceof AlwaysRememberedExpr) { + $unwrappedExprNode = $exprNode->getExpr(); + } + + $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr); + if ($specifiedType !== null) { + if ($exprNode !== $unwrappedExprNode) { + $specifiedType = $specifiedType->unionWith( + $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($expr), + ); + } + return $specifiedType; + } + } + + // $a::class === 'Foo' + if ( + $context->true() && + $unwrappedLeftExpr instanceof ClassConstFetch && + $unwrappedLeftExpr->class instanceof Expr && + $unwrappedLeftExpr->name instanceof Node\Identifier && + $unwrappedRightExpr instanceof ClassConstFetch && + $rightType instanceof ConstantStringType && + $rightType->getValue() !== '' && + strtolower($unwrappedLeftExpr->name->toString()) === 'class' + ) { + return $this->specifyTypesInCondition( + $scope, + new Instanceof_( + $unwrappedLeftExpr->class, + new Name($rightType->getValue()), + ), + $context, + )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + } + + $leftType = $scope->getType($leftExpr); + + // 'Foo' === $a::class + if ( + $context->true() && + $unwrappedRightExpr instanceof ClassConstFetch && + $unwrappedRightExpr->class instanceof Expr && + $unwrappedRightExpr->name instanceof Node\Identifier && + $unwrappedLeftExpr instanceof ClassConstFetch && + $leftType instanceof ConstantStringType && + $leftType->getValue() !== '' && + strtolower($unwrappedRightExpr->name->toString()) === 'class' + ) { + return $this->specifyTypesInCondition( + $scope, + new Instanceof_( + $unwrappedRightExpr->class, + new Name($leftType->getValue()), + ), + $context, + )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + } + + if ($context->false()) { + $identicalType = $scope->getType($expr); + if ($identicalType instanceof ConstantBooleanType) { + $never = new NeverType(); + $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; + $leftTypes = $this->create($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + if ($leftExpr instanceof AlwaysRememberedExpr) { + $leftTypes = $leftTypes->unionWith( + $this->create($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr), + ); + } + if ($rightExpr instanceof AlwaysRememberedExpr) { + $rightTypes = $rightTypes->unionWith( + $this->create($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr), + ); + } + return $leftTypes->unionWith($rightTypes); + } + } + + $types = null; + if ( + count($leftType->getFiniteTypes()) === 1 + || ( + $context->true() + && $leftType->isConstantValue()->yes() + && !$rightType->equals($leftType) + && $rightType->isSuperTypeOf($leftType)->yes()) + ) { + $types = $this->create( + $rightExpr, + $leftType, + $context, + $scope, + )->setRootExpr($expr); + if ($rightExpr instanceof AlwaysRememberedExpr) { + $types = $types->unionWith($this->create( + $unwrappedRightExpr, + $leftType, + $context, + $scope, + ))->setRootExpr($expr); + } + } + if ( + count($rightType->getFiniteTypes()) === 1 + || ( + $context->true() + && $rightType->isConstantValue()->yes() + && !$leftType->equals($rightType) + && $leftType->isSuperTypeOf($rightType)->yes() + ) + ) { + $leftTypes = $this->create( + $leftExpr, + $rightType, + $context, + $scope, + )->setRootExpr($expr); + if ($leftExpr instanceof AlwaysRememberedExpr) { + $leftTypes = $leftTypes->unionWith($this->create( + $unwrappedLeftExpr, + $rightType, + $context, + $scope, + ))->setRootExpr($expr); + } + if ($types !== null) { + $types = $types->unionWith($leftTypes); + } else { + $types = $leftTypes; + } + } + + if ($types !== null) { + return $types; + } + + $leftExprString = $this->exprPrinter->printExpr($unwrappedLeftExpr); + $rightExprString = $this->exprPrinter->printExpr($unwrappedRightExpr); + if ($leftExprString === $rightExprString) { + if (!$unwrappedLeftExpr instanceof Expr\Variable || !$unwrappedRightExpr instanceof Expr\Variable) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + } + + if ($context->true()) { + $leftTypes = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); + if ($leftExpr instanceof AlwaysRememberedExpr) { + $leftTypes = $leftTypes->unionWith( + $this->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr), + ); + } + if ($rightExpr instanceof AlwaysRememberedExpr) { + $rightTypes = $rightTypes->unionWith( + $this->create($unwrappedRightExpr, $leftType, $context, $scope)->setRootExpr($expr), + ); + } + return $leftTypes->unionWith($rightTypes); + } elseif ($context->false()) { + return $this->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) + ->intersectWith($this->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope)); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + +} diff --git a/src/Analyser/TypeSpecifierAwareExtension.php b/src/Analyser/TypeSpecifierAwareExtension.php new file mode 100644 index 00000000..24d51cbf --- /dev/null +++ b/src/Analyser/TypeSpecifierAwareExtension.php @@ -0,0 +1,12 @@ +value === null) { + throw new ShouldNotHappenException(); + } + return self::create(~$this->value & self::CONTEXT_BITMASK); + } + + public function true(): bool + { + return $this->value !== null && (bool) ($this->value & self::CONTEXT_TRUE); + } + + public function truthy(): bool + { + return $this->value !== null && (bool) ($this->value & self::CONTEXT_TRUTHY); + } + + public function false(): bool + { + return $this->value !== null && (bool) ($this->value & self::CONTEXT_FALSE); + } + + public function falsey(): bool + { + return $this->value !== null && (bool) ($this->value & self::CONTEXT_FALSEY); + } + + public function null(): bool + { + return $this->value === null; + } + +} diff --git a/src/Analyser/TypeSpecifierFactory.php b/src/Analyser/TypeSpecifierFactory.php new file mode 100644 index 00000000..b907084d --- /dev/null +++ b/src/Analyser/TypeSpecifierFactory.php @@ -0,0 +1,53 @@ +container->getByType(ExprPrinter::class), + $this->container->getByType(ReflectionProvider::class), + $this->container->getByType(PhpVersion::class), + $this->container->getServicesByTag(self::FUNCTION_TYPE_SPECIFYING_EXTENSION_TAG), + $this->container->getServicesByTag(self::METHOD_TYPE_SPECIFYING_EXTENSION_TAG), + $this->container->getServicesByTag(self::STATIC_METHOD_TYPE_SPECIFYING_EXTENSION_TAG), + $this->container->getParameter('rememberPossiblyImpureFunctionValues'), + ); + + foreach (array_merge( + $this->container->getServicesByTag(BrokerFactory::PROPERTIES_CLASS_REFLECTION_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::METHODS_CLASS_REFLECTION_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_METHOD_RETURN_TYPE_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_STATIC_METHOD_RETURN_TYPE_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_FUNCTION_RETURN_TYPE_EXTENSION_TAG), + ) as $extension) { + if (!($extension instanceof TypeSpecifierAwareExtension)) { + continue; + } + + $extension->setTypeSpecifier($typeSpecifier); + } + + return $typeSpecifier; + } + +} diff --git a/src/Analyser/UndefinedVariableException.php b/src/Analyser/UndefinedVariableException.php new file mode 100644 index 00000000..23c68f3e --- /dev/null +++ b/src/Analyser/UndefinedVariableException.php @@ -0,0 +1,32 @@ +scope; + } + + public function getVariableName(): string + { + return $this->variableName; + } + + public function getTip(): ?string + { + return null; + } + +} diff --git a/src/Broker/AnonymousClassNameHelper.php b/src/Broker/AnonymousClassNameHelper.php new file mode 100644 index 00000000..93b8dc39 --- /dev/null +++ b/src/Broker/AnonymousClassNameHelper.php @@ -0,0 +1,51 @@ +namespacedName)) { + throw new ShouldNotHappenException(); + } + + $filename = $this->relativePathHelper->getRelativePath( + $this->fileHelper->normalizePath($filename, '/'), + ); + + /** @var int|null $lineIndex */ + $lineIndex = $classNode->getAttribute(AnonymousClassVisitor::ATTRIBUTE_LINE_INDEX); + if ($lineIndex === null) { + $hash = md5(sprintf('%s:%s', $filename, $classNode->getStartLine())); + } else { + $hash = md5(sprintf('%s:%s:%d', $filename, $classNode->getStartLine(), $lineIndex)); + } + + return sprintf( + 'AnonymousClass%s', + $hash, + ); + } + +} diff --git a/src/Broker/BrokerFactory.php b/src/Broker/BrokerFactory.php new file mode 100644 index 00000000..1ef3c23d --- /dev/null +++ b/src/Broker/BrokerFactory.php @@ -0,0 +1,18 @@ +getMessage(), + $functionName, + ), 0, $previous); + } else { + parent::__construct(sprintf( + 'Class %s not found.', + $functionName, + ), 0); + } + + $this->className = $functionName; + } + + public function getClassName(): string + { + return $this->className; + } + + public function getTip(): string + { + return 'Learn more at https://phpstan.org/user-guide/discovering-symbols'; + } + +} diff --git a/src/Broker/ClassNotFoundException.php b/src/Broker/ClassNotFoundException.php new file mode 100644 index 00000000..cca1d5cd --- /dev/null +++ b/src/Broker/ClassNotFoundException.php @@ -0,0 +1,27 @@ +className; + } + + public function getTip(): string + { + return 'Learn more at https://phpstan.org/user-guide/discovering-symbols'; + } + +} diff --git a/src/Broker/ConstantNotFoundException.php b/src/Broker/ConstantNotFoundException.php new file mode 100644 index 00000000..4f0a9436 --- /dev/null +++ b/src/Broker/ConstantNotFoundException.php @@ -0,0 +1,27 @@ +constantName; + } + + public function getTip(): string + { + return 'Learn more at https://phpstan.org/user-guide/discovering-symbols'; + } + +} diff --git a/src/Broker/FunctionNotFoundException.php b/src/Broker/FunctionNotFoundException.php new file mode 100644 index 00000000..387c80c6 --- /dev/null +++ b/src/Broker/FunctionNotFoundException.php @@ -0,0 +1,27 @@ +functionName; + } + + public function getTip(): string + { + return 'Learn more at https://phpstan.org/user-guide/discovering-symbols'; + } + +} diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php new file mode 100644 index 00000000..ac388f8b --- /dev/null +++ b/src/Cache/Cache.php @@ -0,0 +1,29 @@ +storage->load($key, $variableKey); + } + + /** + * @param mixed $data + */ + public function save(string $key, string $variableKey, $data): void + { + $this->storage->save($key, $variableKey, $data); + } + +} diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php new file mode 100644 index 00000000..973e648d --- /dev/null +++ b/src/Cache/CacheItem.php @@ -0,0 +1,37 @@ +variableKey === $variableKey; + } + + /** + * @return mixed + */ + public function getData() + { + return $this->data; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self($properties['variableKey'], $properties['data']); + } + +} diff --git a/src/Cache/CacheStorage.php b/src/Cache/CacheStorage.php new file mode 100644 index 00000000..da28db72 --- /dev/null +++ b/src/Cache/CacheStorage.php @@ -0,0 +1,19 @@ +getFilePaths($key); + + return (static function () use ($variableKey, $filePath) { + $cacheItem = @include $filePath; + if (!$cacheItem instanceof CacheItem) { + return null; + } + if (!$cacheItem->isVariableKeyValid($variableKey)) { + return null; + } + + return $cacheItem->getData(); + })(); + } + + /** + * @param mixed $data + * @throws DirectoryCreatorException + */ + public function save(string $key, string $variableKey, $data): void + { + [$firstDirectory, $secondDirectory, $path] = $this->getFilePaths($key); + DirectoryCreator::ensureDirectoryExists($this->directory, 0777); + DirectoryCreator::ensureDirectoryExists($firstDirectory, 0777); + DirectoryCreator::ensureDirectoryExists($secondDirectory, 0777); + + $tmpPath = sprintf('%s/%s.tmp', $this->directory, Random::generate()); + $errorBefore = error_get_last(); + $exported = @var_export(new CacheItem($variableKey, $data), true); + $errorAfter = error_get_last(); + if ($errorAfter !== null && $errorBefore !== $errorAfter) { + throw new ShouldNotHappenException(sprintf('Error occurred while saving item %s (%s) to cache: %s', $key, $variableKey, $errorAfter['message'])); + } + FileWriter::write( + $tmpPath, + sprintf( + "directory, substr($keyHash, 0, 2)); + $secondDirectory = sprintf('%s/%s', $firstDirectory, substr($keyHash, 2, 2)); + $filePath = sprintf('%s/%s.php', $secondDirectory, $keyHash); + + return [ + $firstDirectory, + $secondDirectory, + $filePath, + ]; + } + + public function clearUnusedFiles(): void + { + if (!is_dir($this->directory)) { + return; + } + + $cachedClearedFile = $this->directory . '/cache-cleared'; + if (is_file($cachedClearedFile)) { + try { + $cachedClearedContents = FileReader::read($cachedClearedFile); + if ($cachedClearedContents === self::CACHED_CLEARED_VERSION) { + return; + } + } catch (CouldNotReadFileException) { + return; + } + } + + $iterator = new RecursiveDirectoryIterator($this->directory); + $iterator->setFlags(RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($iterator); + $beginFunction = sprintf( + "getPathname(); + $contents = FileReader::read($path); + if ( + !str_starts_with($contents, $beginFunction) + && !str_starts_with($contents, $beginMethod) + && str_starts_with($contents, $beginNew) + ) { + continue; + } + + $emptyDirectoriesToCheck[dirname($path)] = true; + $emptyDirectoriesToCheck[dirname($path, 2)] = true; + + @unlink($path); + } catch (CouldNotReadFileException) { + continue; + } + } + + uksort($emptyDirectoriesToCheck, static fn ($a, $b) => strlen($b) - strlen($a)); + + foreach (array_keys($emptyDirectoriesToCheck) as $directory) { + if (!$this->isDirectoryEmpty($directory)) { + continue; + } + + @rmdir($directory); + } + + try { + FileWriter::write($cachedClearedFile, self::CACHED_CLEARED_VERSION); + } catch (CouldNotWriteFileException) { + // pass + } + } + + private function isDirectoryEmpty(string $directory): bool + { + $handle = opendir($directory); + if ($handle === false) { + return false; + } + while (($entry = readdir($handle)) !== false) { + if ($entry !== '.' && $entry !== '..') { + closedir($handle); + return false; + } + } + + closedir($handle); + return true; + } + +} diff --git a/src/Cache/MemoryCacheStorage.php b/src/Cache/MemoryCacheStorage.php new file mode 100644 index 00000000..e764fa01 --- /dev/null +++ b/src/Cache/MemoryCacheStorage.php @@ -0,0 +1,41 @@ + */ + private array $storage = []; + + /** + * @return mixed|null + */ + public function load(string $key, string $variableKey) + { + if (!isset($this->storage[$key])) { + return null; + } + + $item = $this->storage[$key]; + if (!$item->isVariableKeyValid($variableKey)) { + return null; + } + + return $item->getData(); + } + + /** + * @param mixed $data + */ + public function save(string $key, string $variableKey, $data): void + { + $item = new CacheItem($variableKey, $data); + @var_export($item, true); + $this->storage[$key] = $item; + } + +} diff --git a/src/Classes/ForbiddenClassNameExtension.php b/src/Classes/ForbiddenClassNameExtension.php new file mode 100644 index 00000000..1001bbb8 --- /dev/null +++ b/src/Classes/ForbiddenClassNameExtension.php @@ -0,0 +1,33 @@ + */ + public function getClassPrefixes(): array; + +} diff --git a/src/Collectors/CollectedData.php b/src/Collectors/CollectedData.php new file mode 100644 index 00000000..bf50ae2b --- /dev/null +++ b/src/Collectors/CollectedData.php @@ -0,0 +1,88 @@ +> $collectorType + */ + public function __construct( + private $data, + private string $filePath, + private string $collectorType, + ) + { + } + + public function getData(): mixed + { + return $this->data; + } + + public function getFilePath(): string + { + return $this->filePath; + } + + public function changeFilePath(string $newFilePath): self + { + return new self($this->data, $newFilePath, $this->collectorType); + } + + /** + * @return class-string> + */ + public function getCollectorType(): string + { + return $this->collectorType; + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'data' => $this->data, + 'filePath' => $this->filePath, + 'collectorType' => $this->collectorType, + ]; + } + + /** + * @param mixed[] $json + */ + public static function decode(array $json): self + { + return new self( + $json['data'], + $json['filePath'], + $json['collectorType'], + ); + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['data'], + $properties['filePath'], + $properties['collectorType'], + ); + } + +} diff --git a/src/Collectors/Collector.php b/src/Collectors/Collector.php new file mode 100644 index 00000000..658e2af5 --- /dev/null +++ b/src/Collectors/Collector.php @@ -0,0 +1,41 @@ + + */ + public function getNodeType(): string; + + /** + * @param TNodeType $node + * @return TValue|null Collected data + */ + public function processNode(Node $node, Scope $scope); + +} diff --git a/src/Collectors/Registry.php b/src/Collectors/Registry.php new file mode 100644 index 00000000..530ef6a9 --- /dev/null +++ b/src/Collectors/Registry.php @@ -0,0 +1,57 @@ +collectors[$collector->getNodeType()][] = $collector; + } + } + + /** + * @template TNodeType of Node + * @param class-string $nodeType + * @return array> + */ + public function getCollectors(string $nodeType): array + { + if (!isset($this->cache[$nodeType])) { + $parentNodeTypes = [$nodeType] + class_parents($nodeType) + class_implements($nodeType); + + $collectors = []; + foreach ($parentNodeTypes as $parentNodeType) { + foreach ($this->collectors[$parentNodeType] ?? [] as $collector) { + $collectors[] = $collector; + } + } + + $this->cache[$nodeType] = $collectors; + } + + /** + * @var array> $selectedCollectors + */ + $selectedCollectors = $this->cache[$nodeType]; + + return $selectedCollectors; + } + +} diff --git a/src/Collectors/RegistryFactory.php b/src/Collectors/RegistryFactory.php new file mode 100644 index 00000000..7ed4aa41 --- /dev/null +++ b/src/Collectors/RegistryFactory.php @@ -0,0 +1,24 @@ +container->getServicesByTag(self::COLLECTOR_TAG), + ); + } + +} diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php new file mode 100644 index 00000000..be912d83 --- /dev/null +++ b/src/Command/AnalyseApplication.php @@ -0,0 +1,225 @@ +resultCacheManagerFactory->create(); + + $ignoredErrorHelperResult = $this->ignoredErrorHelper->initialize(); + $fileSpecificErrors = []; + if (count($ignoredErrorHelperResult->getErrors()) > 0) { + $notFileSpecificErrors = $ignoredErrorHelperResult->getErrors(); + $internalErrors = []; + $collectedData = []; + $savedResultCache = false; + $memoryUsageBytes = memory_get_peak_usage(true); + if ($errorOutput->isVeryVerbose()) { + $errorOutput->writeLineFormatted('Result cache was not saved because of ignoredErrorHelperResult errors.'); + } + $changedProjectExtensionFilesOutsideOfAnalysedPaths = []; + } else { + $resultCache = $resultCacheManager->restore($files, $debug, $onlyFiles, $projectConfigArray, $errorOutput); + $intermediateAnalyserResult = $this->runAnalyser( + $resultCache->getFilesToAnalyse(), + $files, + $debug, + $projectConfigFile, + $stdOutput, + $errorOutput, + $input, + ); + + $projectStubFiles = $this->stubFilesProvider->getProjectStubFiles(); + + $forceValidateStubFiles = (bool) ($_SERVER['__PHPSTAN_FORCE_VALIDATE_STUB_FILES'] ?? false); + if ( + $resultCache->isFullAnalysis() + && count($projectStubFiles) !== 0 + && (!$onlyFiles || $forceValidateStubFiles) + ) { + $stubErrors = $this->stubValidator->validate($projectStubFiles, $debug); + $intermediateAnalyserResult = new AnalyserResult( + array_merge($intermediateAnalyserResult->getUnorderedErrors(), $stubErrors), + $intermediateAnalyserResult->getFilteredPhpErrors(), + $intermediateAnalyserResult->getAllPhpErrors(), + $intermediateAnalyserResult->getLocallyIgnoredErrors(), + $intermediateAnalyserResult->getLinesToIgnore(), + $intermediateAnalyserResult->getUnmatchedLineIgnores(), + $intermediateAnalyserResult->getInternalErrors(), + $intermediateAnalyserResult->getCollectedData(), + $intermediateAnalyserResult->getDependencies(), + $intermediateAnalyserResult->getExportedNodes(), + $intermediateAnalyserResult->hasReachedInternalErrorsCountLimit(), + $intermediateAnalyserResult->getPeakMemoryUsageBytes(), + ); + } + + $resultCacheResult = $resultCacheManager->process($intermediateAnalyserResult, $resultCache, $errorOutput, $onlyFiles, true); + $analyserResult = $this->analyserResultFinalizer->finalize($resultCacheResult->getAnalyserResult(), $onlyFiles, $debug)->getAnalyserResult(); + $internalErrors = $analyserResult->getInternalErrors(); + $errors = array_merge( + $analyserResult->getErrors(), + $analyserResult->getFilteredPhpErrors(), + ); + $hasInternalErrors = count($internalErrors) > 0 || $analyserResult->hasReachedInternalErrorsCountLimit(); + $memoryUsageBytes = $analyserResult->getPeakMemoryUsageBytes(); + $isResultCacheUsed = !$resultCache->isFullAnalysis(); + + $changedProjectExtensionFilesOutsideOfAnalysedPaths = []; + if ( + $isResultCacheUsed + && $resultCacheResult->isSaved() + && !$onlyFiles + && $projectConfigArray !== null + ) { + foreach ($resultCache->getProjectExtensionFiles() as $file => [$hash, $isAnalysed, $className]) { + if ($isAnalysed) { + continue; + } + + if (!is_file($file)) { + $changedProjectExtensionFilesOutsideOfAnalysedPaths[$file] = $className; + continue; + } + + $newHash = sha1_file($file); + if ($newHash === $hash) { + continue; + } + + $changedProjectExtensionFilesOutsideOfAnalysedPaths[$file] = $className; + } + } + + $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $files, $hasInternalErrors); + $fileSpecificErrors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors(); + $notFileSpecificErrors = $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages(); + $collectedData = $analyserResult->getCollectedData(); + $savedResultCache = $resultCacheResult->isSaved(); + } + + return new AnalysisResult( + $fileSpecificErrors, + $notFileSpecificErrors, + $internalErrors, + [], + $collectedData, + $defaultLevelUsed, + $projectConfigFile, + $savedResultCache, + $memoryUsageBytes, + $isResultCacheUsed, + $changedProjectExtensionFilesOutsideOfAnalysedPaths, + ); + } + + /** + * @param string[] $files + * @param string[] $allAnalysedFiles + */ + private function runAnalyser( + array $files, + array $allAnalysedFiles, + bool $debug, + ?string $projectConfigFile, + Output $stdOutput, + Output $errorOutput, + InputInterface $input, + ): AnalyserResult + { + $filesCount = count($files); + $allAnalysedFilesCount = count($allAnalysedFiles); + if ($filesCount === 0) { + $errorOutput->getStyle()->progressStart($allAnalysedFilesCount); + $errorOutput->getStyle()->progressAdvance($allAnalysedFilesCount); + $errorOutput->getStyle()->progressFinish(); + return new AnalyserResult([], [], [], [], [], [], [], [], [], [], false, memory_get_peak_usage(true)); + } + + if (!$debug) { + $preFileCallback = null; + $postFileCallback = static function (int $step) use ($errorOutput): void { + $errorOutput->getStyle()->progressAdvance($step); + }; + + $errorOutput->getStyle()->progressStart($allAnalysedFilesCount); + $errorOutput->getStyle()->progressAdvance($allAnalysedFilesCount - $filesCount); + } else { + $startTime = null; + $preFileCallback = static function (string $file) use ($stdOutput, &$startTime): void { + $stdOutput->writeLineFormatted($file); + $startTime = microtime(true); + }; + $postFileCallback = null; + if ($stdOutput->isDebug()) { + $previousMemory = memory_get_peak_usage(true); + $postFileCallback = static function () use ($stdOutput, &$previousMemory, &$startTime): void { + if ($startTime === null) { + throw new ShouldNotHappenException(); + } + $currentTotalMemory = memory_get_peak_usage(true); + $elapsedTime = microtime(true) - $startTime; + $stdOutput->writeLineFormatted(sprintf('--- consumed %s, total %s, took %.2f s', BytesHelper::bytes($currentTotalMemory - $previousMemory), BytesHelper::bytes($currentTotalMemory), $elapsedTime)); + $previousMemory = $currentTotalMemory; + }; + } + } + + $analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $input); + + if (!$debug) { + $errorOutput->getStyle()->progressFinish(); + } + + return $analyserResult; + } + +} diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php new file mode 100644 index 00000000..241d833e --- /dev/null +++ b/src/Command/AnalyseCommand.php @@ -0,0 +1,720 @@ +setName(self::NAME) + ->setDescription('Analyses source code') + ->setDefinition([ + new InputArgument('paths', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Paths with source code to run analysis on'), + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption(self::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), + new InputOption(ErrorsConsoleStyle::OPTION_NO_PROGRESS, null, InputOption::VALUE_NONE, 'Do not show progress bar, only results'), + new InputOption('debug', null, InputOption::VALUE_NONE, 'Show debug information - which file is analysed, do not catch internal errors'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('error-format', null, InputOption::VALUE_REQUIRED, 'Format in which to print the result of the analysis', null), + new InputOption('generate-baseline', 'b', InputOption::VALUE_OPTIONAL, 'Path to a file where the baseline should be saved', false), + new InputOption('allow-empty-baseline', null, InputOption::VALUE_NONE, 'Do not error out when the generated baseline is empty'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for analysis'), + new InputOption('xdebug', null, InputOption::VALUE_NONE, 'Allow running with Xdebug for debugging purposes'), + new InputOption('fix', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'), + new InputOption('watch', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'), + new InputOption('pro', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'), + new InputOption('fail-without-result-cache', null, InputOption::VALUE_NONE, 'Return non-zero exit code when result cache is not used'), + ]); + } + + /** + * @return string[] + */ + public function getAliases(): array + { + return ['analyze']; + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ((bool) $input->getOption('debug')) { + $application = $this->getApplication(); + if ($application === null) { + return; + } + $application->setCatchExceptions(false); + return; + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getArgument('paths'); + $memoryLimit = $input->getOption('memory-limit'); + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $level = $input->getOption(self::OPTION_LEVEL); + $allowXdebug = $input->getOption('xdebug'); + $debugEnabled = (bool) $input->getOption('debug'); + $fix = (bool) $input->getOption('fix') || (bool) $input->getOption('watch') || (bool) $input->getOption('pro'); + $failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache'); + + /** @var string|false|null $generateBaselineFile */ + $generateBaselineFile = $input->getOption('generate-baseline'); + if ($generateBaselineFile === false) { + $generateBaselineFile = null; + } elseif ($generateBaselineFile === null) { + $generateBaselineFile = 'phpstan-baseline.neon'; + } + + $allowEmptyBaseline = (bool) $input->getOption('allow-empty-baseline'); + + if ( + !is_array($paths) + || (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($level) && $level !== null) + || (!is_bool($allowXdebug)) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + $paths, + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + $generateBaselineFile, + $level, + $allowXdebug, + $debugEnabled, + true, + ); + } catch (InceptionNotSuccessfulException $e) { + return 1; + } + + if ($generateBaselineFile === null && $allowEmptyBaseline) { + $inceptionResult->getStdOutput()->getStyle()->error('You must pass the --generate-baseline option alongside --allow-empty-baseline.'); + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + $errorOutput = $inceptionResult->getErrorOutput(); + $errorFormat = $input->getOption('error-format'); + + if (!is_string($errorFormat) && $errorFormat !== null) { + throw new ShouldNotHappenException(); + } + + if ($errorFormat === null) { + $errorFormat = $inceptionResult->getContainer()->getParameter('errorFormat'); + } + + if ($errorFormat === null) { + $errorFormat = 'table'; + } + + $container = $inceptionResult->getContainer(); + $errorFormatterServiceName = sprintf('errorFormatter.%s', $errorFormat); + if (!$container->hasService($errorFormatterServiceName)) { + $errorOutput->writeLineFormatted(sprintf( + 'Error formatter "%s" not found. Available error formatters are: %s', + $errorFormat, + implode(', ', array_map(static fn (string $name): string => substr($name, strlen('errorFormatter.')), $container->findServiceNamesByType(ErrorFormatter::class))), + )); + return 1; + } + + $generateBaselineFile = $inceptionResult->getGenerateBaselineFile(); + if ($generateBaselineFile !== null) { + $baselineExtension = pathinfo($generateBaselineFile, PATHINFO_EXTENSION); + if ($baselineExtension === '') { + $inceptionResult->getStdOutput()->getStyle()->error(sprintf('Baseline filename must have an extension, %s provided instead.', pathinfo($generateBaselineFile, PATHINFO_BASENAME))); + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + if (!in_array($baselineExtension, ['neon', 'php'], true)) { + $inceptionResult->getStdOutput()->getStyle()->error(sprintf('Baseline filename extension must be .neon or .php, .%s was used instead.', $baselineExtension)); + + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + } + + try { + [$files, $onlyFiles] = $inceptionResult->getFiles(); + } catch (PathNotFoundException $e) { + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + $inceptionResult->getErrorOutput()->writeLineFormatted(sprintf('%s', $e->getMessage())); + return 1; + } catch (InceptionNotSuccessfulException) { + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + return 1; + } + + if (count($files) === 0) { + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + + $inceptionResult->getErrorOutput()->getStyle()->error('No files found to analyse.'); + + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + $analysedConfigFiles = array_intersect($files, $container->getParameter('allConfigFiles')); + /** @var RelativePathHelper $relativePathHelper */ + $relativePathHelper = $container->getService('relativePathHelper'); + foreach ($analysedConfigFiles as $analysedConfigFile) { + $fileSize = @filesize($analysedConfigFile); + if ($fileSize === false) { + continue; + } + + if ($fileSize <= 512 * 1024) { + continue; + } + + $inceptionResult->getErrorOutput()->getStyle()->warning(sprintf( + 'Configuration file %s (%s) is too big and might slow down PHPStan. Consider adding it to excludePaths.', + $relativePathHelper->getRelativePath($analysedConfigFile), + BytesHelper::bytes($fileSize), + )); + } + + if ($fix) { + if ($generateBaselineFile !== null) { + $inceptionResult->getStdOutput()->getStyle()->error('You cannot pass the --generate-baseline option when running PHPStan Pro.'); + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + return $this->runFixer($inceptionResult, $container, $onlyFiles, $input, $output, $files); + } + + /** @var AnalyseApplication $application */ + $application = $container->getByType(AnalyseApplication::class); + + $debug = $input->getOption('debug'); + if (!is_bool($debug)) { + throw new ShouldNotHappenException(); + } + + try { + $analysisResult = $application->analyse( + $files, + $onlyFiles, + $inceptionResult->getStdOutput(), + $inceptionResult->getErrorOutput(), + $inceptionResult->isDefaultLevelUsed(), + $debug, + $inceptionResult->getProjectConfigFile(), + $inceptionResult->getProjectConfigArray(), + $input, + ); + } catch (Throwable $t) { + if ($debug) { + $stdOutput = $inceptionResult->getStdOutput(); + $stdOutput->writeRaw(sprintf( + 'Uncaught %s: %s in %s:%d', + get_class($t), + $t->getMessage(), + $t->getFile(), + $t->getLine(), + )); + $stdOutput->writeLineFormatted(''); + $stdOutput->writeRaw($t->getTraceAsString()); + $stdOutput->writeLineFormatted(''); + + $previous = $t->getPrevious(); + while ($previous !== null) { + $stdOutput->writeLineFormatted(''); + $stdOutput->writeLineFormatted('Caused by:'); + $stdOutput->writeRaw(sprintf( + 'Uncaught %s: %s in %s:%d', + get_class($previous), + $previous->getMessage(), + $previous->getFile(), + $previous->getLine(), + )); + $stdOutput->writeRaw($previous->getTraceAsString()); + $stdOutput->writeLineFormatted(''); + $previous = $previous->getPrevious(); + } + + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + throw $t; + } + + /** + * Variable $internalErrorsTuples contains both "internal errors" + * and "errors with non-ignorable exception" as InternalError objects. + */ + $internalErrorsTuples = []; + $internalFileSpecificErrors = []; + foreach ($analysisResult->getInternalErrorObjects() as $internalError) { + $internalErrorsTuples[$internalError->getMessage()] = [new InternalError( + $internalError->getTraceAsString() !== null ? sprintf('Internal error: %s', $internalError->getMessage()) : $internalError->getMessage(), + $internalError->getContextDescription(), + $internalError->getTrace(), + $internalError->getTraceAsString(), + $internalError->shouldReportBug(), + ), false]; + } + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if (!$fileSpecificError->hasNonIgnorableException()) { + continue; + } + + $message = $fileSpecificError->getMessage(); + $metadata = $fileSpecificError->getMetadata(); + $hasStackTrace = false; + if ( + $fileSpecificError->getIdentifier() === 'phpstan.internal' + && array_key_exists(InternalError::STACK_TRACE_AS_STRING_METADATA_KEY, $metadata) + ) { + $message = sprintf('Internal error: %s', $message); + $hasStackTrace = true; + } + + if (!$hasStackTrace) { + if (!array_key_exists($fileSpecificError->getMessage(), $internalFileSpecificErrors)) { + $internalFileSpecificErrors[$fileSpecificError->getMessage()] = $fileSpecificError; + } + } + + $internalErrorsTuples[$fileSpecificError->getMessage()] = [new InternalError( + $message, + sprintf('analysing file %s', $fileSpecificError->getTraitFilePath() ?? $fileSpecificError->getFilePath()), + $metadata[InternalError::STACK_TRACE_METADATA_KEY] ?? [], + $metadata[InternalError::STACK_TRACE_AS_STRING_METADATA_KEY] ?? null, + true, + ), !$hasStackTrace]; + } + + $internalErrorsTuples = array_values($internalErrorsTuples); + + $fileHelper = $container->getByType(FileHelper::class); + + /** + * Variable $internalErrors only contains non-file-specific "internal errors". + */ + $internalErrors = []; + foreach ($internalErrorsTuples as [$internalError, $isInFileSpecificErrors]) { + if ($isInFileSpecificErrors) { + continue; + } + + $internalErrors[] = new InternalError( + $this->getMessageFromInternalError($fileHelper, $internalError, $output->getVerbosity()), + $internalError->getContextDescription(), + $internalError->getTrace(), + $internalError->getTraceAsString(), + $internalError->shouldReportBug(), + ); + } + + if ($generateBaselineFile !== null) { + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + if (count($internalErrorsTuples) > 0) { + foreach ($internalErrorsTuples as [$internalError]) { + $inceptionResult->getStdOutput()->writeLineFormatted($internalError->getMessage()); + $inceptionResult->getStdOutput()->writeLineFormatted(''); + } + + $inceptionResult->getStdOutput()->getStyle()->error(sprintf( + '%s occurred. Baseline could not be generated.', + count($internalErrors) === 1 ? 'An internal error' : 'Internal errors', + )); + + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + + return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache); + } + + /** @var ErrorFormatter $errorFormatter */ + $errorFormatter = $container->getService($errorFormatterServiceName); + + if (count($internalErrorsTuples) > 0) { + $analysisResult = new AnalysisResult( + array_values($internalFileSpecificErrors), + array_map(static fn (InternalError $internalError) => $internalError->getMessage(), $internalErrors), + [], + [], + [], + $analysisResult->isDefaultLevelUsed(), + $analysisResult->getProjectConfigFile(), + $analysisResult->isResultCacheSaved(), + $analysisResult->getPeakMemoryUsageBytes(), + $analysisResult->isResultCacheUsed(), + $analysisResult->getChangedProjectExtensionFilesOutsideOfAnalysedPaths(), + ); + + $exitCode = $errorFormatter->formatErrors($analysisResult, $inceptionResult->getStdOutput()); + + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + + $errorOutput->writeLineFormatted('⚠️ Result is incomplete because of severe errors. ⚠️'); + $errorOutput->writeLineFormatted(' Fix these errors first and then re-run PHPStan'); + $errorOutput->writeLineFormatted(' to get all reported errors.'); + $errorOutput->writeLineFormatted(''); + + return $inceptionResult->handleReturn( + $exitCode, + $analysisResult->getPeakMemoryUsageBytes(), + $this->analysisStartTime, + ); + } + + $exitCode = $errorFormatter->formatErrors($analysisResult, $inceptionResult->getStdOutput()); + if ($failWithoutResultCache && !$analysisResult->isResultCacheUsed()) { + $exitCode = 2; + } + + if ( + $analysisResult->isResultCacheUsed() + && $analysisResult->isResultCacheSaved() + && !$onlyFiles + && $inceptionResult->getProjectConfigArray() !== null + ) { + $projectServicesNotInAnalysedPaths = array_values(array_unique($analysisResult->getChangedProjectExtensionFilesOutsideOfAnalysedPaths())); + $projectServiceFileNamesNotInAnalysedPaths = array_keys($analysisResult->getChangedProjectExtensionFilesOutsideOfAnalysedPaths()); + + if (count($projectServicesNotInAnalysedPaths) > 0) { + $one = count($projectServicesNotInAnalysedPaths) === 1; + $errorOutput->writeLineFormatted('Result cache might not behave correctly.'); + $errorOutput->writeLineFormatted(sprintf('You\'re using custom %s in your project config', $one ? 'extension' : 'extensions')); + $errorOutput->writeLineFormatted(sprintf('but %s not part of analysed paths:', $one ? 'this extension is' : 'these extensions are')); + $errorOutput->writeLineFormatted(''); + foreach ($projectServicesNotInAnalysedPaths as $service) { + $errorOutput->writeLineFormatted(sprintf('- %s', $service)); + } + + $errorOutput->writeLineFormatted(''); + + $errorOutput->writeLineFormatted('When you edit them and re-run PHPStan, the result cache will get stale.'); + + $directoriesToAdd = []; + foreach ($projectServiceFileNamesNotInAnalysedPaths as $path) { + $directoriesToAdd[] = dirname($relativePathHelper->getRelativePath($path)); + } + + $directoriesToAdd = array_unique($directoriesToAdd); + $oneDirectory = count($directoriesToAdd) === 1; + + $errorOutput->writeLineFormatted(sprintf('Add %s to your analysed paths to get rid of this problem:', $oneDirectory ? 'this directory' : 'these directories')); + + $errorOutput->writeLineFormatted(''); + + foreach ($directoriesToAdd as $directory) { + $errorOutput->writeLineFormatted(sprintf('- %s', $directory)); + } + + $errorOutput->writeLineFormatted(''); + + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + } + + $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput()); + + return $inceptionResult->handleReturn( + $exitCode, + $analysisResult->getPeakMemoryUsageBytes(), + $this->analysisStartTime, + ); + } + + private function createStreamOutput(): StreamOutput + { + $resource = fopen('php://memory', 'w', false); + if ($resource === false) { + throw new ShouldNotHappenException(); + } + return new StreamOutput($resource); + } + + private function getMessageFromInternalError(FileHelper $fileHelper, InternalError $internalError, int $verbosity): string + { + $message = sprintf('%s while %s', $internalError->getMessage(), $internalError->getContextDescription()); + $hasLarastan = false; + $isLaravelLast = false; + + foreach (array_reverse($internalError->getTrace()) as $traceItem) { + if ($traceItem['file'] === null) { + continue; + } + + $file = $fileHelper->normalizePath($traceItem['file'], '/'); + + if (str_contains($file, '/larastan/')) { + $hasLarastan = true; + $isLaravelLast = false; + continue; + } + + if (!str_contains($file, '/laravel/framework/')) { + continue; + } + + $isLaravelLast = true; + } + if ($hasLarastan) { + if ($isLaravelLast) { + $message .= "\n"; + $message .= "\n" . 'This message is coming from Laravel Framework itself.'; + $message .= "\n" . 'Larastan boots up your application in order to provide'; + $message .= "\n" . 'smarter static analysis of your codebase.'; + $message .= "\n"; + $message .= "\n" . 'In order to do that, the environment you run PHPStan in'; + $message .= "\n" . 'must match the environment you run your application in.'; + $message .= "\n"; + $message .= "\n" . 'Make sure you\'ve set your environment variables'; + $message .= "\n" . 'or the .env file correctly.'; + + return $message; + } + + $bugReportUrl = 'https://github.com/larastan/larastan/issues/new?template=bug-report.md'; + } else { + $bugReportUrl = 'https://github.com/phpstan/phpstan/issues/new?template=Bug_report.yaml'; + } + if ($internalError->getTraceAsString() !== null) { + if (OutputInterface::VERBOSITY_VERBOSE <= $verbosity) { + $firstTraceItem = $internalError->getTrace()[0] ?? null; + $trace = ''; + if ($firstTraceItem !== null && $firstTraceItem['file'] !== null && $firstTraceItem['line'] !== null) { + $trace = sprintf('## %s(%d)%s', $firstTraceItem['file'], $firstTraceItem['line'], "\n"); + } + $trace .= $internalError->getTraceAsString(); + + if ($internalError->shouldReportBug()) { + $message .= sprintf('%sPost the following stack trace to %s: %s%s', "\n", $bugReportUrl, "\n", $trace); + } else { + $message .= sprintf('%s%s', "\n\n", $trace); + } + } else { + if ($internalError->shouldReportBug()) { + $message .= sprintf('%sRun PHPStan with -v option and post the stack trace to:%s%s%s', "\n\n", "\n", $bugReportUrl, "\n"); + } else { + $message .= sprintf('%sRun PHPStan with -v option to see the stack trace', "\n"); + } + } + } + + return $message; + } + + private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache): int + { + if (!$allowEmptyBaseline && !$analysisResult->hasErrors()) { + $inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.'); + $inceptionResult->getStdOutput()->writeLineFormatted('To allow generating empty baselines, pass --allow-empty-baseline option.'); + + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + + $streamOutput = $this->createStreamOutput(); + $errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput); + $baselineOutput = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle)); + $baselineFileDirectory = dirname($generateBaselineFile); + $baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory); + + if ($baselineExtension === 'php') { + $baselineErrorFormatter = new BaselinePhpErrorFormatter($baselinePathHelper); + $baselineErrorFormatter->formatErrors($analysisResult, $baselineOutput); + } else { + $baselineErrorFormatter = new BaselineNeonErrorFormatter($baselinePathHelper); + $existingBaselineContent = is_file($generateBaselineFile) ? FileReader::read($generateBaselineFile) : ''; + $baselineErrorFormatter->formatErrors($analysisResult, $baselineOutput, $existingBaselineContent); + } + + $stream = $streamOutput->getStream(); + rewind($stream); + $baselineContents = stream_get_contents($stream); + if ($baselineContents === false) { + throw new ShouldNotHappenException(); + } + + try { + DirectoryCreator::ensureDirectoryExists($baselineFileDirectory, 0644); + } catch (DirectoryCreatorException $e) { + $inceptionResult->getStdOutput()->writeLineFormatted($e->getMessage()); + + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + + try { + FileWriter::write($generateBaselineFile, $baselineContents); + } catch (CouldNotWriteFileException $e) { + $inceptionResult->getStdOutput()->writeLineFormatted($e->getMessage()); + + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + + $errorsCount = 0; + $unignorableCount = 0; + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if (!$fileSpecificError->canBeIgnored()) { + $unignorableCount++; + if ($output->isVeryVerbose()) { + $inceptionResult->getStdOutput()->writeLineFormatted('Unignorable errors could not be added to the baseline:'); + $inceptionResult->getStdOutput()->writeLineFormatted($fileSpecificError->getMessage()); + $inceptionResult->getStdOutput()->writeLineFormatted($fileSpecificError->getFile()); + $inceptionResult->getStdOutput()->writeLineFormatted(''); + } + continue; + } + + $errorsCount++; + } + + $message = sprintf('Baseline generated with %d %s.', $errorsCount, $errorsCount === 1 ? 'error' : 'errors'); + + if ( + $unignorableCount === 0 + && count($analysisResult->getNotFileSpecificErrors()) === 0 + ) { + $inceptionResult->getStdOutput()->getStyle()->success($message); + } else { + if ($output->isVeryVerbose()) { + $inceptionResult->getStdOutput()->getStyle()->warning($message . "\nSome errors could not be put into baseline."); + } else { + $inceptionResult->getStdOutput()->getStyle()->warning($message . "\nSome errors could not be put into baseline. Re-run PHPStan with \"-vv\" and fix them."); + } + } + + $exitCode = 0; + if ($failWithoutResultCache && !$analysisResult->isResultCacheUsed()) { + $exitCode = 2; + } + + return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); + } + + /** + * @param string[] $files + */ + private function runFixer(InceptionResult $inceptionResult, Container $container, bool $onlyFiles, InputInterface $input, OutputInterface $output, array $files): int + { + $ciDetector = new CiDetector(); + if ($ciDetector->isCiDetected()) { + $inceptionResult->getStdOutput()->writeLineFormatted('PHPStan Pro can\'t run in CI environment yet. Stay tuned!'); + + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + + /** @var FixerApplication $fixerApplication */ + $fixerApplication = $container->getByType(FixerApplication::class); + + return $fixerApplication->run( + $inceptionResult->getProjectConfigFile(), + $input, + $output, + count($files), + $_SERVER['argv'][0], + ); + } + + private function runDiagnoseExtensions(Container $container, Output $errorOutput): void + { + if (!$errorOutput->isDebug()) { + return; + } + + /** @var PHPStanDiagnoseExtension $phpstanDiagnoseExtension */ + $phpstanDiagnoseExtension = $container->getService('phpstanDiagnoseExtension'); + + // not using tag for this extension to make sure it's always first + $phpstanDiagnoseExtension->print($errorOutput); + + /** @var DiagnoseExtension $extension */ + foreach ($container->getServicesByTag(DiagnoseExtension::EXTENSION_TAG) as $extension) { + $extension->print($errorOutput); + } + } + +} diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php new file mode 100644 index 00000000..5d586ba9 --- /dev/null +++ b/src/Command/AnalyserRunner.php @@ -0,0 +1,89 @@ +scheduler->scheduleWork($this->cpuCoreCounter->getNumberOfCpuCores(), $files); + $mainScript = null; + if (isset($_SERVER['argv'][0]) && is_file($_SERVER['argv'][0])) { + $mainScript = $_SERVER['argv'][0]; + } + + if ( + !$debug + && $allowParallel + && function_exists('proc_open') + && $mainScript !== null + && $schedule->getNumberOfProcesses() > 0 + ) { + $loop = new StreamSelectLoop(); + $result = null; + $promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $input, null); + $promise->then(static function (AnalyserResult $tmp) use (&$result): void { + $result = $tmp; + }); + $loop->run(); + if ($result === null) { + throw new ShouldNotHappenException(); + } + return $result; + } + + return $this->analyser->analyse( + $files, + $preFileCallback, + $postFileCallback, + $debug, + $allAnalysedFiles, + ); + } + +} diff --git a/src/Command/AnalysisResult.php b/src/Command/AnalysisResult.php new file mode 100644 index 00000000..ed321d86 --- /dev/null +++ b/src/Command/AnalysisResult.php @@ -0,0 +1,152 @@ + sorted by their file name, line number and message */ + private array $fileSpecificErrors; + + /** + * @param list $fileSpecificErrors + * @param list $notFileSpecificErrors + * @param list $internalErrors + * @param list $warnings + * @param list $collectedData + * @param array $changedProjectExtensionFilesOutsideOfAnalysedPaths + */ + public function __construct( + array $fileSpecificErrors, + private array $notFileSpecificErrors, + private array $internalErrors, + private array $warnings, + private array $collectedData, + private bool $defaultLevelUsed, + private ?string $projectConfigFile, + private bool $savedResultCache, + private int $peakMemoryUsageBytes, + private bool $isResultCacheUsed, + private array $changedProjectExtensionFilesOutsideOfAnalysedPaths, + ) + { + usort( + $fileSpecificErrors, + static fn (Error $a, Error $b): int => [ + $a->getFile(), + $a->getLine(), + $a->getMessage(), + ] <=> [ + $b->getFile(), + $b->getLine(), + $b->getMessage(), + ], + ); + + $this->fileSpecificErrors = $fileSpecificErrors; + } + + public function hasErrors(): bool + { + return $this->getTotalErrorsCount() > 0; + } + + public function getTotalErrorsCount(): int + { + return count($this->fileSpecificErrors) + count($this->notFileSpecificErrors); + } + + /** + * @return list sorted by their file name, line number and message + */ + public function getFileSpecificErrors(): array + { + return $this->fileSpecificErrors; + } + + /** + * @return list + */ + public function getNotFileSpecificErrors(): array + { + return $this->notFileSpecificErrors; + } + + /** + * @return list + */ + public function getInternalErrorObjects(): array + { + return $this->internalErrors; + } + + /** + * @return list + */ + public function getWarnings(): array + { + return $this->warnings; + } + + public function hasWarnings(): bool + { + return count($this->warnings) > 0; + } + + /** + * @return list + */ + public function getCollectedData(): array + { + return $this->collectedData; + } + + public function isDefaultLevelUsed(): bool + { + return $this->defaultLevelUsed; + } + + public function getProjectConfigFile(): ?string + { + return $this->projectConfigFile; + } + + public function hasInternalErrors(): bool + { + return count($this->internalErrors) > 0; + } + + public function isResultCacheSaved(): bool + { + return $this->savedResultCache; + } + + public function getPeakMemoryUsageBytes(): int + { + return $this->peakMemoryUsageBytes; + } + + public function isResultCacheUsed(): bool + { + return $this->isResultCacheUsed; + } + + /** + * @return array + */ + public function getChangedProjectExtensionFilesOutsideOfAnalysedPaths(): array + { + return $this->changedProjectExtensionFilesOutsideOfAnalysedPaths; + } + +} diff --git a/src/Command/ClearResultCacheCommand.php b/src/Command/ClearResultCacheCommand.php new file mode 100644 index 00000000..5928bb00 --- /dev/null +++ b/src/Command/ClearResultCacheCommand.php @@ -0,0 +1,102 @@ +setName(self::NAME) + ->setDescription('Clears the result cache.') + ->setDefinition([ + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('debug', null, InputOption::VALUE_NONE, 'Show debug information - which file is analysed, do not catch internal errors'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for clearing result cache'), + new InputOption('xdebug', null, InputOption::VALUE_NONE, 'Allow running with Xdebug for debugging purposes'), + ]); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ((bool) $input->getOption('debug')) { + $application = $this->getApplication(); + if ($application === null) { + throw new ShouldNotHappenException(); + } + $application->setCatchExceptions(false); + return; + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $memoryLimit = $input->getOption('memory-limit'); + $debugEnabled = (bool) $input->getOption('debug'); + $allowXdebug = $input->getOption('xdebug'); + + if ( + (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_bool($allowXdebug)) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + [], + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + '0', + $allowXdebug, + $debugEnabled, + true, + ); + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $container = $inceptionResult->getContainer(); + + $resultCacheClearer = $container->getByType(ResultCacheClearer::class); + $path = $resultCacheClearer->clear(); + + $output->writeln('Result cache cleared from directory:'); + $output->writeln($path); + + return 0; + } + +} diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php new file mode 100644 index 00000000..f1975349 --- /dev/null +++ b/src/Command/CommandHelper.php @@ -0,0 +1,599 @@ +getErrorOutput() : $output; + return new SymfonyOutput($symfonyErrorOutput, new SymfonyStyle(new ErrorsConsoleStyle($input, $symfonyErrorOutput))); + })(); + + if (!$allowXdebug) { + $xdebug = new XdebugHandler('phpstan'); + $xdebug->setPersistent(); + $xdebug->check(); + unset($xdebug); + } + + if ($allowXdebug) { + if (!XdebugHandler::isXdebugActive()) { + $errorOutput->getStyle()->note('You are running with "--xdebug" enabled, but the Xdebug PHP extension is not active. The process will not halt at breakpoints.'); + } else { + $errorOutput->getStyle()->note("You are running with \"--xdebug\" enabled, and the Xdebug PHP extension is active.\nThe process will halt at breakpoints, but PHPStan will run much slower.\nUse this only if you are debugging PHPStan itself or your custom extensions."); + } + } elseif (XdebugHandler::isXdebugActive()) { + $errorOutput->getStyle()->note('The Xdebug PHP extension is active, but "--xdebug" is not used. This may slow down performance and the process will not halt at breakpoints.'); + } elseif ($debugEnabled) { + $v = XdebugHandler::getSkippedVersion(); + if ($v !== '') { + $errorOutput->getStyle()->note( + "The Xdebug PHP extension is active, but \"--xdebug\" is not used.\n" . + "The process was restarted and it will not halt at breakpoints.\n" . + 'Use "--xdebug" if you want to halt at breakpoints.', + ); + } + } + + if ($memoryLimit !== null) { + if (Strings::match($memoryLimit, '#^-?\d+[kMG]?$#i') === null) { + $errorOutput->writeLineFormatted(sprintf('Invalid memory limit format "%s".', $memoryLimit)); + throw new InceptionNotSuccessfulException(); + } + if (ini_set('memory_limit', $memoryLimit) === false) { + $errorOutput->writeLineFormatted(sprintf('Memory limit "%s" cannot be set.', $memoryLimit)); + throw new InceptionNotSuccessfulException(); + } + } + + self::$reservedMemory = str_repeat('PHPStan', 1463); // reserve 10 kB of space + register_shutdown_function(static function () use ($errorOutput): void { + self::$reservedMemory = null; + $error = error_get_last(); + if ($error === null) { + return; + } + if ($error['type'] !== E_ERROR) { + return; + } + + if (!str_contains($error['message'], 'Allowed memory size')) { + return; + } + + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted(sprintf('PHPStan process crashed because it reached configured PHP memory limit: %s', ini_get('memory_limit'))); + $errorOutput->writeLineFormatted('Increase your memory limit in php.ini or run PHPStan with --memory-limit CLI option.'); + }); + + $currentWorkingDirectory = getcwd(); + if ($currentWorkingDirectory === false) { + throw new ShouldNotHappenException(); + } + $currentWorkingDirectoryFileHelper = new FileHelper($currentWorkingDirectory); + $currentWorkingDirectory = $currentWorkingDirectoryFileHelper->getWorkingDirectory(); + + /** @var list|false $autoloadFunctionsBefore */ + $autoloadFunctionsBefore = spl_autoload_functions(); + + if ($autoloadFile !== null) { + $autoloadFile = $currentWorkingDirectoryFileHelper->absolutizePath($autoloadFile); + if (!is_file($autoloadFile)) { + $errorOutput->writeLineFormatted(sprintf('Autoload file "%s" not found.', $autoloadFile)); + throw new InceptionNotSuccessfulException(); + } + + (static function (string $file): void { + require_once $file; + })($autoloadFile); + } + if ($projectConfigFile === null) { + $discoverableConfigNames = [ + '.phpstan.neon', + 'phpstan.neon', + '.phpstan.neon.dist', + 'phpstan.neon.dist', + '.phpstan.dist.neon', + 'phpstan.dist.neon', + ]; + foreach ($discoverableConfigNames as $discoverableConfigName) { + $discoverableConfigFile = $currentWorkingDirectory . DIRECTORY_SEPARATOR . $discoverableConfigName; + if (is_file($discoverableConfigFile)) { + $projectConfigFile = $discoverableConfigFile; + $errorOutput->writeLineFormatted(sprintf('Note: Using configuration file %s.', $projectConfigFile)); + break; + } + } + } else { + $projectConfigFile = $currentWorkingDirectoryFileHelper->absolutizePath($projectConfigFile); + } + + if ($generateBaselineFile !== null) { + $generateBaselineFile = $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($generateBaselineFile)); + } + + $defaultLevelUsed = false; + if ($projectConfigFile === null && $level === null) { + $level = self::DEFAULT_LEVEL; + $defaultLevelUsed = true; + } + + $paths = array_map(static fn (string $path): string => $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($path)), $paths); + + $analysedPathsFromConfig = []; + $containerFactory = new ContainerFactory($currentWorkingDirectory); + if ($cleanupContainerCache) { + $containerFactory->setJournalContainer(); + } + $projectConfig = null; + if ($projectConfigFile !== null) { + if (!is_file($projectConfigFile)) { + $errorOutput->writeLineFormatted(sprintf('Project config file at path %s does not exist.', $projectConfigFile)); + throw new InceptionNotSuccessfulException(); + } + + $loader = (new LoaderFactory( + $currentWorkingDirectoryFileHelper, + $containerFactory->getRootDirectory(), + $containerFactory->getCurrentWorkingDirectory(), + $generateBaselineFile, + ))->createLoader(); + + try { + $projectConfig = $loader->load($projectConfigFile, null); + } catch (InvalidStateException | FileNotFoundException $e) { + $errorOutput->writeLineFormatted($e->getMessage()); + throw new InceptionNotSuccessfulException(); + } + $defaultParameters = [ + 'rootDir' => $containerFactory->getRootDirectory(), + 'currentWorkingDirectory' => $containerFactory->getCurrentWorkingDirectory(), + 'env' => getenv(), + ]; + + if (isset($projectConfig['parameters']['tmpDir'])) { + $tmpDir = Helpers::expand($projectConfig['parameters']['tmpDir'], $defaultParameters); + } + if ($level === null && isset($projectConfig['parameters']['level'])) { + $level = (string) $projectConfig['parameters']['level']; + } + if (isset($projectConfig['parameters']['paths'])) { + $analysedPathsFromConfig = Helpers::expand($projectConfig['parameters']['paths'], $defaultParameters); + } + if (count($paths) === 0) { + $paths = $analysedPathsFromConfig; + } + } + + $additionalConfigFiles = []; + if ($level !== null) { + $levelConfigFile = sprintf('%s/config.level%s.neon', $containerFactory->getConfigDirectory(), $level); + if (!is_file($levelConfigFile)) { + $errorOutput->writeLineFormatted(sprintf('Level config file %s was not found.', $levelConfigFile)); + throw new InceptionNotSuccessfulException(); + } + + $additionalConfigFiles[] = $levelConfigFile; + } + + if (class_exists('PHPStan\ExtensionInstaller\GeneratedConfig')) { + $generatedConfigReflection = new ReflectionClass('PHPStan\ExtensionInstaller\GeneratedConfig'); + $generatedConfigDirectory = dirname($generatedConfigReflection->getFileName()); + foreach (GeneratedConfig::EXTENSIONS as $name => $extensionConfig) { + foreach ($extensionConfig['extra']['includes'] ?? [] as $includedFile) { + if (!is_string($includedFile)) { + $errorOutput->writeLineFormatted(sprintf('Cannot include config from package %s, expecting string file path but got %s', $name, gettype($includedFile))); + throw new InceptionNotSuccessfulException(); + } + $includedFilePath = null; + if (isset($extensionConfig['relative_install_path'])) { + $includedFilePath = sprintf('%s/%s/%s', $generatedConfigDirectory, $extensionConfig['relative_install_path'], $includedFile); + if (!is_file($includedFilePath) || !is_readable($includedFilePath)) { + $includedFilePath = null; + } + } + + if ($includedFilePath === null) { + $includedFilePath = sprintf('%s/%s', $extensionConfig['install_path'], $includedFile); + } + if (!is_file($includedFilePath) || !is_readable($includedFilePath)) { + $errorOutput->writeLineFormatted(sprintf('Config file %s does not exist or isn\'t readable', $includedFilePath)); + throw new InceptionNotSuccessfulException(); + } + $additionalConfigFiles[] = $includedFilePath; + } + } + + if ( + count($additionalConfigFiles) > 0 + && $generatedConfigReflection->hasConstant('PHPSTAN_VERSION_CONSTRAINT') + ) { + $generatedConfigPhpStanVersionConstraint = $generatedConfigReflection->getConstant('PHPSTAN_VERSION_CONSTRAINT'); + if ($generatedConfigPhpStanVersionConstraint !== null) { + $phpstanSemverVersion = ComposerHelper::getPhpStanVersion(); + if ( + $phpstanSemverVersion !== ComposerHelper::UNKNOWN_VERSION + && !str_contains($phpstanSemverVersion, '@') + && !Semver::satisfies($phpstanSemverVersion, $generatedConfigPhpStanVersionConstraint) + ) { + $errorOutput->writeLineFormatted('Running PHPStan with incompatible extensions'); + $errorOutput->writeLineFormatted('You\'re running PHPStan from a different Composer project'); + $errorOutput->writeLineFormatted('than the one where you installed extensions.'); + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted(sprintf('Your PHPStan version is: %s', $phpstanSemverVersion)); + $errorOutput->writeLineFormatted(sprintf('Installed PHPStan extensions support: %s', $generatedConfigPhpStanVersionConstraint)); + + $errorOutput->writeLineFormatted(''); + if (isset($_SERVER['argv'][0]) && is_file($_SERVER['argv'][0])) { + $mainScript = $_SERVER['argv'][0]; + $errorOutput->writeLineFormatted(sprintf('PHPStan is running from: %s', $currentWorkingDirectoryFileHelper->absolutizePath(dirname($mainScript)))); + } + + $errorOutput->writeLineFormatted(sprintf('Extensions were installed in: %s', dirname($generatedConfigDirectory, 3))); + $errorOutput->writeLineFormatted(''); + + $simpleRelativePathHelper = new SimpleRelativePathHelper($currentWorkingDirectory); + $errorOutput->writeLineFormatted(sprintf('Run PHPStan with %s to fix this problem.', $simpleRelativePathHelper->getRelativePath(dirname($generatedConfigDirectory, 3) . '/bin/phpstan'))); + + $errorOutput->writeLineFormatted(''); + throw new InceptionNotSuccessfulException(); + } + } + } + } + + if ( + $projectConfigFile !== null + && $currentWorkingDirectoryFileHelper->normalizePath($projectConfigFile, '/') !== $currentWorkingDirectoryFileHelper->normalizePath(__DIR__ . '/../../conf/config.stubFiles.neon', '/') + ) { + $additionalConfigFiles[] = $projectConfigFile; + } + + $createDir = static function (string $path) use ($errorOutput): void { + try { + DirectoryCreator::ensureDirectoryExists($path, 0777); + } catch (DirectoryCreatorException $e) { + $errorOutput->writeLineFormatted($e->getMessage()); + throw new InceptionNotSuccessfulException(); + } + }; + + if (!isset($tmpDir)) { + $tmpDir = sys_get_temp_dir() . '/phpstan'; + $createDir($tmpDir); + } + + try { + $container = $containerFactory->create($tmpDir, $additionalConfigFiles, $paths, $composerAutoloaderProjectPaths, $analysedPathsFromConfig, $level ?? self::DEFAULT_LEVEL, $generateBaselineFile, $autoloadFile); + } catch (InvalidConfigurationException | AssertionException $e) { + $errorOutput->writeLineFormatted('Invalid configuration:'); + $errorOutput->writeLineFormatted($e->getMessage()); + throw new InceptionNotSuccessfulException(); + } catch (InvalidIgnoredErrorPatternsException $e) { + $errorOutput->writeLineFormatted(sprintf('Invalid %s in ignoreErrors:', count($e->getErrors()) === 1 ? 'entry' : 'entries')); + foreach ($e->getErrors() as $error) { + $errorOutput->writeLineFormatted($error); + $errorOutput->writeLineFormatted(''); + } + + $errorOutput->writeLineFormatted('To ignore non-existent paths in ignoreErrors,'); + $errorOutput->writeLineFormatted('set reportUnmatchedIgnoredErrors: false in your configuration file.'); + $errorOutput->writeLineFormatted(''); + + throw new InceptionNotSuccessfulException(); + } catch (InvalidExcludePathsException $e) { + $errorOutput->writeLineFormatted(sprintf('Invalid %s in excludePaths:', count($e->getErrors()) === 1 ? 'entry' : 'entries')); + foreach ($e->getErrors() as $error) { + $errorOutput->writeLineFormatted($error); + $errorOutput->writeLineFormatted(''); + } + + $errorOutput->writeLineFormatted('If the excluded path can sometimes exist, append (?)'); + $errorOutput->writeLineFormatted('to its config entry to mark it as optional.'); + $errorOutput->writeLineFormatted(''); + + throw new InceptionNotSuccessfulException(); + } catch (ValidationException $e) { + foreach ($e->getMessages() as $message) { + $errorOutput->writeLineFormatted('Invalid configuration:'); + $errorOutput->writeLineFormatted($message); + } + throw new InceptionNotSuccessfulException(); + } catch (ServiceCreationException $e) { + $matches = Strings::match($e->getMessage(), '#Service of type (?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]): Service of type (?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]) needed by \$(?[a-zA-Z_\x7f-\xff][a-zA-Z_0-9\x7f-\xff]*) in (?[a-zA-Z_\x7f-\xff][a-zA-Z_0-9\x7f-\xff]*)\(\)#'); + if ($matches === null) { + throw $e; + } + + if ($matches['parserServiceType'] !== 'PHPStan\\Parser\\Parser') { + throw $e; + } + + if ($matches['methodName'] !== '__construct') { + throw $e; + } + + $errorOutput->writeLineFormatted('Invalid configuration:'); + $errorOutput->writeLineFormatted(sprintf("Service of type %s is no longer autowired.\n", $matches['parserServiceType'])); + $errorOutput->writeLineFormatted('You need to choose one of the following services'); + $errorOutput->writeLineFormatted(sprintf('and use it in the %s argument of your service %s:', $matches['parameterName'], $matches['serviceType'])); + $errorOutput->writeLineFormatted('* defaultAnalysisParser (if you\'re parsing files from analysed paths)'); + $errorOutput->writeLineFormatted('* currentPhpVersionSimpleDirectParser (in most other situations)'); + + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('After fixing this problem, your configuration will look something like this:'); + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('-'); + $errorOutput->writeLineFormatted(sprintf("\tclass: %s", $matches['serviceType'])); + $errorOutput->writeLineFormatted(sprintf("\targuments:")); + $errorOutput->writeLineFormatted(sprintf("\t\t%s: @defaultAnalysisParser", $matches['parameterName'])); + $errorOutput->writeLineFormatted(''); + + throw new InceptionNotSuccessfulException(); + } catch (DuplicateIncludedFilesException $e) { + $format = "These files are included multiple times:\n- %s"; + if (count($e->getFiles()) === 1) { + $format = "This file is included multiple times:\n- %s"; + } + $errorOutput->writeLineFormatted(sprintf($format, implode("\n- ", $e->getFiles()))); + + if (class_exists('PHPStan\ExtensionInstaller\GeneratedConfig')) { + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('It can lead to unexpected results. If you\'re using phpstan/extension-installer, make sure you have removed corresponding neon files from your project config file.'); + } + + throw new InceptionNotSuccessfulException(); + } + + if ($cleanupContainerCache) { + $cacheStorage = $container->getService('cacheStorage'); + if ($cacheStorage instanceof FileCacheStorage) { + $cacheStorage->clearUnusedFiles(); + } + } + + /** @var bool|null $customRulesetUsed */ + $customRulesetUsed = $container->getParameter('customRulesetUsed'); + if ($customRulesetUsed === null) { + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('No rules detected'); + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('You have the following choices:'); + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted('* while running the analyse option, use the --level option to adjust your rule level - the higher the stricter'); + $errorOutput->writeLineFormatted(''); + $errorOutput->writeLineFormatted(sprintf('* create your own custom ruleset by selecting which rules you want to check by copying the service definitions from the built-in config level files in %s.', $currentWorkingDirectoryFileHelper->normalizePath(__DIR__ . '/../../conf'))); + $errorOutput->writeLineFormatted(' * in this case, don\'t forget to define parameter customRulesetUsed in your config file.'); + $errorOutput->writeLineFormatted(''); + throw new InceptionNotSuccessfulException(); + } elseif ($customRulesetUsed) { + $defaultLevelUsed = false; + } + + foreach ($container->getParameter('bootstrapFiles') as $bootstrapFileFromArray) { + self::executeBootstrapFile($bootstrapFileFromArray, $container, $errorOutput, $debugEnabled); + } + + /** @var list|false $autoloadFunctionsAfter */ + $autoloadFunctionsAfter = spl_autoload_functions(); + + if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { + $newAutoloadFunctions = $GLOBALS['__phpstanAutoloadFunctions'] ?? []; + foreach ($autoloadFunctionsAfter as $after) { + foreach ($autoloadFunctionsBefore as $before) { + if ($after === $before) { + continue 2; + } + } + + $newAutoloadFunctions[] = $after; + } + + $GLOBALS['__phpstanAutoloadFunctions'] = $newAutoloadFunctions; + } + + if (PHP_VERSION_ID >= 80000) { + require_once __DIR__ . '/../../stubs/runtime/Enum/UnitEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/BackedEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnumUnitCase.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnumBackedCase.php'; + } + + foreach ($container->getParameter('scanFiles') as $scannedFile) { + if (is_file($scannedFile)) { + continue; + } + + $errorOutput->writeLineFormatted(sprintf('Scanned file %s does not exist.', $scannedFile)); + + throw new InceptionNotSuccessfulException(); + } + + foreach ($container->getParameter('scanDirectories') as $scannedDirectory) { + if (is_dir($scannedDirectory)) { + continue; + } + + $errorOutput->writeLineFormatted(sprintf('Scanned directory %s does not exist.', $scannedDirectory)); + + throw new InceptionNotSuccessfulException(); + } + + $alreadyAddedStubFiles = []; + foreach ($container->getParameter('stubFiles') as $stubFile) { + if (array_key_exists($stubFile, $alreadyAddedStubFiles)) { + $errorOutput->writeLineFormatted(sprintf('Stub file %s is added multiple times.', $stubFile)); + + throw new InceptionNotSuccessfulException(); + } + + $alreadyAddedStubFiles[$stubFile] = true; + + if (is_file($stubFile)) { + continue; + } + + $errorOutput->writeLineFormatted(sprintf('Stub file %s does not exist.', $stubFile)); + + throw new InceptionNotSuccessfulException(); + } + + /** @var FileFinder $fileFinder */ + $fileFinder = $container->getService('fileFinderAnalyse'); + + $pathRoutingParser = $container->getService('pathRoutingParser'); + + $stubFilesProvider = $container->getByType(StubFilesProvider::class); + + $filesCallback = static function () use ($currentWorkingDirectoryFileHelper, $stubFilesProvider, $fileFinder, $pathRoutingParser, $paths, $errorOutput): array { + if (count($paths) === 0) { + $errorOutput->writeLineFormatted('At least one path must be specified to analyse.'); + throw new InceptionNotSuccessfulException(); + } + $fileFinderResult = $fileFinder->findFiles($paths); + $files = $fileFinderResult->getFiles(); + + $pathRoutingParser->setAnalysedFiles($files); + + $stubFilesExcluder = new FileExcluder($currentWorkingDirectoryFileHelper, $stubFilesProvider->getProjectStubFiles()); + + $files = array_values(array_filter($files, static fn (string $file) => !$stubFilesExcluder->isExcludedFromAnalysing($file))); + + return [$files, $fileFinderResult->isOnlyFiles()]; + }; + + return new InceptionResult( + $filesCallback, + $stdOutput, + $errorOutput, + $container, + $defaultLevelUsed, + $projectConfigFile, + $projectConfig, + $generateBaselineFile, + ); + } + + /** + * @throws InceptionNotSuccessfulException + */ + private static function executeBootstrapFile( + string $file, + Container $container, + Output $errorOutput, + bool $debugEnabled, + ): void + { + if (!is_file($file)) { + $errorOutput->writeLineFormatted(sprintf('Bootstrap file %s does not exist.', $file)); + throw new InceptionNotSuccessfulException(); + } + try { + (static function (string $file) use ($container): void { + require_once $file; + })($file); + } catch (Throwable $e) { + $errorOutput->writeLineFormatted(sprintf('%s thrown in %s on line %d while loading bootstrap file %s: %s', get_class($e), $e->getFile(), $e->getLine(), $file, $e->getMessage())); + + if ($debugEnabled) { + $errorOutput->writeLineFormatted($e->getTraceAsString()); + } + + throw new InceptionNotSuccessfulException(); + } + } + +} diff --git a/src/Command/DiagnoseCommand.php b/src/Command/DiagnoseCommand.php new file mode 100644 index 00000000..a0ec341b --- /dev/null +++ b/src/Command/DiagnoseCommand.php @@ -0,0 +1,107 @@ +setName(self::NAME) + ->setDescription('Shows diagnose information about PHPStan and extensions') + ->setDefinition([ + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('debug', null, InputOption::VALUE_NONE, 'Show debug information - do not catch internal errors'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for clearing result cache'), + ]); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ((bool) $input->getOption('debug')) { + $application = $this->getApplication(); + if ($application === null) { + throw new ShouldNotHappenException(); + } + $application->setCatchExceptions(false); + return; + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $memoryLimit = $input->getOption('memory-limit'); + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $level = $input->getOption(AnalyseCommand::OPTION_LEVEL); + + if ( + (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($level) && $level !== null) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + [], + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + $level, + false, + false, + false, + ); + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $container = $inceptionResult->getContainer(); + $output = $inceptionResult->getStdOutput(); + + /** @var PHPStanDiagnoseExtension $phpstanDiagnoseExtension */ + $phpstanDiagnoseExtension = $container->getService('phpstanDiagnoseExtension'); + + // not using tag for this extension to make sure it's always first + $phpstanDiagnoseExtension->print($output); + + /** @var DiagnoseExtension $extension */ + foreach ($container->getServicesByTag(DiagnoseExtension::EXTENSION_TAG) as $extension) { + $extension->print($output); + } + + return 0; + } + +} diff --git a/src/Command/DumpParametersCommand.php b/src/Command/DumpParametersCommand.php new file mode 100644 index 00000000..ee2e2ca0 --- /dev/null +++ b/src/Command/DumpParametersCommand.php @@ -0,0 +1,113 @@ +setName(self::NAME) + ->setDescription('Dumps all parameters') + ->setDefinition([ + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('debug', null, InputOption::VALUE_NONE, 'Show debug information - which file is analysed, do not catch internal errors'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for clearing result cache'), + new InputOption('json', null, InputOption::VALUE_NONE, 'Dump parameters as JSON instead of NEON'), + ]); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ((bool) $input->getOption('debug')) { + $application = $this->getApplication(); + if ($application === null) { + throw new ShouldNotHappenException(); + } + $application->setCatchExceptions(false); + return; + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $memoryLimit = $input->getOption('memory-limit'); + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $level = $input->getOption(AnalyseCommand::OPTION_LEVEL); + $json = (bool) $input->getOption('json'); + + if ( + (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($level) && $level !== null) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + [], + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + $level, + false, + false, + false, + ); + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $parameters = $inceptionResult->getContainer()->getParameters(); + + // always set to '.' + unset($parameters['analysedPaths']); + // irrelevant Nette parameters + unset($parameters['debugMode']); + unset($parameters['productionMode']); + unset($parameters['tempDir']); + unset($parameters['__validate']); + + if ($json) { + $encoded = Json::encode($parameters, Json::PRETTY); + } else { + $encoded = Neon::encode($parameters, true); + } + + $output->writeln($encoded); + + return 0; + } + +} diff --git a/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php b/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php new file mode 100644 index 00000000..319cc22d --- /dev/null +++ b/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php @@ -0,0 +1,128 @@ +hasErrors()) { + $output->writeRaw($this->getNeon([], $existingBaselineContent)); + return 0; + } + + $fileErrors = []; + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if (!$fileSpecificError->canBeIgnored()) { + continue; + } + $fileErrors[$this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = $fileSpecificError; + } + ksort($fileErrors, SORT_STRING); + + $errorsToOutput = []; + foreach ($fileErrors as $file => $errors) { + $fileErrorsByMessage = []; + foreach ($errors as $error) { + $errorMessage = $error->getMessage(); + $identifier = $error->getIdentifier(); + if (!isset($fileErrorsByMessage[$errorMessage])) { + $fileErrorsByMessage[$errorMessage] = [ + 1, + $identifier !== null ? [$identifier => 1] : [], + ]; + continue; + } + + $fileErrorsByMessage[$errorMessage][0]++; + + if ($identifier === null) { + continue; + } + + if (!isset($fileErrorsByMessage[$errorMessage][1][$identifier])) { + $fileErrorsByMessage[$errorMessage][1][$identifier] = 1; + continue; + } + + $fileErrorsByMessage[$errorMessage][1][$identifier]++; + } + ksort($fileErrorsByMessage, SORT_STRING); + + foreach ($fileErrorsByMessage as $message => [$totalCount, $identifiers]) { + ksort($identifiers, SORT_STRING); + if (count($identifiers) > 0) { + foreach ($identifiers as $identifier => $identifierCount) { + $errorsToOutput[] = [ + 'message' => Helpers::escape('#^' . preg_quote($message, '#') . '$#'), + 'identifier' => $identifier, + 'count' => $identifierCount, + 'path' => Helpers::escape($file), + ]; + } + } else { + $errorsToOutput[] = [ + 'message' => Helpers::escape('#^' . preg_quote($message, '#') . '$#'), + 'count' => $totalCount, + 'path' => Helpers::escape($file), + ]; + } + } + } + + $output->writeRaw($this->getNeon($errorsToOutput, $existingBaselineContent)); + + return 1; + } + + /** + * @param array $ignoreErrors + */ + private function getNeon(array $ignoreErrors, string $existingBaselineContent): string + { + $neon = Neon::encode([ + 'parameters' => [ + 'ignoreErrors' => $ignoreErrors, + ], + ], Neon::BLOCK); + + if (substr($neon, -2) !== "\n\n") { + throw new ShouldNotHappenException(); + } + + if ($existingBaselineContent === '') { + return substr($neon, 0, -1); + } + + $existingBaselineContentEndOfFileNewlinesMatches = Strings::match($existingBaselineContent, "~(\n)+$~"); + $existingBaselineContentEndOfFileNewlines = $existingBaselineContentEndOfFileNewlinesMatches !== null + ? $existingBaselineContentEndOfFileNewlinesMatches[0] + : ''; + + return substr($neon, 0, -2) . $existingBaselineContentEndOfFileNewlines; + } + +} diff --git a/src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php b/src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php new file mode 100644 index 00000000..b178f38d --- /dev/null +++ b/src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php @@ -0,0 +1,111 @@ +hasErrors()) { + $php = 'writeRaw($php); + return 0; + } + + $fileErrors = []; + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if (!$fileSpecificError->canBeIgnored()) { + continue; + } + $fileErrors['/' . $this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = $fileSpecificError; + } + ksort($fileErrors, SORT_STRING); + + $php = ' $errors) { + $fileErrorsByMessage = []; + foreach ($errors as $error) { + $errorMessage = $error->getMessage(); + $identifier = $error->getIdentifier(); + if (!isset($fileErrorsByMessage[$errorMessage])) { + $fileErrorsByMessage[$errorMessage] = [ + 1, + $identifier !== null ? [$identifier => 1] : [], + ]; + continue; + } + + $fileErrorsByMessage[$errorMessage][0]++; + + if ($identifier === null) { + continue; + } + + if (!isset($fileErrorsByMessage[$errorMessage][1][$identifier])) { + $fileErrorsByMessage[$errorMessage][1][$identifier] = 1; + continue; + } + + $fileErrorsByMessage[$errorMessage][1][$identifier]++; + } + ksort($fileErrorsByMessage, SORT_STRING); + + foreach ($fileErrorsByMessage as $message => [$totalCount, $identifiers]) { + ksort($identifiers, SORT_STRING); + if (count($identifiers) > 0) { + foreach ($identifiers as $identifier => $identifierCount) { + $php .= sprintf( + "\$ignoreErrors[] = [\n\t'message' => %s,\n\t'identifier' => %s,\n\t'count' => %d,\n\t'path' => __DIR__ . %s,\n];\n", + var_export(Helpers::escape('#^' . preg_quote($message, '#') . '$#'), true), + var_export(Helpers::escape($identifier), true), + var_export($identifierCount, true), + var_export(Helpers::escape($file), true), + ); + } + } else { + $php .= sprintf( + "\$ignoreErrors[] = [\n\t'message' => %s,\n\t'count' => %d,\n\t'path' => __DIR__ . %s,\n];\n", + var_export(Helpers::escape('#^' . preg_quote($message, '#') . '$#'), true), + var_export($totalCount, true), + var_export(Helpers::escape($file), true), + ); + } + } + } + + $php .= "\n"; + $php .= 'return [\'parameters\' => [\'ignoreErrors\' => $ignoreErrors]];'; + $php .= "\n"; + + $output->writeRaw($php); + + return 1; + } + +} diff --git a/src/Command/ErrorFormatter/CheckstyleErrorFormatter.php b/src/Command/ErrorFormatter/CheckstyleErrorFormatter.php new file mode 100644 index 00000000..a708bdce --- /dev/null +++ b/src/Command/ErrorFormatter/CheckstyleErrorFormatter.php @@ -0,0 +1,122 @@ +writeRaw(''); + $output->writeLineFormatted(''); + $output->writeRaw(''); + $output->writeLineFormatted(''); + + foreach ($this->groupByFile($analysisResult) as $relativeFilePath => $errors) { + $output->writeRaw(sprintf( + '', + $this->escape($relativeFilePath), + )); + $output->writeLineFormatted(''); + + foreach ($errors as $error) { + $output->writeRaw(sprintf( + ' ', + $this->escape((string) $error->getLine()), + $this->escape($error->getMessage()), + $error->getIdentifier() !== null ? sprintf(' source="%s"', $this->escape($error->getIdentifier())) : '', + )); + $output->writeLineFormatted(''); + } + $output->writeRaw(''); + $output->writeLineFormatted(''); + } + + $notFileSpecificErrors = $analysisResult->getNotFileSpecificErrors(); + + if (count($notFileSpecificErrors) > 0) { + $output->writeRaw(''); + $output->writeLineFormatted(''); + + foreach ($notFileSpecificErrors as $error) { + $output->writeRaw(sprintf(' ', $this->escape($error))); + $output->writeLineFormatted(''); + } + + $output->writeRaw(''); + $output->writeLineFormatted(''); + } + + if ($analysisResult->hasWarnings()) { + $output->writeRaw(''); + $output->writeLineFormatted(''); + + foreach ($analysisResult->getWarnings() as $warning) { + $output->writeRaw(sprintf(' ', $this->escape($warning))); + $output->writeLineFormatted(''); + } + + $output->writeRaw(''); + $output->writeLineFormatted(''); + } + + $output->writeRaw(''); + $output->writeLineFormatted(''); + + return $analysisResult->hasErrors() ? 1 : 0; + } + + /** + * Escapes values for using in XML + * + */ + private function escape(string $string): string + { + return htmlspecialchars($string, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + } + + /** + * Group errors by file + * + * @return array> Array that have as key the relative path of file + * and as value an array with occurred errors. + */ + private function groupByFile(AnalysisResult $analysisResult): array + { + $files = []; + + /** @var Error $fileSpecificError */ + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $absolutePath = $fileSpecificError->getFilePath(); + if ($fileSpecificError->getTraitFilePath() !== null) { + $absolutePath = $fileSpecificError->getTraitFilePath(); + } + $relativeFilePath = $this->relativePathHelper->getRelativePath( + $absolutePath, + ); + + $files[$relativeFilePath][] = $fileSpecificError; + } + + return $files; + } + +} diff --git a/src/Command/ErrorFormatter/CiDetectedErrorFormatter.php b/src/Command/ErrorFormatter/CiDetectedErrorFormatter.php new file mode 100644 index 00000000..8e167bc2 --- /dev/null +++ b/src/Command/ErrorFormatter/CiDetectedErrorFormatter.php @@ -0,0 +1,46 @@ +detect(); + if ($ci->getCiName() === CiDetector::CI_GITHUB_ACTIONS) { + return $this->githubErrorFormatter->formatErrors($analysisResult, $output); + } elseif ($ci->getCiName() === CiDetector::CI_TEAMCITY) { + return $this->teamcityErrorFormatter->formatErrors($analysisResult, $output); + } + } catch (CiNotDetectedException) { + // pass + } + + if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) { + return 0; + } + + return $analysisResult->getTotalErrorsCount() > 0 ? 1 : 0; + } + +} diff --git a/src/Command/ErrorFormatter/ErrorFormatter.php b/src/Command/ErrorFormatter/ErrorFormatter.php new file mode 100644 index 00000000..5046d52d --- /dev/null +++ b/src/Command/ErrorFormatter/ErrorFormatter.php @@ -0,0 +1,36 @@ +getFileSpecificErrors() as $fileSpecificError) { + $metas = [ + 'file' => $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()), + 'line' => $fileSpecificError->getLine(), + 'col' => 0, + ]; + array_walk($metas, static function (&$value, string $key): void { + $value = sprintf('%s=%s', $key, (string) $value); + }); + + $message = $fileSpecificError->getMessage(); + // newlines need to be encoded + // see https://github.com/actions/starter-workflows/issues/68#issuecomment-581479448 + $message = str_replace("\n", '%0A', $message); + + $line = sprintf('::error %s::%s', implode(',', $metas), $message); + + $output->writeRaw($line); + $output->writeLineFormatted(''); + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + // newlines need to be encoded + // see https://github.com/actions/starter-workflows/issues/68#issuecomment-581479448 + $notFileSpecificError = str_replace("\n", '%0A', $notFileSpecificError); + + $line = sprintf('::error ::%s', $notFileSpecificError); + + $output->writeRaw($line); + $output->writeLineFormatted(''); + } + + foreach ($analysisResult->getWarnings() as $warning) { + // newlines need to be encoded + // see https://github.com/actions/starter-workflows/issues/68#issuecomment-581479448 + $warning = str_replace("\n", '%0A', $warning); + + $line = sprintf('::warning ::%s', $warning); + + $output->writeRaw($line); + $output->writeLineFormatted(''); + } + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/src/Command/ErrorFormatter/GitlabErrorFormatter.php b/src/Command/ErrorFormatter/GitlabErrorFormatter.php new file mode 100644 index 00000000..52f08f23 --- /dev/null +++ b/src/Command/ErrorFormatter/GitlabErrorFormatter.php @@ -0,0 +1,73 @@ +getFileSpecificErrors() as $fileSpecificError) { + $error = [ + 'description' => $fileSpecificError->getMessage(), + 'fingerprint' => hash( + 'sha256', + implode( + [ + $fileSpecificError->getFile(), + $fileSpecificError->getLine(), + $fileSpecificError->getMessage(), + ], + ), + ), + 'severity' => $fileSpecificError->canBeIgnored() ? 'major' : 'blocker', + 'location' => [ + 'path' => $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()), + 'lines' => [ + 'begin' => $fileSpecificError->getLine() ?? 0, + ], + ], + ]; + + $errorsArray[] = $error; + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + $errorsArray[] = [ + 'description' => $notFileSpecificError, + 'fingerprint' => hash('sha256', $notFileSpecificError), + 'severity' => 'major', + 'location' => [ + 'path' => '', + 'lines' => [ + 'begin' => 0, + ], + ], + ]; + } + + $json = Json::encode($errorsArray, Json::PRETTY); + + $output->writeRaw($json); + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/src/Command/ErrorFormatter/JsonErrorFormatter.php b/src/Command/ErrorFormatter/JsonErrorFormatter.php new file mode 100644 index 00000000..df54f9a6 --- /dev/null +++ b/src/Command/ErrorFormatter/JsonErrorFormatter.php @@ -0,0 +1,71 @@ + [ + 'errors' => count($analysisResult->getNotFileSpecificErrors()), + 'file_errors' => count($analysisResult->getFileSpecificErrors()), + ], + 'files' => [], + 'errors' => [], + ]; + + $tipFormatter = new OutputFormatter(false); + + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $file = $fileSpecificError->getFile(); + if (!array_key_exists($file, $errorsArray['files'])) { + $errorsArray['files'][$file] = [ + 'errors' => 0, + 'messages' => [], + ]; + } + $errorsArray['files'][$file]['errors']++; + + $message = [ + 'message' => $fileSpecificError->getMessage(), + 'line' => $fileSpecificError->getLine(), + 'ignorable' => $fileSpecificError->canBeIgnored(), + ]; + + if ($fileSpecificError->getTip() !== null) { + $message['tip'] = $tipFormatter->format($fileSpecificError->getTip()); + } + + if ($fileSpecificError->getIdentifier() !== null) { + $message['identifier'] = $fileSpecificError->getIdentifier(); + } + + $errorsArray['files'][$file]['messages'][] = $message; + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + $errorsArray['errors'][] = $notFileSpecificError; + } + + $json = Json::encode($errorsArray, $this->pretty ? Json::PRETTY : 0); + + $output->writeRaw($json); + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/src/Command/ErrorFormatter/JunitErrorFormatter.php b/src/Command/ErrorFormatter/JunitErrorFormatter.php new file mode 100644 index 00000000..dac150b2 --- /dev/null +++ b/src/Command/ErrorFormatter/JunitErrorFormatter.php @@ -0,0 +1,91 @@ +getTotalErrorsCount(); + $totalTestsCount = $analysisResult->hasErrors() ? $totalFailuresCount : 1; + + $result = ''; + $result .= sprintf( + '', + $totalFailuresCount, + $totalTestsCount, + ); + + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $fileName = $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()); + $result .= $this->createTestCase( + sprintf('%s:%s', $fileName, (string) $fileSpecificError->getLine()), + 'ERROR', + $fileSpecificError->getMessage(), + ); + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + $result .= $this->createTestCase('General error', 'ERROR', $notFileSpecificError); + } + + foreach ($analysisResult->getWarnings() as $warning) { + $result .= $this->createTestCase('Warning', 'WARNING', $warning); + } + + if (!$analysisResult->hasErrors()) { + $result .= $this->createTestCase('phpstan', ''); + } + + $result .= ''; + + $output->writeRaw($result); + + return $analysisResult->hasErrors() ? 1 : 0; + } + + /** + * Format a single test case + * + * + */ + private function createTestCase(string $reference, string $type, ?string $message = null): string + { + $result = sprintf('', $this->escape($reference)); + + if ($message !== null) { + $result .= sprintf('', $this->escape($type), $this->escape($message)); + } + + $result .= ''; + + return $result; + } + + /** + * Escapes values for using in XML + * + */ + private function escape(string $string): string + { + return htmlspecialchars($string, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + } + +} diff --git a/src/Command/ErrorFormatter/RawErrorFormatter.php b/src/Command/ErrorFormatter/RawErrorFormatter.php new file mode 100644 index 00000000..5771d699 --- /dev/null +++ b/src/Command/ErrorFormatter/RawErrorFormatter.php @@ -0,0 +1,50 @@ +getNotFileSpecificErrors() as $notFileSpecificError) { + $output->writeRaw(sprintf('?:?:%s', $notFileSpecificError)); + $output->writeLineFormatted(''); + } + + $outputIdentifiers = $output->isVerbose(); + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $identifier = ''; + if ($outputIdentifiers && $fileSpecificError->getIdentifier() !== null) { + $identifier = sprintf(' [identifier=%s]', $fileSpecificError->getIdentifier()); + } + + $output->writeRaw( + sprintf( + '%s:%d:%s%s', + $fileSpecificError->getFile(), + $fileSpecificError->getLine() ?? '?', + $fileSpecificError->getMessage(), + $identifier, + ), + ); + $output->writeLineFormatted(''); + } + + foreach ($analysisResult->getWarnings() as $warning) { + $output->writeRaw(sprintf('?:?:%s', $warning)); + $output->writeLineFormatted(''); + } + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/src/Command/ErrorFormatter/TableErrorFormatter.php b/src/Command/ErrorFormatter/TableErrorFormatter.php new file mode 100644 index 00000000..a0e3c75a --- /dev/null +++ b/src/Command/ErrorFormatter/TableErrorFormatter.php @@ -0,0 +1,167 @@ +ciDetectedErrorFormatter->formatErrors($analysisResult, $output); + $projectConfigFile = 'phpstan.neon'; + if ($analysisResult->getProjectConfigFile() !== null) { + $projectConfigFile = $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile()); + } + + $style = $output->getStyle(); + + if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) { + $style->success('No errors'); + + if ($this->showTipsOfTheDay) { + if ($analysisResult->isDefaultLevelUsed()) { + $output->writeLineFormatted('💡 Tip of the Day:'); + $output->writeLineFormatted(sprintf( + "PHPStan is performing only the most basic checks.\nYou can pass a higher rule level through the --%s option\n(the default and current level is %d) to analyse code more thoroughly.", + AnalyseCommand::OPTION_LEVEL, + AnalyseCommand::DEFAULT_LEVEL, + )); + $output->writeLineFormatted(''); + } + } + + return 0; + } + + /** @var array $fileErrors */ + $fileErrors = []; + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if (!isset($fileErrors[$fileSpecificError->getFile()])) { + $fileErrors[$fileSpecificError->getFile()] = []; + } + + $fileErrors[$fileSpecificError->getFile()][] = $fileSpecificError; + } + + foreach ($fileErrors as $file => $errors) { + $rows = []; + foreach ($errors as $error) { + $message = $error->getMessage(); + $filePath = $error->getTraitFilePath() ?? $error->getFilePath(); + if ($error->getIdentifier() !== null && $error->canBeIgnored()) { + $message .= "\n"; + $message .= '🪪 ' . $error->getIdentifier(); + } + if ($error->getTip() !== null) { + $tip = $error->getTip(); + $tip = str_replace('%configurationFile%', $projectConfigFile, $tip); + + $message .= "\n"; + if (str_contains($tip, "\n")) { + $lines = explode("\n", $tip); + foreach ($lines as $line) { + $message .= '💡 ' . ltrim($line, ' •') . "\n"; + } + } else { + $message .= '💡 ' . $tip; + } + } + if (is_string($this->editorUrl)) { + $url = str_replace( + ['%file%', '%relFile%', '%line%'], + [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()], + $this->editorUrl, + ); + + if (is_string($this->editorUrlTitle)) { + $title = str_replace( + ['%file%', '%relFile%', '%line%'], + [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()], + $this->editorUrlTitle, + ); + } else { + $title = $this->relativePathHelper->getRelativePath($filePath); + } + + $message .= "\n✏️ ' . $title . ''; + } + $rows[] = [ + $this->formatLineNumber($error->getLine()), + $message, + ]; + } + + $style->table(['Line', $this->relativePathHelper->getRelativePath($file)], $rows); + } + + if (count($analysisResult->getNotFileSpecificErrors()) > 0) { + $style->table(['', 'Error'], array_map(static fn (string $error): array => ['', OutputFormatter::escape($error)], $analysisResult->getNotFileSpecificErrors())); + } + + $warningsCount = count($analysisResult->getWarnings()); + if ($warningsCount > 0) { + $style->table(['', 'Warning'], array_map(static fn (string $warning): array => ['', OutputFormatter::escape($warning)], $analysisResult->getWarnings())); + } + + $finalMessage = sprintf($analysisResult->getTotalErrorsCount() === 1 ? 'Found %d error' : 'Found %d errors', $analysisResult->getTotalErrorsCount()); + if ($warningsCount > 0) { + $finalMessage .= sprintf($warningsCount === 1 ? ' and %d warning' : ' and %d warnings', $warningsCount); + } + + if ($analysisResult->getTotalErrorsCount() > 0) { + $style->error($finalMessage); + } else { + $style->warning($finalMessage); + } + + return $analysisResult->getTotalErrorsCount() > 0 ? 1 : 0; + } + + private function formatLineNumber(?int $lineNumber): string + { + if ($lineNumber === null) { + return ''; + } + + $isRunningInVSCodeTerminal = getenv('TERM_PROGRAM') === 'vscode'; + if ($isRunningInVSCodeTerminal) { + return ':' . $lineNumber; + } + + return (string) $lineNumber; + } + +} diff --git a/src/Command/ErrorFormatter/TeamcityErrorFormatter.php b/src/Command/ErrorFormatter/TeamcityErrorFormatter.php new file mode 100644 index 00000000..c7255c23 --- /dev/null +++ b/src/Command/ErrorFormatter/TeamcityErrorFormatter.php @@ -0,0 +1,117 @@ +getFileSpecificErrors(); + $notFileSpecificErrors = $analysisResult->getNotFileSpecificErrors(); + $warnings = $analysisResult->getWarnings(); + + if (count($fileSpecificErrors) === 0 && count($notFileSpecificErrors) === 0 && count($warnings) === 0) { + return 0; + } + + $result .= $this->createTeamcityLine('inspectionType', [ + 'id' => 'phpstan', + 'name' => 'phpstan', + 'category' => 'phpstan', + 'description' => 'phpstan Inspection', + ]); + + foreach ($fileSpecificErrors as $fileSpecificError) { + $result .= $this->createTeamcityLine('inspection', [ + 'typeId' => 'phpstan', + 'message' => $fileSpecificError->getMessage(), + 'file' => $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()), + 'line' => $fileSpecificError->getLine(), + // additional attributes + 'SEVERITY' => 'ERROR', + 'ignorable' => $fileSpecificError->canBeIgnored(), + 'tip' => $fileSpecificError->getTip(), + ]); + } + + foreach ($notFileSpecificErrors as $notFileSpecificError) { + $result .= $this->createTeamcityLine('inspection', [ + 'typeId' => 'phpstan', + 'message' => $notFileSpecificError, + // the file is required + 'file' => $analysisResult->getProjectConfigFile() !== null ? $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile()) : '.', + 'SEVERITY' => 'ERROR', + ]); + } + + foreach ($warnings as $warning) { + $result .= $this->createTeamcityLine('inspection', [ + 'typeId' => 'phpstan', + 'message' => $warning, + // the file is required + 'file' => $analysisResult->getProjectConfigFile() !== null ? $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile()) : '.', + 'SEVERITY' => 'WARNING', + ]); + } + + $output->writeRaw($result); + + return $analysisResult->hasErrors() ? 1 : 0; + } + + /** + * Creates a Teamcity report line + * + * @param string $messageName The message name + * @param mixed[] $keyValuePairs The key=>value pairs + * @return string The Teamcity report line + */ + private function createTeamcityLine(string $messageName, array $keyValuePairs): string + { + $string = '##teamcity[' . $messageName; + foreach ($keyValuePairs as $key => $value) { + if (is_string($value)) { + $value = $this->escape($value); + } + $string .= ' ' . $key . '=\'' . $value . '\''; + } + return $string . ']' . PHP_EOL; + } + + /** + * Escapes the given string for Teamcity output + * + * @param string $string The string to escape + * @return string The escaped string + */ + private function escape(string $string): string + { + $replacements = [ + '~\n~' => '|n', + '~\r~' => '|r', + '~([\'\|\[\]])~' => '|$1', + ]; + return (string) preg_replace(array_keys($replacements), array_values($replacements), $string); + } + +} diff --git a/src/Command/ErrorsConsoleStyle.php b/src/Command/ErrorsConsoleStyle.php new file mode 100644 index 00000000..1e62257a --- /dev/null +++ b/src/Command/ErrorsConsoleStyle.php @@ -0,0 +1,209 @@ +showProgress = $input->hasOption(self::OPTION_NO_PROGRESS) && !(bool) $input->getOption(self::OPTION_NO_PROGRESS); + } + + private function isCiDetected(): bool + { + if ($this->isCiDetected === null) { + $ciDetector = new CiDetector(); + $this->isCiDetected = $ciDetector->isCiDetected(); + } + + return $this->isCiDetected; + } + + /** + * @param string[] $headers + * @param string[][] $rows + */ + public function table(array $headers, array $rows): void + { + /** @var int $terminalWidth */ + $terminalWidth = (new Terminal())->getWidth() - 2; + $maxHeaderWidth = strlen($headers[0]); + foreach ($rows as $row) { + $length = strlen($row[0]); + if ($maxHeaderWidth !== 0 && $length <= $maxHeaderWidth) { + continue; + } + + $maxHeaderWidth = $length; + } + + // manual wrapping could be replaced with $table->setColumnMaxWidth() + // but it's buggy for lines + // https://github.com/symfony/symfony/issues/45520 + // https://github.com/symfony/symfony/issues/45521 + $headers = $this->wrap($headers, $terminalWidth, $maxHeaderWidth); + foreach ($headers as $i => $header) { + $newHeader = []; + foreach (explode("\n", $header) as $h) { + $newHeader[] = sprintf('%s', $h); + } + + $headers[$i] = implode("\n", $newHeader); + } + + foreach ($rows as $i => $row) { + $rows[$i] = $this->wrap($row, $terminalWidth, $maxHeaderWidth); + } + + $table = $this->createTable(); + array_unshift($rows, $headers, new TableSeparator()); + $table->setRows($rows); + + $table->render(); + $this->newLine(); + } + + /** + * @param string[] $rows + * @return string[] + */ + private function wrap(array $rows, int $terminalWidth, int $maxHeaderWidth): array + { + foreach ($rows as $i => $column) { + $columnRows = explode("\n", $column); + foreach ($columnRows as $k => $columnRow) { + if (str_starts_with($columnRow, '✏️')) { + continue; + } + $wrapped = wordwrap( + $columnRow, + $terminalWidth - $maxHeaderWidth - 5, + ); + if (str_starts_with($columnRow, '💡 ')) { + $wrappedLines = explode("\n", $wrapped); + $newWrappedLines = []; + foreach ($wrappedLines as $l => $line) { + if ($l === 0) { + $newWrappedLines[] = $line; + continue; + } + + $newWrappedLines[] = ' ' . $line; + } + $columnRows[$k] = implode("\n", $newWrappedLines); + } else { + $columnRows[$k] = $wrapped; + } + + } + + $rows[$i] = implode("\n", $columnRows); + } + + return $rows; + } + + public function createProgressBar(int $max = 0): ProgressBar + { + $this->progressBar = parent::createProgressBar($max); + + $format = $this->getProgressBarFormat(); + if ($format !== null) { + $this->progressBar->setFormat($format); + } + + $ci = $this->isCiDetected(); + $this->progressBar->setOverwrite(!$ci); + + if ($ci) { + $this->progressBar->minSecondsBetweenRedraws(15); + $this->progressBar->maxSecondsBetweenRedraws(30); + } elseif (DIRECTORY_SEPARATOR === '\\') { + $this->progressBar->minSecondsBetweenRedraws(0.5); + $this->progressBar->maxSecondsBetweenRedraws(2); + } else { + $this->progressBar->minSecondsBetweenRedraws(0.1); + $this->progressBar->maxSecondsBetweenRedraws(0.5); + } + + return $this->progressBar; + } + + private function getProgressBarFormat(): ?string + { + switch ($this->getVerbosity()) { + case OutputInterface::VERBOSITY_NORMAL: + $formatName = ProgressBar::FORMAT_NORMAL; + break; + case OutputInterface::VERBOSITY_VERBOSE: + $formatName = ProgressBar::FORMAT_VERBOSE; + break; + case OutputInterface::VERBOSITY_VERY_VERBOSE: + case OutputInterface::VERBOSITY_DEBUG: + $formatName = ProgressBar::FORMAT_VERY_VERBOSE; + break; + default: + $formatName = null; + break; + } + + if ($formatName === null) { + return null; + } + + return ProgressBar::getFormatDefinition($formatName); + } + + public function progressStart(int $max = 0): void + { + if (!$this->showProgress) { + return; + } + parent::progressStart($max); + } + + public function progressAdvance(int $step = 1): void + { + if (!$this->showProgress) { + return; + } + + parent::progressAdvance($step); + } + + public function progressFinish(): void + { + if (!$this->showProgress) { + return; + } + parent::progressFinish(); + } + +} diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php new file mode 100644 index 00000000..370c1366 --- /dev/null +++ b/src/Command/FixerApplication.php @@ -0,0 +1,589 @@ +|null */ + private PromiseInterface|null $processInProgress = null; + + private bool $fileMonitorActive = true; + + /** + * @param string[] $analysedPaths + * @param list $dnsServers + * @param string[] $composerAutoloaderProjectPaths + * @param string[] $allConfigFiles + * @param string[] $bootstrapFiles + */ + public function __construct( + private FileMonitor $fileMonitor, + private IgnoredErrorHelper $ignoredErrorHelper, + private StubFilesProvider $stubFilesProvider, + private array $analysedPaths, + private string $currentWorkingDirectory, + private string $proTmpDir, + private array $dnsServers, + private array $composerAutoloaderProjectPaths, + private array $allConfigFiles, + private ?string $cliAutoloadFile, + private array $bootstrapFiles, + private ?string $editorUrl, + private string $usedLevel, + ) + { + } + + public function run( + ?string $projectConfigFile, + InputInterface $input, + OutputInterface $output, + int $filesCount, + string $mainScript, + ): int + { + $loop = new StreamSelectLoop(); + $server = new TcpServer('127.0.0.1:0', $loop); + /** @var string $serverAddress */ + $serverAddress = $server->getAddress(); + + /** @var int<0, 65535> $serverPort */ + $serverPort = parse_url($serverAddress, PHP_URL_PORT); + + $server->on('connection', function (ConnectionInterface $connection) use ($loop, $projectConfigFile, $input, $output, $mainScript, $filesCount): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; + // phpcs:enable + $decoder = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, 128 * 1024 * 1024); + $encoder = new Encoder($connection, $jsonInvalidUtf8Ignore); + $encoder->write(['action' => 'initialData', 'data' => [ + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'analysedPaths' => $this->analysedPaths, + 'projectConfigFile' => $projectConfigFile, + 'filesCount' => $filesCount, + 'phpstanVersion' => ComposerHelper::getPhpStanVersion(), + 'editorUrl' => $this->editorUrl, + 'ruleLevel' => $this->usedLevel, + ]]); + $decoder->on('data', function (array $data) use ( + $output, + ): void { + if ($data['action'] === 'webPort') { + $output->writeln(sprintf('Open your web browser at: http://127.0.0.1:%d', $data['data']['port'])); + $output->writeln('Press [Ctrl-C] to quit.'); + return; + } + if ($data['action'] === 'resumeFileMonitor') { + $this->fileMonitorActive = true; + return; + } + if ($data['action'] === 'pauseFileMonitor') { + $this->fileMonitorActive = false; + return; + } + }); + + $this->fileMonitor->initialize(array_merge( + $this->analysedPaths, + $this->getComposerLocks(), + $this->getComposerInstalled(), + $this->getExecutedFiles(), + $this->getStubFiles(), + $this->allConfigFiles, + )); + + $this->analyse( + $loop, + $mainScript, + $projectConfigFile, + $input, + $output, + $encoder, + ); + + $this->monitorFileChanges($loop, function (FileMonitorResult $changes) use ($loop, $mainScript, $projectConfigFile, $input, $encoder, $output): void { + if ($this->processInProgress !== null) { + $this->processInProgress->cancel(); + $this->processInProgress = null; + } + + if (count($changes->getChangedFiles()) > 0) { + $encoder->write(['action' => 'changedFiles', 'data' => [ + 'paths' => $changes->getChangedFiles(), + ]]); + } + + $this->analyse( + $loop, + $mainScript, + $projectConfigFile, + $input, + $output, + $encoder, + ); + }); + }); + + try { + $fixerProcess = $this->getFixerProcess($output, $serverPort); + } catch (FixerProcessException) { + return 1; + } + + $fixerProcess->start($loop); + $fixerProcess->on('exit', function ($exitCode) use ($output, $loop): void { + $loop->stop(); + if ($exitCode === null) { + return; + } + if ($exitCode === 0) { + return; + } + $output->writeln(sprintf('PHPStan Pro process exited with code %d.', $exitCode)); + @unlink($this->proTmpDir . '/phar-info.json'); + }); + + $loop->run(); + + return 0; + } + + /** + * @throws FixerProcessException + */ + private function getFixerProcess(OutputInterface $output, int $serverPort): Process + { + try { + DirectoryCreator::ensureDirectoryExists($this->proTmpDir, 0777); + } catch (DirectoryCreatorException $e) { + $output->writeln($e->getMessage()); + throw new FixerProcessException(); + } + + $pharPath = $this->proTmpDir . '/phpstan-fixer.phar'; + $infoPath = $this->proTmpDir . '/phar-info.json'; + + try { + $this->downloadPhar($output, $pharPath, $infoPath); + } catch (RuntimeException $e) { + if (!is_file($pharPath)) { + $this->printDownloadError($output, $e); + + throw new FixerProcessException(); + } + } + + $pubKeyPath = $pharPath . '.pubkey'; + FileWriter::write($pubKeyPath, FileReader::read(__DIR__ . '/fixer-phar.pubkey')); + + try { + $phar = new Phar($pharPath); + } catch (Throwable $e) { + @unlink($pharPath); + @unlink($infoPath); + $output->writeln('PHPStan Pro PHAR signature is corrupted.'); + $output->writeln(sprintf('%s: %s', get_class($e), $e->getMessage())); + + throw new FixerProcessException(); + } + + if ($phar->getSignature()['hash_type'] !== 'OpenSSL') { + @unlink($pharPath); + @unlink($infoPath); + $output->writeln('PHPStan Pro PHAR signature is corrupted.'); + $output->writeln(sprintf('Wrong hash type: %s', $phar->getSignature()['hash_type'])); + + throw new FixerProcessException(); + } + + $env = getenv(); + $env['PHPSTAN_PRO_TMP_DIR'] = $this->proTmpDir; + $forcedPort = $_SERVER['PHPSTAN_PRO_WEB_PORT'] ?? null; + if ($forcedPort !== null) { + $env['PHPSTAN_PRO_WEB_PORT'] = $_SERVER['PHPSTAN_PRO_WEB_PORT']; + $isDocker = $this->isDockerRunning(); + if ($isDocker) { + $output->writeln('Running in Docker? Don\'t forget to do these steps:'); + + $output->writeln('1) Publish this port when running Docker:'); + $output->writeln(sprintf(' -p 127.0.0.1:%d:%d', $_SERVER['PHPSTAN_PRO_WEB_PORT'], $_SERVER['PHPSTAN_PRO_WEB_PORT'])); + $output->writeln('2) Map the temp directory to a persistent volume'); + $output->writeln(' so that you don\'t have to log in every time:'); + $output->writeln(sprintf(' -v ~/.phpstan-pro:%s', $this->proTmpDir)); + $output->writeln(''); + } + } else { + $isDocker = $this->isDockerRunning(); + if ($isDocker) { + $output->writeln('Running in Docker? You need to do these steps in order to launch PHPStan Pro:'); + $output->writeln(''); + $output->writeln('1) Set the PHPSTAN_PRO_WEB_PORT environment variable in the Dockerfile:'); + $output->writeln(' ENV PHPSTAN_PRO_WEB_PORT=11111'); + $output->writeln('2) Expose this port in the Dockerfile:'); + $output->writeln(' EXPOSE 11111'); + $output->writeln('3) Publish this port when running Docker:'); + $output->writeln(' -p 127.0.0.1:11111:11111'); + $output->writeln('4) Map the temp directory to a persistent volume'); + $output->writeln(' so that you don\'t have to log in every time:'); + $output->writeln(sprintf(' -v ~/phpstan-pro:%s', $this->proTmpDir)); + $output->writeln(''); + } + } + + return new Process(sprintf('%s -d memory_limit=%s %s --port %d', escapeshellarg(PHP_BINARY), escapeshellarg(ini_get('memory_limit')), escapeshellarg($pharPath), $serverPort), null, $env, []); + } + + private function downloadPhar( + OutputInterface $output, + string $pharPath, + string $infoPath, + ): void + { + $currentVersion = null; + $branch = '2.0.x'; + if (is_file($pharPath) && is_file($infoPath)) { + /** @var array{version: string, date: string, branch?: string} $currentInfo */ + $currentInfo = Json::decode(FileReader::read($infoPath), Json::FORCE_ARRAY); + $currentVersion = $currentInfo['version']; + $currentBranch = $currentInfo['branch'] ?? 'master'; + $currentDate = DateTime::createFromFormat(DateTime::ATOM, $currentInfo['date']); + if ($currentDate === false) { + throw new ShouldNotHappenException(); + } + if ( + $currentBranch === $branch + && (new DateTimeImmutable('', new DateTimeZone('UTC'))) <= $currentDate->modify('+24 hours') + ) { + return; + } + + $output->writeln('Checking if there\'s a new PHPStan Pro release...'); + } + + $dnsConfig = new Config(); + $dnsConfig->nameservers = $this->dnsServers; + + $client = new Browser( + new Connector( + [ + 'timeout' => 5, + 'tls' => [ + 'cafile' => CaBundle::getBundledCaBundlePath(), + ], + 'dns' => $dnsConfig, + ], + ), + ); + + /** + * @var array{url: string, version: string} $latestInfo + */ + $latestInfo = Json::decode((string) await($client->get(sprintf('https://fixer-download-api.phpstan.com/latest?%s', http_build_query(['phpVersion' => PHP_VERSION_ID, 'branch' => $branch]))))->getBody(), Json::FORCE_ARRAY); + if ($currentVersion !== null && $latestInfo['version'] === $currentVersion) { + $this->writeInfoFile($infoPath, $latestInfo['version'], $branch); + $output->writeln('You\'re running the latest PHPStan Pro!'); + return; + } + + $output->writeln('Downloading the latest PHPStan Pro...'); + + $pharPathResource = fopen($pharPath, 'w'); + if ($pharPathResource === false) { + throw new ShouldNotHappenException(sprintf('Could not open file %s for writing.', $pharPath)); + } + $progressBar = new ProgressBar($output); + $client->requestStreaming('GET', $latestInfo['url'])->then(static function (ResponseInterface $response) use ($progressBar, $pharPathResource): void { + $body = $response->getBody(); + if (!$body instanceof ReadableStreamInterface) { + throw new ShouldNotHappenException(); + } + + $totalSize = (int) $response->getHeaderLine('Content-Length'); + $progressBar->setFormat('file_download'); + $progressBar->setMessage(sprintf('%.2f MB', $totalSize / 1000000), 'fileSize'); + $progressBar->start($totalSize); + + $bytes = 0; + $body->on('data', static function ($chunk) use ($pharPathResource, $progressBar, &$bytes): void { + $bytes += strlen($chunk); + fwrite($pharPathResource, $chunk); + $progressBar->setProgress($bytes); + }); + }, function (Throwable $e) use ($output): void { + $this->printDownloadError($output, $e); + }); + + Loop::run(); + + fclose($pharPathResource); + + $progressBar->finish(); + $output->writeln(''); + $output->writeln(''); + + $this->writeInfoFile($infoPath, $latestInfo['version'], $branch); + } + + private function printDownloadError(OutputInterface $output, Throwable $e): void + { + $output->writeln(sprintf('Could not download the PHPStan Pro executable: %s', $e->getMessage())); + $output->writeln(''); + $output->writeln('Try different DNS servers in your configuration file:'); + $output->writeln(''); + $output->writeln('parameters:'); + $output->writeln("\tpro:"); + $output->writeln("\t\tdnsServers!:"); + $output->writeln("\t\t\t- '8.8.8.8'"); + $output->writeln(''); + } + + private function writeInfoFile(string $infoPath, string $version, string $branch): void + { + FileWriter::write($infoPath, Json::encode([ + 'version' => $version, + 'branch' => $branch, + 'date' => (new DateTimeImmutable('', new DateTimeZone('UTC')))->format(DateTime::ATOM), + ])); + } + + /** + * @param callable(FileMonitorResult): void $hasChangesCallback + */ + private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback): void + { + $callback = function () use (&$callback, $loop, $hasChangesCallback): void { + if (!$this->fileMonitorActive) { + $loop->addTimer(1.0, $callback); + return; + } + if ($this->processInProgress !== null) { + $loop->addTimer(1.0, $callback); + return; + } + $changes = $this->fileMonitor->getChanges(); + + if ($changes->hasAnyChanges()) { + $hasChangesCallback($changes); + } + + $loop->addTimer(1.0, $callback); + }; + $loop->addTimer(1.0, $callback); + } + + private function analyse( + LoopInterface $loop, + string $mainScript, + ?string $projectConfigFile, + InputInterface $input, + OutputInterface $output, + Encoder $phpstanFixerEncoder, + ): void + { + $ignoredErrorHelperResult = $this->ignoredErrorHelper->initialize(); + if (count($ignoredErrorHelperResult->getErrors()) > 0) { + throw new ShouldNotHappenException(); + } + + // TCP server for fixer:worker (TCP client) + $server = new TcpServer('127.0.0.1:0', $loop); + /** @var string $serverAddress */ + $serverAddress = $server->getAddress(); + /** @var int<0, 65535> $serverPort */ + $serverPort = parse_url($serverAddress, PHP_URL_PORT); + + $server->on('connection', static function (ConnectionInterface $connection) use ($phpstanFixerEncoder): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; + // phpcs:enable + $decoder = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, 128 * 1024 * 1024); + $decoder->on('data', static function (array $data) use ($phpstanFixerEncoder): void { + $phpstanFixerEncoder->write($data); + }); + }); + + $process = new ProcessPromise($loop, 'changedFileAnalysis', ProcessHelper::getWorkerCommand( + $mainScript, + 'fixer:worker', + $projectConfigFile, + [ + '--server-port', + (string) $serverPort, + ], + $input, + )); + $this->processInProgress = $process->run(); + + $this->processInProgress->then(function () use ($server): void { + $this->processInProgress = null; + $server->close(); + }, function (Throwable $e) use ($server, $phpstanFixerEncoder): void { + $this->processInProgress = null; + $server->close(); + + if ($e instanceof ProcessCanceledException) { + return; + } + + if ($e instanceof ProcessCrashedException) { + $message = 'Analysis crashed'; + $traceAsString = $e->getMessage(); + $trace = []; + } else { + $message = $e->getMessage(); + $traceAsString = $e->getTraceAsString(); + $trace = InternalError::prepareTrace($e); + } + $phpstanFixerEncoder->write(['action' => 'analysisCrash', 'data' => [ + 'internalErrors' => [new InternalError( + $message, + 'running PHPStan Pro worker', + $trace, + $traceAsString, + false, + )], + ]]); + }); + } + + private function isDockerRunning(): bool + { + return is_file('/.dockerenv'); + } + + /** + * @return list + */ + private function getComposerLocks(): array + { + $locks = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloadPath) { + $lockPath = $autoloadPath . '/composer.lock'; + if (!is_file($lockPath)) { + continue; + } + + $locks[] = $lockPath; + } + + return $locks; + } + + /** + * @return list + */ + private function getComposerInstalled(): array + { + $files = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloadPath) { + $composer = ComposerHelper::getComposerConfig($autoloadPath); + if ($composer === null) { + continue; + } + + $filePath = ComposerHelper::getVendorDirFromComposerConfig($autoloadPath, $composer) . '/composer/installed.php'; + if (!is_file($filePath)) { + continue; + } + + $files[] = $filePath; + } + + return $files; + } + + /** + * @return list + */ + private function getExecutedFiles(): array + { + $files = []; + if ($this->cliAutoloadFile !== null) { + $files[] = $this->cliAutoloadFile; + } + + foreach ($this->bootstrapFiles as $bootstrapFile) { + $files[] = $bootstrapFile; + } + + return $files; + } + + /** + * @return list + */ + private function getStubFiles(): array + { + $stubFiles = []; + foreach ($this->stubFilesProvider->getProjectStubFiles() as $stubFile) { + $stubFiles[] = $stubFile; + } + + return $stubFiles; + } + +} diff --git a/src/Command/FixerProcessException.php b/src/Command/FixerProcessException.php new file mode 100644 index 00000000..04803069 --- /dev/null +++ b/src/Command/FixerProcessException.php @@ -0,0 +1,11 @@ +setName(self::NAME) + ->setDescription('(Internal) Support for PHPStan Pro.') + ->setDefinition([ + new InputArgument('paths', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Paths with source code to run analysis on'), + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for analysis'), + new InputOption('xdebug', null, InputOption::VALUE_NONE, 'Allow running with Xdebug for debugging purposes'), + new InputOption('server-port', null, InputOption::VALUE_REQUIRED, 'Server port for FixerApplication'), + ]) + ->setHidden(true); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getArgument('paths'); + $memoryLimit = $input->getOption('memory-limit'); + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $level = $input->getOption(AnalyseCommand::OPTION_LEVEL); + $allowXdebug = $input->getOption('xdebug'); + $serverPort = $input->getOption('server-port'); + + if ( + !is_array($paths) + || (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($level) && $level !== null) + || (!is_bool($allowXdebug)) + || (!is_string($serverPort)) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + $paths, + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + $level, + $allowXdebug, + false, + false, + ); + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $container = $inceptionResult->getContainer(); + + /** @var IgnoredErrorHelper $ignoredErrorHelper */ + $ignoredErrorHelper = $container->getByType(IgnoredErrorHelper::class); + $ignoredErrorHelperResult = $ignoredErrorHelper->initialize(); + if (count($ignoredErrorHelperResult->getErrors()) > 0) { + throw new ShouldNotHappenException(); + } + + $loop = new StreamSelectLoop(); + $tcpConnector = new TcpConnector($loop); + $tcpConnector->connect(sprintf('127.0.0.1:%d', $serverPort))->then(function (ConnectionInterface $connection) use ($container, $inceptionResult, $configuration, $input, $ignoredErrorHelperResult, $loop): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; + // phpcs:enable + $out = new Encoder($connection, $jsonInvalidUtf8Ignore); + //$in = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, 128 * 1024 * 1024); + + /** @var ResultCacheManager $resultCacheManager */ + $resultCacheManager = $container->getByType(ResultCacheManagerFactory::class)->create(); + $projectConfigArray = $inceptionResult->getProjectConfigArray(); + + /** @var AnalyserResultFinalizer $analyserResultFinalizer */ + $analyserResultFinalizer = $container->getByType(AnalyserResultFinalizer::class); + + try { + [$inceptionFiles, $isOnlyFiles] = $inceptionResult->getFiles(); + } catch (PathNotFoundException | InceptionNotSuccessfulException) { + throw new ShouldNotHappenException(); + } + + $out->write([ + 'action' => 'analysisStart', + 'result' => [ + 'analysedFiles' => $inceptionFiles, + ], + ]); + + $resultCache = $resultCacheManager->restore($inceptionFiles, false, false, $projectConfigArray, $inceptionResult->getErrorOutput()); + + $errorsFromResultCacheTmp = $resultCache->getErrors(); + $locallyIgnoredErrorsFromResultCacheTmp = $resultCache->getLocallyIgnoredErrors(); + foreach ($resultCache->getFilesToAnalyse() as $fileToAnalyse) { + unset($errorsFromResultCacheTmp[$fileToAnalyse]); + unset($locallyIgnoredErrorsFromResultCacheTmp[$fileToAnalyse]); + } + + $errorsFromResultCache = []; + foreach ($errorsFromResultCacheTmp as $errorsByFile) { + foreach ($errorsByFile as $error) { + $errorsFromResultCache[] = $error; + } + } + + [$errorsFromResultCache, $ignoredErrorsFromResultCache] = $this->filterErrors($errorsFromResultCache, $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles, false); + + foreach ($locallyIgnoredErrorsFromResultCacheTmp as $locallyIgnoredErrors) { + foreach ($locallyIgnoredErrors as $locallyIgnoredError) { + $ignoredErrorsFromResultCache[] = [$locallyIgnoredError, null]; + } + } + + $out->write([ + 'action' => 'analysisStream', + 'result' => [ + 'errors' => $errorsFromResultCache, + 'ignoredErrors' => $ignoredErrorsFromResultCache, + 'analysedFiles' => array_diff($inceptionFiles, $resultCache->getFilesToAnalyse()), + ], + ]); + + $filesToAnalyse = $resultCache->getFilesToAnalyse(); + usort($filesToAnalyse, static function (string $a, string $b): int { + $aTime = @filemtime($a); + if ($aTime === false) { + return 1; + } + + $bTime = @filemtime($b); + if ($bTime === false) { + return -1; + } + + // files are sorted from the oldest + // because ParallelAnalyser reverses the scheduler jobs to do the smallest + // jobs first + return $aTime <=> $bTime; + }); + + $this->runAnalyser( + $loop, + $container, + $filesToAnalyse, + $configuration, + $input, + function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ($out, $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles): void { + $internalErrors = []; + foreach ($errors as $fileSpecificError) { + if (!$fileSpecificError->hasNonIgnorableException()) { + continue; + } + + $internalErrors[] = $this->transformErrorIntoInternalError($fileSpecificError); + } + + if (count($internalErrors) > 0) { + $out->write(['action' => 'analysisCrash', 'data' => [ + 'internalErrors' => $internalErrors, + ]]); + return; + } + + [$errors, $ignoredErrors] = $this->filterErrors($errors, $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles, false); + foreach ($locallyIgnoredErrors as $locallyIgnoredError) { + $ignoredErrors[] = [$locallyIgnoredError, null]; + } + $out->write([ + 'action' => 'analysisStream', + 'result' => [ + 'errors' => $errors, + 'ignoredErrors' => $ignoredErrors, + 'analysedFiles' => $analysedFiles, + ], + ]); + }, + )->then(function (AnalyserResult $intermediateAnalyserResult) use ($analyserResultFinalizer, $resultCacheManager, $resultCache, $inceptionResult, $isOnlyFiles, $ignoredErrorHelperResult, $inceptionFiles, $out): void { + $analyserResult = $resultCacheManager->process( + $intermediateAnalyserResult, + $resultCache, + $inceptionResult->getErrorOutput(), + false, + true, + )->getAnalyserResult(); + $finalizerResult = $analyserResultFinalizer->finalize($analyserResult, $isOnlyFiles, false); + + $internalErrors = []; + foreach ($finalizerResult->getAnalyserResult()->getInternalErrors() as $internalError) { + $internalErrors[] = new InternalError( + $internalError->getTraceAsString() !== null ? sprintf('Internal error: %s', $internalError->getMessage()) : $internalError->getMessage(), + $internalError->getContextDescription(), + $internalError->getTrace(), + $internalError->getTraceAsString(), + $internalError->shouldReportBug(), + ); + } + + foreach ($finalizerResult->getAnalyserResult()->getUnorderedErrors() as $fileSpecificError) { + if (!$fileSpecificError->hasNonIgnorableException()) { + continue; + } + + $internalErrors[] = $this->transformErrorIntoInternalError($fileSpecificError); + } + + $hasInternalErrors = count($internalErrors) > 0 || $finalizerResult->getAnalyserResult()->hasReachedInternalErrorsCountLimit(); + + if ($hasInternalErrors) { + $out->write(['action' => 'analysisCrash', 'data' => [ + 'internalErrors' => count($internalErrors) > 0 ? $internalErrors : [ + new InternalError( + 'Internal error occurred', + 'running analyser in PHPStan Pro worker', + [], + null, + false, + ), + ], + ]]); + } + + [$collectorErrors, $ignoredCollectorErrors] = $this->filterErrors($finalizerResult->getCollectorErrors(), $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles, $hasInternalErrors); + foreach ($finalizerResult->getLocallyIgnoredCollectorErrors() as $locallyIgnoredCollectorError) { + $ignoredCollectorErrors[] = [$locallyIgnoredCollectorError, null]; + } + $out->write([ + 'action' => 'analysisStream', + 'result' => [ + 'errors' => $collectorErrors, + 'ignoredErrors' => $ignoredCollectorErrors, + 'analysedFiles' => [], + ], + ]); + + $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process( + $finalizerResult->getErrors(), + $isOnlyFiles, + $inceptionFiles, + $hasInternalErrors, + ); + $ignoreFileErrors = []; + foreach ($ignoredErrorHelperProcessedResult->getNotIgnoredErrors() as $error) { + if ($error->getIdentifier() === null) { + continue; + } + if (!in_array($error->getIdentifier(), ['ignore.count', 'ignore.unmatched', 'ignore.unmatchedLine', 'ignore.unmatchedIdentifier'], true)) { + continue; + } + $ignoreFileErrors[] = $error; + } + + $out->end([ + 'action' => 'analysisEnd', + 'result' => [ + 'ignoreFileErrors' => $ignoreFileErrors, + 'ignoreNotFileErrors' => $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages(), + ], + ]); + }); + }); + $loop->run(); + + return 0; + } + + private function transformErrorIntoInternalError(Error $error): InternalError + { + $message = $error->getMessage(); + $metadata = $error->getMetadata(); + if ( + $error->getIdentifier() === 'phpstan.internal' + && array_key_exists(InternalError::STACK_TRACE_AS_STRING_METADATA_KEY, $metadata) + ) { + $message = sprintf('Internal error: %s', $message); + } + + return new InternalError( + $message, + sprintf('analysing file %s', $error->getTraitFilePath() ?? $error->getFilePath()), + $metadata[InternalError::STACK_TRACE_METADATA_KEY] ?? [], + $metadata[InternalError::STACK_TRACE_AS_STRING_METADATA_KEY] ?? null, + true, + ); + } + + /** + * @param string[] $inceptionFiles + * @param array $errors + * @return array{list, list} + */ + private function filterErrors(array $errors, IgnoredErrorHelperResult $ignoredErrorHelperResult, bool $onlyFiles, array $inceptionFiles, bool $hasInternalErrors): array + { + $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $inceptionFiles, $hasInternalErrors); + $finalErrors = []; + foreach ($ignoredErrorHelperProcessedResult->getNotIgnoredErrors() as $error) { + if ($error->getIdentifier() === null) { + $finalErrors[] = $error; + continue; + } + if (in_array($error->getIdentifier(), ['ignore.count', 'ignore.unmatched'], true)) { + continue; + } + $finalErrors[] = $error; + } + + return [ + $finalErrors, + $ignoredErrorHelperProcessedResult->getIgnoredErrors(), + ]; + } + + /** + * @param string[] $files + * @param callable(list, list, string[]): void $onFileAnalysisHandler + * @return PromiseInterface + */ + private function runAnalyser(LoopInterface $loop, Container $container, array $files, ?string $configuration, InputInterface $input, callable $onFileAnalysisHandler): PromiseInterface + { + /** @var ParallelAnalyser $parallelAnalyser */ + $parallelAnalyser = $container->getByType(ParallelAnalyser::class); + $filesCount = count($files); + if ($filesCount === 0) { + return resolve(new AnalyserResult([], [], [], [], [], [], [], [], [], [], false, memory_get_peak_usage(true))); + } + + /** @var Scheduler $scheduler */ + $scheduler = $container->getByType(Scheduler::class); + + /** @var CpuCoreCounter $cpuCoreCounter */ + $cpuCoreCounter = $container->getByType(CpuCoreCounter::class); + + $schedule = $scheduler->scheduleWork($cpuCoreCounter->getNumberOfCpuCores(), $files); + $mainScript = null; + if (isset($_SERVER['argv'][0]) && is_file($_SERVER['argv'][0])) { + $mainScript = $_SERVER['argv'][0]; + } + + return $parallelAnalyser->analyse( + $loop, + $schedule, + $mainScript, + null, + $configuration, + $input, + $onFileAnalysisHandler, + ); + } + +} diff --git a/src/Command/IgnoredRegexValidator.php b/src/Command/IgnoredRegexValidator.php new file mode 100644 index 00000000..acb46065 --- /dev/null +++ b/src/Command/IgnoredRegexValidator.php @@ -0,0 +1,162 @@ +removeDelimiters($regex); + + try { + /** @var TreeNode $ast */ + $ast = $this->parser->parse($regex); + } catch (Exception) { + return new IgnoredRegexValidatorResult([], false, false); + } + + if (Strings::match($regex, '~(?getIgnoredTypes($ast), + $this->hasAnchorsInTheMiddle($ast), + false, + ); + } + + /** + * @return array + */ + private function getIgnoredTypes(TreeNode $ast): array + { + /** @var TreeNode|null $alternation */ + $alternation = $ast->getChild(0); + if ($alternation === null) { + return []; + } + + if ($alternation->getId() !== '#alternation') { + return []; + } + + $types = []; + foreach ($alternation->getChildren() as $child) { + $text = $this->getText($child); + if ($text === null) { + continue; + } + + $matches = Strings::match($text, '#^([a-zA-Z0-9]+)[,]?\s*#'); + if ($matches === null) { + continue; + } + + try { + $type = $this->typeStringResolver->resolve($matches[1], null); + } catch (ParserException) { + continue; + } + + if ($type instanceof ObjectType) { + continue; + } + + $typeDescription = $type->describe(VerbosityLevel::typeOnly()); + + if ($typeDescription !== $matches[1]) { + continue; + } + + $types[$typeDescription] = $text; + } + + return $types; + } + + private function removeDelimiters(string $regex): string + { + $delimiter = substr($regex, 0, 1); + $endDelimiterPosition = strrpos($regex, $delimiter); + if ($endDelimiterPosition === false) { + throw new ShouldNotHappenException(); + } + + return substr($regex, 1, $endDelimiterPosition - 1); + } + + private function getText(TreeNode $treeNode): ?string + { + if ($treeNode->getId() === 'token') { + return $treeNode->getValueValue(); + } + + if ($treeNode->getId() === '#concatenation') { + $fullText = ''; + foreach ($treeNode->getChildren() as $child) { + $text = $this->getText($child); + if ($text === null) { + continue; + } + + $fullText .= $text; + } + + if ($fullText === '') { + return null; + } + + return $fullText; + } + + return null; + } + + private function hasAnchorsInTheMiddle(TreeNode $ast): bool + { + if ($ast->getId() === 'token') { + $valueArray = $ast->getValue(); + + return $valueArray['token'] === 'anchor' && $valueArray['value'] === '$'; + } + $childrenCount = count($ast->getChildren()); + foreach ($ast->getChildren() as $i => $child) { + $has = $this->hasAnchorsInTheMiddle($child); + if ( + $has + && ($ast->getId() !== '#concatenation' || $i !== $childrenCount - 1) + ) { + return true; + } + } + + return false; + } + +} diff --git a/src/Command/IgnoredRegexValidatorResult.php b/src/Command/IgnoredRegexValidatorResult.php new file mode 100644 index 00000000..bce762f7 --- /dev/null +++ b/src/Command/IgnoredRegexValidatorResult.php @@ -0,0 +1,50 @@ + $ignoredTypes + */ + public function __construct( + private array $ignoredTypes, + private bool $anchorsInTheMiddle, + private bool $allErrorsIgnored, + private ?string $wrongSequence = null, + private ?string $escapedWrongSequence = null, + ) + { + } + + /** + * @return array + */ + public function getIgnoredTypes(): array + { + return $this->ignoredTypes; + } + + public function hasAnchorsInTheMiddle(): bool + { + return $this->anchorsInTheMiddle; + } + + public function areAllErrorsIgnored(): bool + { + return $this->allErrorsIgnored; + } + + public function getWrongSequence(): ?string + { + return $this->wrongSequence; + } + + public function getEscapedWrongSequence(): ?string + { + return $this->escapedWrongSequence; + } + +} diff --git a/src/Command/InceptionNotSuccessfulException.php b/src/Command/InceptionNotSuccessfulException.php new file mode 100644 index 00000000..4868b12d --- /dev/null +++ b/src/Command/InceptionNotSuccessfulException.php @@ -0,0 +1,11 @@ +filesCallback = $filesCallback; + } + + /** + * @throws InceptionNotSuccessfulException + * @throws PathNotFoundException + * @return array{string[], bool} + */ + public function getFiles(): array + { + $callback = $this->filesCallback; + + /** @throws InceptionNotSuccessfulException|PathNotFoundException */ + return $callback(); + } + + public function getStdOutput(): Output + { + return $this->stdOutput; + } + + public function getErrorOutput(): Output + { + return $this->errorOutput; + } + + public function getContainer(): Container + { + return $this->container; + } + + public function isDefaultLevelUsed(): bool + { + return $this->isDefaultLevelUsed; + } + + public function getProjectConfigFile(): ?string + { + return $this->projectConfigFile; + } + + /** + * @return mixed[]|null + */ + public function getProjectConfigArray(): ?array + { + return $this->projectConfigArray; + } + + public function getGenerateBaselineFile(): ?string + { + return $this->generateBaselineFile; + } + + public function handleReturn(int $exitCode, ?int $peakMemoryUsageBytes, float $analysisStartTime): int + { + if ($this->getErrorOutput()->isVerbose()) { + $this->getErrorOutput()->writeLineFormatted(sprintf( + 'Elapsed time: %s', + $this->formatDuration((int) round(microtime(true) - $analysisStartTime)), + )); + } + + if ($peakMemoryUsageBytes !== null && $this->getErrorOutput()->isVerbose()) { + $this->getErrorOutput()->writeLineFormatted(sprintf( + 'Used memory: %s', + BytesHelper::bytes(max(memory_get_peak_usage(true), $peakMemoryUsageBytes)), + )); + } + + return $exitCode; + } + + private function formatDuration(int $seconds): string + { + $minutes = (int) floor($seconds / 60); + $remainingSeconds = $seconds % 60; + + $result = []; + if ($minutes > 0) { + $result[] = $minutes . ' minute' . ($minutes > 1 ? 's' : ''); + } + + if ($remainingSeconds > 0) { + $result[] = $remainingSeconds . ' second' . ($remainingSeconds > 1 ? 's' : ''); + } + + return implode(' ', $result); + } + +} diff --git a/src/Command/Output.php b/src/Command/Output.php new file mode 100644 index 00000000..45be295b --- /dev/null +++ b/src/Command/Output.php @@ -0,0 +1,26 @@ +symfonyOutput->write($message, false, OutputInterface::OUTPUT_NORMAL); + } + + public function writeLineFormatted(string $message): void + { + $this->symfonyOutput->writeln($message, OutputInterface::OUTPUT_NORMAL); + } + + public function writeRaw(string $message): void + { + $this->symfonyOutput->write($message, false, OutputInterface::OUTPUT_RAW); + } + + public function getStyle(): OutputStyle + { + return $this->style; + } + + public function isVerbose(): bool + { + return $this->symfonyOutput->isVerbose(); + } + + public function isVeryVerbose(): bool + { + return $this->symfonyOutput->isVeryVerbose(); + } + + public function isDebug(): bool + { + return $this->symfonyOutput->isDebug(); + } + + public function isDecorated(): bool + { + return $this->symfonyOutput->isDecorated(); + } + +} diff --git a/src/Command/Symfony/SymfonyStyle.php b/src/Command/Symfony/SymfonyStyle.php new file mode 100644 index 00000000..18f56913 --- /dev/null +++ b/src/Command/Symfony/SymfonyStyle.php @@ -0,0 +1,89 @@ +symfonyStyle; + } + + public function title(string $message): void + { + $this->symfonyStyle->title($message); + } + + public function section(string $message): void + { + $this->symfonyStyle->section($message); + } + + public function listing(array $elements): void + { + $this->symfonyStyle->listing($elements); + } + + public function success(string $message): void + { + $this->symfonyStyle->success($message); + } + + public function error(string $message): void + { + $this->symfonyStyle->error($message); + } + + public function warning(string $message): void + { + $this->symfonyStyle->warning($message); + } + + public function note(string $message): void + { + $this->symfonyStyle->note($message); + } + + public function caution(string $message): void + { + $this->symfonyStyle->caution($message); + } + + public function table(array $headers, array $rows): void + { + $this->symfonyStyle->table($headers, $rows); + } + + public function newLine(int $count = 1): void + { + $this->symfonyStyle->newLine($count); + } + + public function progressStart(int $max = 0): void + { + $this->symfonyStyle->progressStart($max); + } + + public function progressAdvance(int $step = 1): void + { + $this->symfonyStyle->progressAdvance($step); + } + + public function progressFinish(): void + { + $this->symfonyStyle->progressFinish(); + } + +} diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php new file mode 100644 index 00000000..4d983c74 --- /dev/null +++ b/src/Command/WorkerCommand.php @@ -0,0 +1,266 @@ +setName(self::NAME) + ->setDescription('(Internal) Support for parallel analysis.') + ->setDefinition([ + new InputArgument('paths', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Paths with source code to run analysis on'), + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for analysis'), + new InputOption('xdebug', null, InputOption::VALUE_NONE, 'Allow running with Xdebug for debugging purposes'), + new InputOption('port', null, InputOption::VALUE_REQUIRED), + new InputOption('identifier', null, InputOption::VALUE_REQUIRED), + ]) + ->setHidden(true); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getArgument('paths'); + $memoryLimit = $input->getOption('memory-limit'); + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $level = $input->getOption(AnalyseCommand::OPTION_LEVEL); + $allowXdebug = $input->getOption('xdebug'); + $port = $input->getOption('port'); + $identifier = $input->getOption('identifier'); + + if ( + !is_array($paths) + || (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($level) && $level !== null) + || (!is_bool($allowXdebug)) + || !is_string($port) + || !is_string($identifier) + ) { + throw new ShouldNotHappenException(); + } + + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + $paths, + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + $level, + $allowXdebug, + false, + false, + ); + } catch (InceptionNotSuccessfulException $e) { + return 1; + } + $loop = new StreamSelectLoop(); + + $container = $inceptionResult->getContainer(); + + try { + [$analysedFiles] = $inceptionResult->getFiles(); + } catch (PathNotFoundException $e) { + $inceptionResult->getErrorOutput()->writeLineFormatted(sprintf('%s', $e->getMessage())); + return 1; + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $nodeScopeResolver = $container->getByType(NodeScopeResolver::class); + $nodeScopeResolver->setAnalysedFiles($analysedFiles); + + $analysedFiles = array_fill_keys($analysedFiles, true); + + $tcpConnector = new TcpConnector($loop); + $tcpConnector->connect(sprintf('127.0.0.1:%d', $port))->then(function (ConnectionInterface $connection) use ($container, $identifier, $output, $analysedFiles): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; + // phpcs:enable + $out = new Encoder($connection, $jsonInvalidUtf8Ignore); + $in = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, $container->getParameter('parallel')['buffer']); + $out->write(['action' => 'hello', 'identifier' => $identifier]); + $this->runWorker($container, $out, $in, $output, $analysedFiles); + }); + + $loop->run(); + + if ($this->errorCount > 0) { + return 1; + } + + return 0; + } + + /** + * @param array $analysedFiles + */ + private function runWorker( + Container $container, + WritableStreamInterface $out, + ReadableStreamInterface $in, + OutputInterface $output, + array $analysedFiles, + ): void + { + $handleError = function (Throwable $error) use ($out, $output): void { + $this->errorCount++; + $output->writeln(sprintf('Error: %s', $error->getMessage())); + $out->write([ + 'action' => 'result', + 'result' => [ + 'errors' => [], + 'internalErrors' => [ + new InternalError( + $error->getMessage(), + 'communicating with main process in parallel worker', + InternalError::prepareTrace($error), + $error->getTraceAsString(), + true, + ), + ], + 'filteredPhpErrors' => [], + 'allPhpErrors' => [], + 'locallyIgnoredErrors' => [], + 'linesToIgnore' => [], + 'unmatchedLineIgnores' => [], + 'collectedData' => [], + 'memoryUsage' => memory_get_peak_usage(true), + 'dependencies' => [], + 'exportedNodes' => [], + 'files' => [], + 'internalErrorsCount' => 1, + ], + ]); + $out->end(); + }; + $out->on('error', $handleError); + $fileAnalyser = $container->getByType(FileAnalyser::class); + $ruleRegistry = $container->getByType(RuleRegistry::class); + $collectorRegistry = $container->getByType(CollectorRegistry::class); + $in->on('data', static function (array $json) use ($fileAnalyser, $ruleRegistry, $collectorRegistry, $out, $analysedFiles): void { + $action = $json['action']; + if ($action !== 'analyse') { + return; + } + + $internalErrorsCount = 0; + $files = $json['files']; + $errors = []; + $internalErrors = []; + $filteredPhpErrors = []; + $allPhpErrors = []; + $locallyIgnoredErrors = []; + $linesToIgnore = []; + $unmatchedLineIgnores = []; + $collectedData = []; + $dependencies = []; + $exportedNodes = []; + foreach ($files as $file) { + try { + $fileAnalyserResult = $fileAnalyser->analyseFile($file, $analysedFiles, $ruleRegistry, $collectorRegistry, null); + $fileErrors = $fileAnalyserResult->getErrors(); + $filteredPhpErrors = array_merge($filteredPhpErrors, $fileAnalyserResult->getFilteredPhpErrors()); + $allPhpErrors = array_merge($allPhpErrors, $fileAnalyserResult->getAllPhpErrors()); + $linesToIgnore[$file] = $fileAnalyserResult->getLinesToIgnore(); + $unmatchedLineIgnores[$file] = $fileAnalyserResult->getUnmatchedLineIgnores(); + $dependencies[$file] = $fileAnalyserResult->getDependencies(); + $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); + foreach ($fileErrors as $fileError) { + $errors[] = $fileError; + } + foreach ($fileAnalyserResult->getLocallyIgnoredErrors() as $locallyIgnoredError) { + $locallyIgnoredErrors[] = $locallyIgnoredError; + } + foreach ($fileAnalyserResult->getCollectedData() as $data) { + $collectedData[] = $data; + } + } catch (Throwable $t) { + $internalErrorsCount++; + $internalErrors[] = new InternalError( + $t->getMessage(), + sprintf('analysing file %s', $file), + InternalError::prepareTrace($t), + $t->getTraceAsString(), + true, + ); + } + } + + $out->write([ + 'action' => 'result', + 'result' => [ + 'errors' => $errors, + 'internalErrors' => $internalErrors, + 'filteredPhpErrors' => $filteredPhpErrors, + 'allPhpErrors' => $allPhpErrors, + 'locallyIgnoredErrors' => $locallyIgnoredErrors, + 'linesToIgnore' => $linesToIgnore, + 'unmatchedLineIgnores' => $unmatchedLineIgnores, + 'collectedData' => $collectedData, + 'memoryUsage' => memory_get_peak_usage(true), + 'dependencies' => $dependencies, + 'exportedNodes' => $exportedNodes, + 'files' => $files, + 'internalErrorsCount' => $internalErrorsCount, + ]]); + }); + $in->on('error', $handleError); + } + +} diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php new file mode 100644 index 00000000..93ab5c45 --- /dev/null +++ b/src/Dependency/DependencyResolver.php @@ -0,0 +1,681 @@ +namespacedName !== null) { + $this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections); + } + if ($node->extends !== null) { + $this->addClassToDependencies($node->extends->toString(), $dependenciesReflections); + } + foreach ($node->implements as $className) { + $this->addClassToDependencies($className->toString(), $dependenciesReflections); + } + } elseif ($node instanceof Node\Stmt\Interface_) { + if ($node->namespacedName !== null) { + $this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections); + } + foreach ($node->extends as $className) { + $this->addClassToDependencies($className->toString(), $dependenciesReflections); + } + } elseif ($node instanceof Node\Stmt\Enum_) { + if ($node->namespacedName !== null) { + $this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections); + } + foreach ($node->implements as $className) { + $this->addClassToDependencies($className->toString(), $dependenciesReflections); + } + } elseif ($node instanceof InClassMethodNode) { + $nativeMethod = $node->getMethodReflection(); + $this->extractThrowType($nativeMethod->getThrowType(), $dependenciesReflections); + $this->extractFromParametersAcceptor($nativeMethod, $dependenciesReflections); + foreach ($nativeMethod->getAsserts()->getAll() as $assertTag) { + foreach ($assertTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + foreach ($assertTag->getOriginalType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($nativeMethod->getSelfOutType() !== null) { + foreach ($nativeMethod->getSelfOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } elseif ($node instanceof ClassPropertyNode) { + $nativeType = $node->getNativeType(); + if ($nativeType !== null) { + foreach ($nativeType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + $phpDocType = $node->getPhpDocType(); + if ($phpDocType !== null) { + foreach ($phpDocType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } elseif ($node instanceof InFunctionNode) { + $functionReflection = $node->getFunctionReflection(); + $this->extractThrowType($functionReflection->getThrowType(), $dependenciesReflections); + + $this->extractFromParametersAcceptor($functionReflection, $dependenciesReflections); + foreach ($functionReflection->getAsserts()->getAll() as $assertTag) { + foreach ($assertTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + foreach ($assertTag->getOriginalType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } elseif ($node instanceof Closure || $node instanceof Node\Expr\ArrowFunction) { + $closureType = $scope->getType($node); + if ($closureType instanceof ClosureType) { + foreach ($closureType->getParameters() as $parameter) { + $referencedClasses = $parameter->getType()->getReferencedClasses(); + foreach ($referencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + $returnTypeReferencedClasses = $closureType->getReturnType()->getReferencedClasses(); + foreach ($returnTypeReferencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } elseif ($node instanceof Node\Expr\FuncCall) { + $functionName = $node->name; + if ($functionName instanceof Node\Name) { + try { + $functionReflection = $this->getFunctionReflection($functionName, $scope); + $dependenciesReflections[] = $functionReflection; + + foreach ($functionReflection->getVariants() as $functionVariant) { + foreach ($functionVariant->getParameters() as $parameter) { + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + + foreach ($functionReflection->getAsserts()->getAll() as $assertTag) { + foreach ($assertTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + foreach ($assertTag->getOriginalType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } catch (FunctionNotFoundException) { + // pass + } + } else { + $calledType = $scope->getType($functionName); + if ($calledType->isCallable()->yes()) { + $variants = $calledType->getCallableParametersAcceptors($scope); + foreach ($variants as $variant) { + $referencedClasses = $variant->getReturnType()->getReferencedClasses(); + foreach ($referencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + foreach ($variant->getParameters() as $parameter) { + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } + } + + $returnType = $scope->getType($node); + foreach ($returnType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } elseif ($node instanceof Node\Expr\MethodCall) { + $calledOnType = $scope->getType($node->var); + $classNames = $calledOnType->getReferencedClasses(); + foreach ($classNames as $className) { + $this->addClassToDependencies($className, $dependenciesReflections); + } + + $returnType = $scope->getType($node); + foreach ($returnType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($node->name instanceof Node\Identifier) { + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->toString()); + if ($methodReflection !== null) { + $this->addClassToDependencies($methodReflection->getDeclaringClass()->getName(), $dependenciesReflections); + foreach ($methodReflection->getVariants() as $methodVariant) { + foreach ($methodVariant->getParameters() as $parameter) { + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + + foreach ($methodReflection->getAsserts()->getAll() as $assertTag) { + foreach ($assertTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + foreach ($assertTag->getOriginalType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + if ($methodReflection->getSelfOutType() !== null) { + foreach ($methodReflection->getSelfOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } + } elseif ($node instanceof Node\Expr\PropertyFetch) { + $fetchedOnType = $scope->getType($node->var); + $classNames = $fetchedOnType->getReferencedClasses(); + foreach ($classNames as $className) { + $this->addClassToDependencies($className, $dependenciesReflections); + } + + $propertyType = $scope->getType($node); + foreach ($propertyType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($node->name instanceof Node\Identifier) { + $propertyReflection = $scope->getPropertyReflection($fetchedOnType, $node->name->toString()); + if ($propertyReflection !== null) { + $this->addClassToDependencies($propertyReflection->getDeclaringClass()->getName(), $dependenciesReflections); + } + } + } elseif ($node instanceof Node\Expr\StaticCall) { + if ($node->class instanceof Node\Name) { + $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); + } else { + foreach ($scope->getType($node->class)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + $returnType = $scope->getType($node); + foreach ($returnType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($node->name instanceof Node\Identifier) { + if ($node->class instanceof Node\Name) { + $className = $scope->resolveName($node->class); + if ($this->reflectionProvider->hasClass($className)) { + $methodClassReflection = $this->reflectionProvider->getClass($className); + if ($methodClassReflection->hasMethod($node->name->toString())) { + $methodReflection = $methodClassReflection->getMethod($node->name->toString(), $scope); + $this->addClassToDependencies($methodReflection->getDeclaringClass()->getName(), $dependenciesReflections); + foreach ($methodReflection->getVariants() as $methodVariant) { + foreach ($methodVariant->getParameters() as $parameter) { + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } + } + } else { + $methodReflection = $scope->getMethodReflection($scope->getType($node->class), $node->name->toString()); + if ($methodReflection !== null) { + $this->addClassToDependencies($methodReflection->getDeclaringClass()->getName(), $dependenciesReflections); + foreach ($methodReflection->getVariants() as $methodVariant) { + foreach ($methodVariant->getParameters() as $parameter) { + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } + } + } + } elseif ($node instanceof Node\Expr\ClassConstFetch) { + if ($node->class instanceof Node\Name) { + $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); + } else { + foreach ($scope->getType($node->class)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + $returnType = $scope->getType($node); + foreach ($returnType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($node->name instanceof Node\Identifier && $node->name->toLowerString() !== 'class') { + if ($node->class instanceof Node\Name) { + $className = $scope->resolveName($node->class); + if ($this->reflectionProvider->hasClass($className)) { + $constantClassReflection = $this->reflectionProvider->getClass($className); + if ($constantClassReflection->hasConstant($node->name->toString())) { + $constantReflection = $constantClassReflection->getConstant($node->name->toString()); + $this->addClassToDependencies($constantReflection->getDeclaringClass()->getName(), $dependenciesReflections); + } + } + } else { + $constantReflection = $scope->getConstantReflection($scope->getType($node->class), $node->name->toString()); + if ($constantReflection !== null) { + $this->addClassToDependencies($constantReflection->getDeclaringClass()->getName(), $dependenciesReflections); + } + } + } + } elseif ($node instanceof Node\Expr\StaticPropertyFetch) { + if ($node->class instanceof Node\Name) { + $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); + } else { + foreach ($scope->getType($node->class)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + $returnType = $scope->getType($node); + foreach ($returnType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($node->name instanceof Node\Identifier) { + if ($node->class instanceof Node\Name) { + $className = $scope->resolveName($node->class); + if ($this->reflectionProvider->hasClass($className)) { + $propertyClassReflection = $this->reflectionProvider->getClass($className); + if ($propertyClassReflection->hasProperty($node->name->toString())) { + $propertyReflection = $propertyClassReflection->getProperty($node->name->toString(), $scope); + $this->addClassToDependencies($propertyReflection->getDeclaringClass()->getName(), $dependenciesReflections); + } + } + } else { + $propertyReflection = $scope->getPropertyReflection($scope->getType($node->class), $node->name->toString()); + if ($propertyReflection !== null) { + $this->addClassToDependencies($propertyReflection->getDeclaringClass()->getName(), $dependenciesReflections); + } + } + } + } elseif ( + $node instanceof Node\Expr\New_ + && $node->class instanceof Node\Name + ) { + $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); + } elseif ($node instanceof Node\Stmt\Trait_ && $node->namespacedName !== null) { + try { + $classReflection = $this->reflectionProvider->getClass($node->namespacedName->toString()); + + foreach ($classReflection->getRequireImplementsTags() as $implementsTag) { + foreach ($implementsTag->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } catch (ClassNotFoundException) { + // pass + } + } elseif ($node instanceof Node\Stmt\TraitUse) { + foreach ($node->traits as $traitName) { + $this->addClassToDependencies($traitName->toString(), $dependenciesReflections); + } + + $docComment = $node->getDocComment(); + if ($docComment !== null) { + $usesTags = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + null, + $docComment->getText(), + )->getUsesTags(); + foreach ($usesTags as $usesTag) { + foreach ($usesTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } elseif ($node instanceof Node\Expr\Instanceof_) { + if ($node->class instanceof Name) { + $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); + } + } elseif ($node instanceof Node\Stmt\Catch_) { + foreach ($node->types as $type) { + $this->addClassToDependencies($scope->resolveName($type), $dependenciesReflections); + } + } elseif ($node instanceof ArrayDimFetch && $node->dim !== null) { + $varType = $scope->getType($node->var); + $dimType = $scope->getType($node->dim); + + foreach ($varType->getOffsetValueType($dimType)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } elseif ($node instanceof Foreach_) { + $exprType = $scope->getType($node->expr); + if ($node->keyVar !== null) { + + foreach ($scope->getIterableKeyType($exprType)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + foreach ($scope->getIterableValueType($exprType)->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } elseif ( + $node instanceof Array_ + && $this->considerArrayForCallableTest($scope, $node) + ) { + $arrayType = $scope->getType($node); + if (!$arrayType->isCallable()->no()) { + foreach ($arrayType->getCallableParametersAcceptors($scope) as $variant) { + $referencedClasses = $variant->getReturnType()->getReferencedClasses(); + foreach ($referencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } + + return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($scope->getFile(), $node)); + } + + private function considerArrayForCallableTest(Scope $scope, Array_ $arrayNode): bool + { + $items = $arrayNode->items; + if (count($items) !== 2) { + return false; + } + + $itemType = $scope->getType($items[0]->value); + return $itemType->isClassString()->yes(); + } + + /** + * @param array $dependenciesReflections + */ + private function addClassToDependencies(string $className, array &$dependenciesReflections): void + { + try { + $classReflection = $this->reflectionProvider->getClass($className); + } catch (ClassNotFoundException) { + return; + } + + do { + $dependenciesReflections[] = $classReflection; + + foreach ($classReflection->getInterfaces() as $interface) { + $dependenciesReflections[] = $interface; + } + + foreach ($classReflection->getTraits() as $trait) { + $dependenciesReflections[] = $trait; + } + + foreach ($classReflection->getResolvedMixinTypes() as $mixinType) { + foreach ($mixinType->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + foreach ($classReflection->getRequireExtendsTags() as $extendsTag) { + foreach ($extendsTag->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + foreach ($classReflection->getTemplateTags() as $templateTag) { + foreach ($templateTag->getBound()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + + $default = $templateTag->getDefault(); + if ($default === null) { + continue; + } + foreach ($default->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + foreach ($classReflection->getPropertyTags() as $propertyTag) { + if ($propertyTag->isReadable()) { + foreach ($propertyTag->getReadableType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + if (!$propertyTag->isWritable()) { + continue; + } + + foreach ($propertyTag->getWritableType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + foreach ($classReflection->getMethodTags() as $methodTag) { + foreach ($methodTag->getReturnType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + foreach ($methodTag->getParameters() as $parameter) { + foreach ($parameter->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + if ($parameter->getDefaultValue() === null) { + continue; + } + foreach ($parameter->getDefaultValue()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + } + + foreach ($classReflection->getExtendsTags() as $extendsTag) { + foreach ($extendsTag->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + foreach ($classReflection->getImplementsTags() as $implementsTag) { + foreach ($implementsTag->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + + $phpDoc = $classReflection->getResolvedPhpDoc(); + if ($phpDoc !== null) { + foreach ($phpDoc->getTypeAliasImportTags() as $importTag) { + $dependenciesReflections[] = $this->reflectionProvider->getClass($importTag->getImportedFrom()); + } + } + + $classReflection = $classReflection->getParentClass(); + } while ($classReflection !== null); + } + + private function getFunctionReflection(Node\Name $nameNode, ?Scope $scope): FunctionReflection + { + return $this->reflectionProvider->getFunction($nameNode, $scope); + } + + /** + * @param array $dependenciesReflections + */ + private function extractFromParametersAcceptor( + ExtendedParametersAcceptor $parametersAcceptor, + array &$dependenciesReflections, + ): void + { + foreach ($parametersAcceptor->getParameters() as $parameter) { + $referencedClasses = array_merge( + $parameter->getNativeType()->getReferencedClasses(), + $parameter->getPhpDocType()->getReferencedClasses(), + ); + + foreach ($referencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + + if ($parameter->getOutType() !== null) { + foreach ($parameter->getOutType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + if ($parameter->getClosureThisType() === null) { + continue; + } + foreach ($parameter->getClosureThisType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + $returnTypeReferencedClasses = array_merge( + $parametersAcceptor->getNativeReturnType()->getReferencedClasses(), + $parametersAcceptor->getPhpDocReturnType()->getReferencedClasses(), + ); + foreach ($returnTypeReferencedClasses as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + + /** + * @param array $dependenciesReflections + */ + private function extractThrowType( + ?Type $throwType, + array &$dependenciesReflections, + ): void + { + if ($throwType === null) { + return; + } + + foreach ($throwType->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + +} diff --git a/src/Dependency/ExportedNode.php b/src/Dependency/ExportedNode.php new file mode 100644 index 00000000..94963461 --- /dev/null +++ b/src/Dependency/ExportedNode.php @@ -0,0 +1,21 @@ + $args argument name or index(string|int) => value expression (string) + */ + public function __construct( + private string $name, + private array $args, + ) + { + } + + public function equals(ExportedNode $node): bool + { + if (!$node instanceof self) { + return false; + } + + if ($this->name !== $node->name) { + return false; + } + + if (count($this->args) !== count($node->args)) { + return false; + } + + foreach ($this->args as $argName => $argValue) { + if (!isset($node->args[$argName]) || $argValue !== $node->args[$argName]) { + return false; + } + } + + return true; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['args'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'args' => $this->args, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['args'], + ); + } + +} diff --git a/src/Dependency/ExportedNode/ExportedClassConstantNode.php b/src/Dependency/ExportedNode/ExportedClassConstantNode.php new file mode 100644 index 00000000..16c758c6 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedClassConstantNode.php @@ -0,0 +1,92 @@ +attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->name === $node->name + && $this->value === $node->value; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['value'], + $properties['attributes'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['value'], + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'value' => $this->value, + 'attributes' => $this->attributes, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedClassConstantsNode.php b/src/Dependency/ExportedNode/ExportedClassConstantsNode.php new file mode 100644 index 00000000..bab30db1 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedClassConstantsNode.php @@ -0,0 +1,107 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->constants) !== count($node->constants)) { + return false; + } + + foreach ($this->constants as $i => $constant) { + if (!$constant->equals($node->constants[$i])) { + return false; + } + } + + return $this->public === $node->public + && $this->private === $node->private + && $this->final === $node->final; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['constants'], + $properties['public'], + $properties['private'], + $properties['final'], + $properties['phpDoc'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + array_map(static function (array $constantData): ExportedClassConstantNode { + if ($constantData['type'] !== ExportedClassConstantNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedClassConstantNode::decode($constantData['data']); + }, $data['constants']), + $data['public'], + $data['private'], + $data['final'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'constants' => $this->constants, + 'public' => $this->public, + 'private' => $this->private, + 'final' => $this->final, + 'phpDoc' => $this->phpDoc, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedClassNode.php b/src/Dependency/ExportedNode/ExportedClassNode.php new file mode 100644 index 00000000..b67c0036 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedClassNode.php @@ -0,0 +1,186 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + if (count($this->traitUseAdaptations) !== count($node->traitUseAdaptations)) { + return false; + } + + foreach ($this->traitUseAdaptations as $i => $ourTraitUseAdaptation) { + $theirTraitUseAdaptation = $node->traitUseAdaptations[$i]; + if (!$ourTraitUseAdaptation->equals($theirTraitUseAdaptation)) { + return false; + } + } + + if (count($this->statements) !== count($node->statements)) { + return false; + } + + foreach ($this->statements as $i => $statement) { + if ($statement->equals($node->statements[$i])) { + continue; + } + + return false; + } + + return $this->name === $node->name + && $this->abstract === $node->abstract + && $this->final === $node->final + && $this->extends === $node->extends + && $this->implements === $node->implements + && $this->usedTraits === $node->usedTraits; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['phpDoc'], + $properties['abstract'], + $properties['final'], + $properties['extends'], + $properties['implements'], + $properties['usedTraits'], + $properties['traitUseAdaptations'], + $properties['statements'], + $properties['attributes'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'phpDoc' => $this->phpDoc, + 'abstract' => $this->abstract, + 'final' => $this->final, + 'extends' => $this->extends, + 'implements' => $this->implements, + 'usedTraits' => $this->usedTraits, + 'traitUseAdaptations' => $this->traitUseAdaptations, + 'statements' => $this->statements, + 'attributes' => $this->attributes, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['abstract'], + $data['final'], + $data['extends'], + $data['implements'], + $data['usedTraits'], + array_map(static function (array $traitUseAdaptationData): ExportedTraitUseAdaptation { + if ($traitUseAdaptationData['type'] !== ExportedTraitUseAdaptation::class) { + throw new ShouldNotHappenException(); + } + return ExportedTraitUseAdaptation::decode($traitUseAdaptationData['data']); + }, $data['traitUseAdaptations']), + array_map(static function (array $node): ExportedNode { + $nodeType = $node['type']; + + return $nodeType::decode($node['data']); + }, $data['statements']), + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + + /** + * @return self::TYPE_CLASS + */ + public function getType(): string + { + return self::TYPE_CLASS; + } + + public function getName(): string + { + return $this->name; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedEnumCaseNode.php b/src/Dependency/ExportedNode/ExportedEnumCaseNode.php new file mode 100644 index 00000000..73f9456b --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedEnumCaseNode.php @@ -0,0 +1,79 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + return $this->name === $node->name + && $this->value === $node->value; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['value'], + $properties['phpDoc'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['value'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'value' => $this->value, + 'phpDoc' => $this->phpDoc, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedEnumNode.php b/src/Dependency/ExportedNode/ExportedEnumNode.php new file mode 100644 index 00000000..fb951857 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedEnumNode.php @@ -0,0 +1,149 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->statements) !== count($node->statements)) { + return false; + } + + foreach ($this->statements as $i => $statement) { + if ($statement->equals($node->statements[$i])) { + continue; + } + + return false; + } + + if (count($this->attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->name === $node->name + && $this->scalarType === $node->scalarType + && $this->implements === $node->implements; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['scalarType'], + $properties['phpDoc'], + $properties['implements'], + $properties['statements'], + $properties['attributes'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'scalarType' => $this->scalarType, + 'phpDoc' => $this->phpDoc, + 'implements' => $this->implements, + 'statements' => $this->statements, + 'attributes' => $this->attributes, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['scalarType'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['implements'], + array_map(static function (array $node): ExportedNode { + $nodeType = $node['type']; + + return $nodeType::decode($node['data']); + }, $data['statements']), + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + + /** + * @return self::TYPE_ENUM + */ + public function getType(): string + { + return self::TYPE_ENUM; + } + + public function getName(): string + { + return $this->name; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedFunctionNode.php b/src/Dependency/ExportedNode/ExportedFunctionNode.php new file mode 100644 index 00000000..a4285f1f --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedFunctionNode.php @@ -0,0 +1,148 @@ +parameters) !== count($node->parameters)) { + return false; + } + + foreach ($this->parameters as $i => $ourParameter) { + $theirParameter = $node->parameters[$i]; + if (!$ourParameter->equals($theirParameter)) { + return false; + } + } + + if ($this->phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->name === $node->name + && $this->byRef === $node->byRef + && $this->returnType === $node->returnType; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['phpDoc'], + $properties['byRef'], + $properties['returnType'], + $properties['parameters'], + $properties['attributes'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'phpDoc' => $this->phpDoc, + 'byRef' => $this->byRef, + 'returnType' => $this->returnType, + 'parameters' => $this->parameters, + 'attributes' => $this->attributes, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['byRef'], + $data['returnType'], + array_map(static function (array $parameterData): ExportedParameterNode { + if ($parameterData['type'] !== ExportedParameterNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedParameterNode::decode($parameterData['data']); + }, $data['parameters']), + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + + /** + * @return self::TYPE_FUNCTION + */ + public function getType(): string + { + return self::TYPE_FUNCTION; + } + + public function getName(): string + { + return $this->name; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedInterfaceNode.php b/src/Dependency/ExportedNode/ExportedInterfaceNode.php new file mode 100644 index 00000000..dde72934 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedInterfaceNode.php @@ -0,0 +1,118 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->statements) !== count($node->statements)) { + return false; + } + + foreach ($this->statements as $i => $statement) { + if ($statement->equals($node->statements[$i])) { + continue; + } + + return false; + } + + return $this->name === $node->name + && $this->extends === $node->extends; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['phpDoc'], + $properties['extends'], + $properties['statements'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'phpDoc' => $this->phpDoc, + 'extends' => $this->extends, + 'statements' => $this->statements, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['extends'], + array_map(static function (array $node): ExportedNode { + $nodeType = $node['type']; + + return $nodeType::decode($node['data']); + }, $data['statements']), + ); + } + + /** + * @return self::TYPE_INTERFACE + */ + public function getType(): string + { + return self::TYPE_INTERFACE; + } + + public function getName(): string + { + return $this->name; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedMethodNode.php b/src/Dependency/ExportedNode/ExportedMethodNode.php new file mode 100644 index 00000000..f7c44939 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedMethodNode.php @@ -0,0 +1,159 @@ +parameters) !== count($node->parameters)) { + return false; + } + + foreach ($this->parameters as $i => $ourParameter) { + $theirParameter = $node->parameters[$i]; + if (!$ourParameter->equals($theirParameter)) { + return false; + } + } + + if ($this->phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->name === $node->name + && $this->byRef === $node->byRef + && $this->public === $node->public + && $this->private === $node->private + && $this->abstract === $node->abstract + && $this->final === $node->final + && $this->static === $node->static + && $this->returnType === $node->returnType; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['phpDoc'], + $properties['byRef'], + $properties['public'], + $properties['private'], + $properties['abstract'], + $properties['final'], + $properties['static'], + $properties['returnType'], + $properties['parameters'], + $properties['attributes'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'phpDoc' => $this->phpDoc, + 'byRef' => $this->byRef, + 'public' => $this->public, + 'private' => $this->private, + 'abstract' => $this->abstract, + 'final' => $this->final, + 'static' => $this->static, + 'returnType' => $this->returnType, + 'parameters' => $this->parameters, + 'attributes' => $this->attributes, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['byRef'], + $data['public'], + $data['private'], + $data['abstract'], + $data['final'], + $data['static'], + $data['returnType'], + array_map(static function (array $parameterData): ExportedParameterNode { + if ($parameterData['type'] !== ExportedParameterNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedParameterNode::decode($parameterData['data']); + }, $data['parameters']), + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + +} diff --git a/src/Dependency/ExportedNode/ExportedParameterNode.php b/src/Dependency/ExportedNode/ExportedParameterNode.php new file mode 100644 index 00000000..eedc72c1 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedParameterNode.php @@ -0,0 +1,107 @@ +attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->name === $node->name + && $this->type === $node->type + && $this->byRef === $node->byRef + && $this->variadic === $node->variadic + && $this->hasDefault === $node->hasDefault; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['type'], + $properties['byRef'], + $properties['variadic'], + $properties['hasDefault'], + $properties['attributes'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'type' => $this->type, + 'byRef' => $this->byRef, + 'variadic' => $this->variadic, + 'hasDefault' => $this->hasDefault, + 'attributes' => $this->attributes, + ], + ]; + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['type'], + $data['byRef'], + $data['variadic'], + $data['hasDefault'], + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + +} diff --git a/src/Dependency/ExportedNode/ExportedPhpDocNode.php b/src/Dependency/ExportedNode/ExportedPhpDocNode.php new file mode 100644 index 00000000..7bf06367 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedPhpDocNode.php @@ -0,0 +1,66 @@ + $uses alias(string) => fullName(string) + * @param array $constUses alias(string) => fullName(string) + */ + public function __construct(private string $phpDocString, private ?string $namespace, private array $uses, private array $constUses) + { + } + + public function equals(ExportedNode $node): bool + { + if (!$node instanceof self) { + return false; + } + + return $this->phpDocString === $node->phpDocString + && $this->namespace === $node->namespace + && $this->uses === $node->uses + && $this->constUses === $node->constUses; + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'phpDocString' => $this->phpDocString, + 'namespace' => $this->namespace, + 'uses' => $this->uses, + 'constUses' => $this->constUses, + ], + ]; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self($properties['phpDocString'], $properties['namespace'], $properties['uses'], $properties['constUses'] ?? []); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self($data['phpDocString'], $data['namespace'], $data['uses'], $data['constUses'] ?? []); + } + +} diff --git a/src/Dependency/ExportedNode/ExportedPropertiesNode.php b/src/Dependency/ExportedNode/ExportedPropertiesNode.php new file mode 100644 index 00000000..2471b3c1 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedPropertiesNode.php @@ -0,0 +1,138 @@ +phpDoc === null) { + if ($node->phpDoc !== null) { + return false; + } + } elseif ($node->phpDoc !== null) { + if (!$this->phpDoc->equals($node->phpDoc)) { + return false; + } + } else { + return false; + } + + if (count($this->names) !== count($node->names)) { + return false; + } + + foreach ($this->names as $i => $name) { + if ($name !== $node->names[$i]) { + return false; + } + } + + if (count($this->attributes) !== count($node->attributes)) { + return false; + } + + foreach ($this->attributes as $i => $attribute) { + if (!$attribute->equals($node->attributes[$i])) { + return false; + } + } + + return $this->type === $node->type + && $this->public === $node->public + && $this->private === $node->private + && $this->static === $node->static + && $this->readonly === $node->readonly; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['names'], + $properties['phpDoc'], + $properties['type'], + $properties['public'], + $properties['private'], + $properties['static'], + $properties['readonly'], + $properties['attributes'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['names'], + $data['phpDoc'] !== null ? ExportedPhpDocNode::decode($data['phpDoc']['data']) : null, + $data['type'], + $data['public'], + $data['private'], + $data['static'], + $data['readonly'], + array_map(static function (array $attributeData): ExportedAttributeNode { + if ($attributeData['type'] !== ExportedAttributeNode::class) { + throw new ShouldNotHappenException(); + } + return ExportedAttributeNode::decode($attributeData['data']); + }, $data['attributes']), + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'names' => $this->names, + 'phpDoc' => $this->phpDoc, + 'type' => $this->type, + 'public' => $this->public, + 'private' => $this->private, + 'static' => $this->static, + 'readonly' => $this->readonly, + 'attributes' => $this->attributes, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedTraitNode.php b/src/Dependency/ExportedNode/ExportedTraitNode.php new file mode 100644 index 00000000..ff3243bd --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedTraitNode.php @@ -0,0 +1,66 @@ + self::class, + 'data' => [ + 'traitName' => $this->traitName, + ], + ]; + } + + /** + * @return self::TYPE_TRAIT + */ + public function getType(): string + { + return self::TYPE_TRAIT; + } + + public function getName(): string + { + return $this->traitName; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedTraitUseAdaptation.php b/src/Dependency/ExportedNode/ExportedTraitUseAdaptation.php new file mode 100644 index 00000000..96e758f9 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedTraitUseAdaptation.php @@ -0,0 +1,107 @@ +traitName === $node->traitName + && $this->method === $node->method + && $this->newModifier === $node->newModifier + && $this->newName === $node->newName + && $this->insteadOfs === $node->insteadOfs; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['traitName'], + $properties['method'], + $properties['newModifier'], + $properties['newName'], + $properties['insteadOfs'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['traitName'], + $data['method'], + $data['newModifier'], + $data['newName'], + $data['insteadOfs'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'traitName' => $this->traitName, + 'method' => $this->method, + 'newModifier' => $this->newModifier, + 'newName' => $this->newName, + 'insteadOfs' => $this->insteadOfs, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNodeFetcher.php b/src/Dependency/ExportedNodeFetcher.php new file mode 100644 index 00000000..ad7b076f --- /dev/null +++ b/src/Dependency/ExportedNodeFetcher.php @@ -0,0 +1,39 @@ +addVisitor($this->visitor); + + try { + $ast = $this->parser->parseFile($fileName); + } catch (ParserErrorsException) { + return []; + } + $this->visitor->reset($fileName); + $nodeTraverser->traverse($ast); + + return $this->visitor->getExportedNodes(); + } + +} diff --git a/src/Dependency/ExportedNodeResolver.php b/src/Dependency/ExportedNodeResolver.php new file mode 100644 index 00000000..90dcd6de --- /dev/null +++ b/src/Dependency/ExportedNodeResolver.php @@ -0,0 +1,386 @@ +namespacedName)) { + $docComment = $node->getDocComment(); + $extendsName = null; + if ($node->extends !== null) { + $extendsName = $node->extends->toString(); + } + + $implementsNames = []; + foreach ($node->implements as $className) { + $implementsNames[] = $className->toString(); + } + + $usedTraits = []; + $adaptations = []; + foreach ($node->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $usedTraitName) { + $usedTraits[] = $usedTraitName->toString(); + } + foreach ($traitUse->adaptations as $adaptation) { + $adaptations[] = $adaptation; + } + } + + $className = $node->namespacedName->toString(); + + return new ExportedClassNode( + $className, + $this->exportPhpDocNode( + $fileName, + $className, + null, + $docComment !== null ? $docComment->getText() : null, + ), + $node->isAbstract(), + $node->isFinal(), + $extendsName, + $implementsNames, + $usedTraits, + array_map(static function (Node\Stmt\TraitUseAdaptation $adaptation): ExportedTraitUseAdaptation { + if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { + return ExportedTraitUseAdaptation::createAlias( + $adaptation->trait !== null ? $adaptation->trait->toString() : null, + $adaptation->method->toString(), + $adaptation->newModifier, + $adaptation->newName !== null ? $adaptation->newName->toString() : null, + ); + } + + if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Precedence) { + return ExportedTraitUseAdaptation::createPrecedence( + $adaptation->trait !== null ? $adaptation->trait->toString() : null, + $adaptation->method->toString(), + array_map(static fn (Name $name): string => $name->toString(), $adaptation->insteadof), + ); + } + + throw new ShouldNotHappenException(); + }, $adaptations), + $this->exportClassStatements($node->stmts, $fileName, $className), + $this->exportAttributeNodes($node->attrGroups), + ); + } + + if ($node instanceof Node\Stmt\Interface_ && isset($node->namespacedName)) { + $extendsNames = array_map(static fn (Name $name): string => (string) $name, $node->extends); + $docComment = $node->getDocComment(); + + $interfaceName = $node->namespacedName->toString(); + + return new ExportedInterfaceNode( + $interfaceName, + $this->exportPhpDocNode( + $fileName, + $interfaceName, + null, + $docComment !== null ? $docComment->getText() : null, + ), + $extendsNames, + $this->exportClassStatements($node->stmts, $fileName, $interfaceName), + ); + } + + if ($node instanceof Node\Stmt\Enum_ && $node->namespacedName !== null) { + $implementsNames = array_map(static fn (Name $name): string => (string) $name, $node->implements); + $docComment = $node->getDocComment(); + + $enumName = $node->namespacedName->toString(); + $scalarType = null; + if ($node->scalarType !== null) { + $scalarType = $node->scalarType->toString(); + } + + return new ExportedEnumNode( + $enumName, + $scalarType, + $this->exportPhpDocNode( + $fileName, + $enumName, + null, + $docComment !== null ? $docComment->getText() : null, + ), + $implementsNames, + $this->exportClassStatements($node->stmts, $fileName, $enumName), + $this->exportAttributeNodes($node->attrGroups), + ); + } + + if ($node instanceof Node\Stmt\Trait_ && isset($node->namespacedName)) { + return new ExportedTraitNode($node->namespacedName->toString()); + } + + if ($node instanceof Function_) { + $functionName = $node->name->name; + if (isset($node->namespacedName)) { + $functionName = (string) $node->namespacedName; + } + + $docComment = $node->getDocComment(); + + return new ExportedFunctionNode( + $functionName, + $this->exportPhpDocNode( + $fileName, + null, + $functionName, + $docComment !== null ? $docComment->getText() : null, + ), + $node->byRef, + NodeTypePrinter::printType($node->returnType), + $this->exportParameterNodes($node->params), + $this->exportAttributeNodes($node->attrGroups), + ); + } + + return null; + } + + /** + * @param Node\Param[] $params + * @return ExportedParameterNode[] + */ + private function exportParameterNodes(array $params): array + { + $nodes = []; + foreach ($params as $param) { + if (!$param->var instanceof Node\Expr\Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $type = $param->type; + if ( + $type !== null + && $param->default instanceof Node\Expr\ConstFetch + && $param->default->name->toLowerString() === 'null' + ) { + if ($type instanceof Node\UnionType) { + $innerTypes = $type->types; + $innerTypes[] = new Name('null'); + $type = new Node\UnionType($innerTypes); + } elseif ($type instanceof Node\Identifier || $type instanceof Name) { + $type = new Node\NullableType($type); + } + } + $nodes[] = new ExportedParameterNode( + $param->var->name, + NodeTypePrinter::printType($type), + $param->byRef, + $param->variadic, + $param->default !== null, + $this->exportAttributeNodes($param->attrGroups), + ); + } + + return $nodes; + } + + private function exportPhpDocNode( + string $file, + ?string $className, + ?string $functionName, + ?string $text, + ): ?ExportedPhpDocNode + { + if ($text === null) { + return null; + } + + $resolvedPhpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $className, + null, + $functionName, + $text, + ); + + $nameScope = $resolvedPhpDocBlock->getNullableNameScope(); + if ($nameScope === null) { + return null; + } + + return new ExportedPhpDocNode($text, $nameScope->getNamespace(), $nameScope->getUses(), $nameScope->getConstUses()); + } + + /** + * @param Node\Stmt[] $statements + * @return ExportedNode[] + */ + private function exportClassStatements(array $statements, string $fileName, string $namespacedName): array + { + $exportedNodes = []; + foreach ($statements as $statement) { + $exportedNode = $this->exportClassStatement($statement, $fileName, $namespacedName); + if ($exportedNode === null) { + continue; + } + + $exportedNodes[] = $exportedNode; + } + + return $exportedNodes; + } + + private function exportClassStatement(Node\Stmt $node, string $fileName, string $namespacedName): ?ExportedNode + { + if ($node instanceof ClassMethod) { + if ($node->isAbstract() || $node->isFinal() || !$node->isPrivate()) { + $methodName = $node->name->toString(); + $docComment = $node->getDocComment(); + + return new ExportedMethodNode( + $methodName, + $this->exportPhpDocNode( + $fileName, + $namespacedName, + $methodName, + $docComment !== null ? $docComment->getText() : null, + ), + $node->byRef, + $node->isPublic(), + $node->isPrivate(), + $node->isAbstract(), + $node->isFinal(), + $node->isStatic(), + NodeTypePrinter::printType($node->returnType), + $this->exportParameterNodes($node->params), + $this->exportAttributeNodes($node->attrGroups), + ); + } + } + + if ($node instanceof Node\Stmt\Property) { + if ($node->isPrivate()) { + return null; + } + + $docComment = $node->getDocComment(); + + return new ExportedPropertiesNode( + array_map(static fn (Node\PropertyItem $prop): string => $prop->name->toString(), $node->props), + $this->exportPhpDocNode( + $fileName, + $namespacedName, + null, + $docComment !== null ? $docComment->getText() : null, + ), + NodeTypePrinter::printType($node->type), + $node->isPublic(), + $node->isPrivate(), + $node->isStatic(), + $node->isReadonly(), + $this->exportAttributeNodes($node->attrGroups), + ); + } + + if ($node instanceof Node\Stmt\ClassConst) { + if ($node->isPrivate()) { + return null; + } + + $docComment = $node->getDocComment(); + + $constants = []; + foreach ($node->consts as $const) { + $constants[] = new ExportedClassConstantNode( + $const->name->toString(), + $this->exprPrinter->printExpr($const->value), + $this->exportAttributeNodes($node->attrGroups), + ); + } + + return new ExportedClassConstantsNode( + $constants, + $node->isPublic(), + $node->isPrivate(), + $node->isFinal(), + $this->exportPhpDocNode( + $fileName, + $namespacedName, + null, + $docComment !== null ? $docComment->getText() : null, + ), + ); + } + + if ($node instanceof Node\Stmt\EnumCase) { + $docComment = $node->getDocComment(); + + return new ExportedEnumCaseNode( + $node->name->toString(), + $node->expr !== null ? $this->exprPrinter->printExpr($node->expr) : null, + $this->exportPhpDocNode( + $fileName, + $namespacedName, + null, + $docComment !== null ? $docComment->getText() : null, + ), + ); + } + + return null; + } + + /** + * @param Node\AttributeGroup[] $attributeGroups + * @return ExportedAttributeNode[] + */ + private function exportAttributeNodes(array $attributeGroups): array + { + $nodes = []; + foreach ($attributeGroups as $attributeGroup) { + foreach ($attributeGroup->attrs as $attribute) { + $args = []; + foreach ($attribute->args as $i => $arg) { + $args[$arg->name->name ?? $i] = $this->exprPrinter->printExpr($arg->value); + } + + $nodes[] = new ExportedAttributeNode( + $attribute->name->toString(), + $args, + ); + } + } + + return $nodes; + } + +} diff --git a/src/Dependency/ExportedNodeVisitor.php b/src/Dependency/ExportedNodeVisitor.php new file mode 100644 index 00000000..d3eae618 --- /dev/null +++ b/src/Dependency/ExportedNodeVisitor.php @@ -0,0 +1,62 @@ +fileName = $fileName; + $this->currentNodes = []; + } + + /** + * @return RootExportedNode[] + */ + public function getExportedNodes(): array + { + return $this->currentNodes; + } + + public function enterNode(Node $node): ?int + { + if ($this->fileName === null) { + throw new ShouldNotHappenException(); + } + $exportedNode = $this->exportedNodeResolver->resolve($this->fileName, $node); + if ($exportedNode !== null) { + $this->currentNodes[] = $exportedNode; + } + + if ( + $node instanceof Node\Stmt\ClassMethod + || $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Stmt\Trait_ + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + return null; + } + +} diff --git a/src/Dependency/NodeDependencies.php b/src/Dependency/NodeDependencies.php new file mode 100644 index 00000000..b13d11a8 --- /dev/null +++ b/src/Dependency/NodeDependencies.php @@ -0,0 +1,59 @@ + $reflections + */ + public function __construct( + private FileHelper $fileHelper, + private array $reflections, + private ?RootExportedNode $exportedNode, + ) + { + } + + /** + * @param array $analysedFiles + * @return string[] + */ + public function getFileDependencies(string $currentFile, array $analysedFiles): array + { + $dependencies = []; + + foreach ($this->reflections as $dependencyReflection) { + $dependencyFile = $dependencyReflection->getFileName(); + if ($dependencyFile === null) { + continue; + } + $dependencyFile = $this->fileHelper->normalizePath($dependencyFile); + + if ($currentFile === $dependencyFile) { + continue; + } + + if (!isset($analysedFiles[$dependencyFile])) { + continue; + } + + $dependencies[$dependencyFile] = $dependencyFile; + } + + return array_values($dependencies); + } + + public function getExportedNode(): ?RootExportedNode + { + return $this->exportedNode; + } + +} diff --git a/src/Dependency/RootExportedNode.php b/src/Dependency/RootExportedNode.php new file mode 100644 index 00000000..8ace31d7 --- /dev/null +++ b/src/Dependency/RootExportedNode.php @@ -0,0 +1,24 @@ + $bool, + BrokerFactory::METHODS_CLASS_REFLECTION_EXTENSION_TAG => $bool, + BrokerFactory::DYNAMIC_METHOD_RETURN_TYPE_EXTENSION_TAG => $bool, + BrokerFactory::DYNAMIC_STATIC_METHOD_RETURN_TYPE_EXTENSION_TAG => $bool, + BrokerFactory::DYNAMIC_FUNCTION_RETURN_TYPE_EXTENSION_TAG => $bool, + BrokerFactory::EXPRESSION_TYPE_RESOLVER_EXTENSION_TAG => $bool, + BrokerFactory::OPERATOR_TYPE_SPECIFYING_EXTENSION_TAG => $bool, + BrokerFactory::ALLOWED_SUB_TYPES_CLASS_REFLECTION_EXTENSION_TAG => $bool, + LazyRegistry::RULE_TAG => $bool, + TypeNodeResolverExtension::EXTENSION_TAG => $bool, + StubFilesExtension::EXTENSION_TAG => $bool, + AlwaysUsedClassConstantsExtensionProvider::EXTENSION_TAG => $bool, + ReadWritePropertiesExtensionProvider::EXTENSION_TAG => $bool, + TypeSpecifierFactory::FUNCTION_TYPE_SPECIFYING_EXTENSION_TAG => $bool, + TypeSpecifierFactory::METHOD_TYPE_SPECIFYING_EXTENSION_TAG => $bool, + TypeSpecifierFactory::STATIC_METHOD_TYPE_SPECIFYING_EXTENSION_TAG => $bool, + RichParser::VISITOR_SERVICE_TAG => $bool, + CollectorRegistryFactory::COLLECTOR_TAG => $bool, + LazyDynamicThrowTypeExtensionProvider::FUNCTION_TAG => $bool, + LazyDynamicThrowTypeExtensionProvider::METHOD_TAG => $bool, + LazyDynamicThrowTypeExtensionProvider::STATIC_METHOD_TAG => $bool, + LazyParameterClosureTypeExtensionProvider::FUNCTION_TAG => $bool, + LazyParameterClosureTypeExtensionProvider::METHOD_TAG => $bool, + LazyParameterClosureTypeExtensionProvider::STATIC_METHOD_TAG => $bool, + LazyParameterOutTypeExtensionProvider::FUNCTION_TAG => $bool, + LazyParameterOutTypeExtensionProvider::METHOD_TAG => $bool, + LazyParameterOutTypeExtensionProvider::STATIC_METHOD_TAG => $bool, + DiagnoseExtension::EXTENSION_TAG => $bool, + ResultCacheMetaExtension::EXTENSION_TAG => $bool, + ])->min(1)); + } + + public function beforeCompile(): void + { + /** @var mixed[] $config */ + $config = $this->config; + $builder = $this->getContainerBuilder(); + + foreach ($config as $type => $tags) { + $services = $builder->findByType($type); + if (count($services) === 0) { + throw new ShouldNotHappenException(sprintf('No services of type "%s" found.', $type)); + } + foreach ($services as $service) { + foreach ($tags as $tag => $parameter) { + if (is_array($parameter)) { + $parameter = array_reduce($parameter, static fn ($carry, $item) => $carry && (bool) $item, true); + } + if ((bool) $parameter) { + $service->addTag($tag); + continue; + } + } + } + } + } + +} diff --git a/src/DependencyInjection/Configurator.php b/src/DependencyInjection/Configurator.php new file mode 100644 index 00000000..db7eac05 --- /dev/null +++ b/src/DependencyInjection/Configurator.php @@ -0,0 +1,220 @@ +loaderFactory->createLoader(); + } + + /** + * @param string[] $allConfigFiles + */ + public function setAllConfigFiles(array $allConfigFiles): void + { + $this->allConfigFiles = $allConfigFiles; + } + + /** + * @return mixed[] + */ + protected function getDefaultParameters(): array + { + return []; + } + + public function getContainerCacheDirectory(): string + { + return $this->getCacheDirectory() . '/nette.configurator'; + } + + public function loadContainer(): string + { + $loader = new ContainerLoader( + $this->getContainerCacheDirectory(), + $this->staticParameters['debugMode'], + ); + + $className = $loader->load( + [$this, 'generateContainer'], + [$this->staticParameters, array_keys($this->dynamicParameters), $this->configs, PHP_VERSION_ID - PHP_RELEASE_VERSION, NeonAdapter::CACHE_KEY, $this->getAllConfigFilesHashes()], + ); + + if ($this->journalContainer) { + $this->journal($className); + } + + return $className; + } + + private function journal(string $currentContainerClassName): void + { + $directory = $this->getContainerCacheDirectory(); + if (!is_dir($directory)) { + return; + } + + $journalFile = $directory . '/container.journal'; + if (!is_file($journalFile)) { + try { + FileWriter::write($journalFile, sprintf("%s:%d\n", $currentContainerClassName, time())); + } catch (CouldNotWriteFileException) { + // pass + } + + return; + } + + try { + $journalContents = FileReader::read($journalFile); + } catch (CouldNotReadFileException) { + return; + } + + $journalLines = explode("\n", trim($journalContents)); + $linesToWrite = []; + $usedInTheLastWeek = []; + $now = time(); + $currentAlreadyInTheJournal = false; + foreach ($journalLines as $journalLine) { + if ($journalLine === '') { + continue; + } + $journalLineParts = explode(':', $journalLine); + if (count($journalLineParts) !== 2) { + return; + } + $className = $journalLineParts[0]; + $containerLastUsedTime = (int) $journalLineParts[1]; + + $week = 3600 * 24 * 7; + + if ($containerLastUsedTime + $week < $now) { + continue; + } + + $usedInTheLastWeek[] = $className; + + if ($currentContainerClassName !== $className) { + $linesToWrite[] = sprintf('%s:%d', $className, $containerLastUsedTime); + continue; + } + + $linesToWrite[] = sprintf('%s:%d', $currentContainerClassName, $now); + $currentAlreadyInTheJournal = true; + } + + if (!$currentAlreadyInTheJournal) { + $linesToWrite[] = sprintf('%s:%d', $currentContainerClassName, $now); + $usedInTheLastWeek[] = $currentContainerClassName; + } + + try { + FileWriter::write($journalFile, implode("\n", $linesToWrite) . "\n"); + } catch (CouldNotWriteFileException) { + return; + } + + foreach (new DirectoryIterator($directory) as $fileInfo) { + if ($fileInfo->isDot()) { + continue; + } + $fileName = $fileInfo->getFilename(); + if ($fileName === 'container.journal') { + continue; + } + if (!str_ends_with($fileName, '.php')) { + continue; + } + $fileClassName = substr($fileName, 0, -4); + if (in_array($fileClassName, $usedInTheLastWeek, true)) { + continue; + } + $basePathname = $fileInfo->getPathname(); + @unlink($basePathname); + @unlink($basePathname . '.lock'); + @unlink($basePathname . '.meta'); + } + } + + public function createContainer(bool $initialize = true): OriginalNetteContainer + { + set_error_handler(static function (int $errno): bool { + if ((error_reporting() & $errno) === 0) { + // silence @ operator + return true; + } + + return $errno === E_USER_DEPRECATED; + }); + + try { + $container = parent::createContainer($initialize); + } finally { + restore_error_handler(); + } + + return $container; + } + + /** + * @return string[] + */ + private function getAllConfigFilesHashes(): array + { + $hashes = []; + foreach ($this->allConfigFiles as $file) { + $hash = sha1_file($file); + + if ($hash === false) { + throw new CouldNotReadFileException($file); + } + + $hashes[$file] = $hash; + } + + return $hashes; + } + +} diff --git a/src/DependencyInjection/Container.php b/src/DependencyInjection/Container.php new file mode 100644 index 00000000..7751996b --- /dev/null +++ b/src/DependencyInjection/Container.php @@ -0,0 +1,48 @@ + $className + * @return T + */ + public function getByType(string $className); + + /** + * @param class-string $className + * @return string[] + */ + public function findServiceNamesByType(string $className): array; + + /** + * @return mixed[] + */ + public function getServicesByTag(string $tagName): array; + + /** + * @return mixed[] + */ + public function getParameters(): array; + + public function hasParameter(string $parameterName): bool; + + /** + * @return mixed + * @throws ParameterNotFoundException + */ + public function getParameter(string $parameterName); + +} diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php new file mode 100644 index 00000000..ff8bb0fb --- /dev/null +++ b/src/DependencyInjection/ContainerFactory.php @@ -0,0 +1,395 @@ +fileHelper = new FileHelper($currentWorkingDirectory); + + $rootDir = __DIR__ . '/../..'; + $originalRootDir = $this->fileHelper->normalizePath($rootDir); + if (extension_loaded('phar')) { + $pharPath = Phar::running(false); + if ($pharPath !== '') { + $rootDir = dirname($pharPath); + } + } + $this->rootDirectory = $this->fileHelper->normalizePath($rootDir); + $this->configDirectory = $originalRootDir . '/conf'; + } + + public function setJournalContainer(): void + { + $this->journalContainer = true; + } + + /** + * @param string[] $additionalConfigFiles + * @param string[] $analysedPaths + * @param string[] $composerAutoloaderProjectPaths + * @param string[] $analysedPathsFromConfig + */ + public function create( + string $tempDirectory, + array $additionalConfigFiles, + array $analysedPaths, + array $composerAutoloaderProjectPaths = [], + array $analysedPathsFromConfig = [], + string $usedLevel = CommandHelper::DEFAULT_LEVEL, + ?string $generateBaselineFile = null, + ?string $cliAutoloadFile = null, + ): Container + { + [$allConfigFiles, $projectConfig] = $this->detectDuplicateIncludedFiles( + array_merge([__DIR__ . '/../../conf/parametersSchema.neon'], $additionalConfigFiles), + [ + 'rootDir' => $this->rootDirectory, + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'env' => getenv(), + ], + ); + + $configurator = new Configurator(new LoaderFactory( + $this->fileHelper, + $this->rootDirectory, + $this->currentWorkingDirectory, + $generateBaselineFile, + ), $this->journalContainer); + $configurator->defaultExtensions = [ + 'php' => PhpExtension::class, + 'extensions' => ExtensionsExtension::class, + ]; + $configurator->setDebugMode(true); + $configurator->setTempDirectory($tempDirectory); + $configurator->addParameters([ + 'rootDir' => $this->rootDirectory, + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'cliArgumentsVariablesRegistered' => ini_get('register_argc_argv') === '1', + 'tmpDir' => $tempDirectory, + 'additionalConfigFiles' => $additionalConfigFiles, + 'allConfigFiles' => $allConfigFiles, + 'composerAutoloaderProjectPaths' => $composerAutoloaderProjectPaths, + 'generateBaselineFile' => $generateBaselineFile, + 'usedLevel' => $usedLevel, + 'cliAutoloadFile' => $cliAutoloadFile, + ]); + $configurator->addDynamicParameters([ + 'analysedPaths' => $analysedPaths, + 'analysedPathsFromConfig' => $analysedPathsFromConfig, + 'env' => getenv(), + ]); + $configurator->addConfig($this->configDirectory . '/config.neon'); + foreach ($additionalConfigFiles as $additionalConfigFile) { + $configurator->addConfig($additionalConfigFile); + } + + $configurator->setAllConfigFiles($allConfigFiles); + + $container = $configurator->createContainer()->getByType(Container::class); + $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']); + self::postInitializeContainer($container); + + return $container; + } + + /** @internal */ + public static function postInitializeContainer(Container $container): void + { + $containerId = spl_object_id($container); + if ($containerId === self::$lastInitializedContainerId) { + return; + } + + self::$lastInitializedContainerId = $containerId; + + /** @var SourceLocator $sourceLocator */ + $sourceLocator = $container->getService('betterReflectionSourceLocator'); + + /** @var Reflector $reflector */ + $reflector = $container->getService('betterReflectionReflector'); + + /** @var Parser $phpParser */ + $phpParser = $container->getService('phpParserDecorator'); + + BetterReflection::populate( + $container->getByType(PhpVersion::class)->getVersionId(), + $sourceLocator, + $reflector, + $phpParser, + $container->getByType(PhpStormStubsSourceStubber::class), + $container->getByType(Printer::class), + ); + + ReflectionProviderStaticAccessor::registerInstance($container->getByType(ReflectionProvider::class)); + PhpVersionStaticAccessor::registerInstance($container->getByType(PhpVersion::class)); + ObjectType::resetCaches(); + $container->getService('typeSpecifier'); + + BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']); + } + + public function getCurrentWorkingDirectory(): string + { + return $this->currentWorkingDirectory; + } + + public function getRootDirectory(): string + { + return $this->rootDirectory; + } + + public function getConfigDirectory(): string + { + return $this->configDirectory; + } + + /** + * @param string[] $configFiles + * @param array $loaderParameters + * @return array{list, array} + * @throws DuplicateIncludedFilesException + */ + private function detectDuplicateIncludedFiles( + array $configFiles, + array $loaderParameters, + ): array + { + $neonAdapter = new NeonAdapter(); + $phpAdapter = new PhpAdapter(); + $allConfigFiles = []; + $configArray = []; + foreach ($configFiles as $configFile) { + [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($this->fileHelper, $neonAdapter, $phpAdapter, $configFile, $loaderParameters, null); + $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles); + + /** @var array $configArray */ + $configArray = \Nette\Schema\Helpers::merge($tmpConfigArray, $configArray); + } + + $normalized = array_map(fn (string $file): string => $this->fileHelper->normalizePath($file), $allConfigFiles); + + $deduplicated = array_unique($normalized); + if (count($normalized) <= count($deduplicated)) { + return [$normalized, $configArray]; + } + + $duplicateFiles = array_unique(array_diff_key($normalized, $deduplicated)); + + throw new DuplicateIncludedFilesException($duplicateFiles); + } + + /** + * @param array $loaderParameters + * @return array{list, array} + */ + private static function getConfigFiles( + FileHelper $fileHelper, + NeonAdapter $neonAdapter, + PhpAdapter $phpAdapter, + string $configFile, + array $loaderParameters, + ?string $generateBaselineFile, + ): array + { + if ($generateBaselineFile === $fileHelper->normalizePath($configFile)) { + return [[], []]; + } + if (!is_file($configFile) || !is_readable($configFile)) { + return [[], []]; + } + + if (str_ends_with($configFile, '.php')) { + $data = $phpAdapter->load($configFile); + } else { + $data = $neonAdapter->load($configFile); + } + $allConfigFiles = [$configFile]; + if (isset($data['includes'])) { + Validators::assert($data['includes'], 'list', sprintf("section 'includes' in file '%s'", $configFile)); + $includes = Helpers::expand($data['includes'], $loaderParameters); + foreach ($includes as $include) { + $include = self::expandIncludedFile($include, $configFile); + [$tmpConfigFiles, $tmpConfigArray] = self::getConfigFiles($fileHelper, $neonAdapter, $phpAdapter, $include, $loaderParameters, $generateBaselineFile); + $allConfigFiles = array_merge($allConfigFiles, $tmpConfigFiles); + + /** @var array $data */ + $data = \Nette\Schema\Helpers::merge($tmpConfigArray, $data); + } + } + + return [$allConfigFiles, $data]; + } + + private static function expandIncludedFile(string $includedFile, string $mainFile): string + { + return Strings::match($includedFile, '#([a-z]+:)?[/\\\\]#Ai') !== null // is absolute + ? $includedFile + : dirname($mainFile) . '/' . $includedFile; + } + + /** + * @param array $parameters + * @param array $parametersSchema + */ + private function validateParameters(array $parameters, array $parametersSchema): void + { + if (!(bool) $parameters['__validate']) { + return; + } + + $schema = $this->processArgument( + new Statement('schema', [ + new Statement('structure', [$parametersSchema]), + ]), + ); + $processor = new Processor(); + $processor->onNewContext[] = static function (SchemaContext $context): void { + $context->path = ['parameters']; + }; + $processor->process($schema, $parameters); + + if ( + !array_key_exists('phpVersion', $parameters) + || !is_array($parameters['phpVersion'])) { + return; + } + + $phpVersion = $parameters['phpVersion']; + + if ($phpVersion['max'] < $phpVersion['min']) { + throw new InvalidPhpVersionException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.'); + } + } + + /** + * @param Statement[] $statements + */ + private function processSchema(array $statements, bool $required = true): Schema + { + if (count($statements) === 0) { + throw new ShouldNotHappenException(); + } + + $parameterSchema = null; + foreach ($statements as $statement) { + $processedArguments = array_map(fn ($argument) => $this->processArgument($argument), $statement->arguments); + if ($parameterSchema === null) { + /** @var Type|AnyOf|Structure $parameterSchema */ + $parameterSchema = Expect::{$statement->getEntity()}(...$processedArguments); + } else { + $parameterSchema->{$statement->getEntity()}(...$processedArguments); + } + } + + if ($required) { + $parameterSchema->required(); + } + + return $parameterSchema; + } + + /** + * @param mixed $argument + * @return mixed + */ + private function processArgument($argument, bool $required = true) + { + if ($argument instanceof Statement) { + if ($argument->entity === 'schema') { + $arguments = []; + foreach ($argument->arguments as $schemaArgument) { + if (!$schemaArgument instanceof Statement) { + throw new ShouldNotHappenException('schema() should contain another statement().'); + } + + $arguments[] = $schemaArgument; + } + + if (count($arguments) === 0) { + throw new ShouldNotHappenException('schema() should have at least one argument.'); + } + + return $this->processSchema($arguments, $required); + } + + return $this->processSchema([$argument], $required); + } elseif (is_array($argument)) { + $processedArray = []; + foreach ($argument as $key => $val) { + $required = $key[0] !== '?'; + $key = $required ? $key : substr($key, 1); + $processedArray[$key] = $this->processArgument($val, $required); + } + + return $processedArray; + } + + return $argument; + } + +} diff --git a/src/DependencyInjection/DerivativeContainerFactory.php b/src/DependencyInjection/DerivativeContainerFactory.php new file mode 100644 index 00000000..b3554af6 --- /dev/null +++ b/src/DependencyInjection/DerivativeContainerFactory.php @@ -0,0 +1,53 @@ +currentWorkingDirectory, + ); + $containerFactory->setJournalContainer(); + + return $containerFactory->create( + $this->tempDirectory, + array_merge($this->additionalConfigFiles, $additionalConfigFiles), + $this->analysedPaths, + $this->composerAutoloaderProjectPaths, + $this->analysedPathsFromConfig, + $this->usedLevel, + $this->generateBaselineFile, + $this->cliAutoloadFile, + ); + } + +} diff --git a/src/DependencyInjection/DuplicateIncludedFilesException.php b/src/DependencyInjection/DuplicateIncludedFilesException.php new file mode 100644 index 00000000..a8f9490d --- /dev/null +++ b/src/DependencyInjection/DuplicateIncludedFilesException.php @@ -0,0 +1,29 @@ +files))); + } + + /** + * @return string[] + */ + public function getFiles(): array + { + return $this->files; + } + +} diff --git a/src/DependencyInjection/InvalidExcludePathsException.php b/src/DependencyInjection/InvalidExcludePathsException.php new file mode 100644 index 00000000..e5b23ad7 --- /dev/null +++ b/src/DependencyInjection/InvalidExcludePathsException.php @@ -0,0 +1,28 @@ +errors)); + } + + /** + * @return string[] + */ + public function getErrors(): array + { + return $this->errors; + } + +} diff --git a/src/DependencyInjection/InvalidIgnoredErrorPatternsException.php b/src/DependencyInjection/InvalidIgnoredErrorPatternsException.php new file mode 100644 index 00000000..7572f60c --- /dev/null +++ b/src/DependencyInjection/InvalidIgnoredErrorPatternsException.php @@ -0,0 +1,28 @@ +errors)); + } + + /** + * @return string[] + */ + public function getErrors(): array + { + return $this->errors; + } + +} diff --git a/src/DependencyInjection/InvalidPhpVersionException.php b/src/DependencyInjection/InvalidPhpVersionException.php new file mode 100644 index 00000000..850ac598 --- /dev/null +++ b/src/DependencyInjection/InvalidPhpVersionException.php @@ -0,0 +1,11 @@ +fileHelper, $this->generateBaselineFile); + $loader->addAdapter('dist', NeonAdapter::class); + $loader->addAdapter('neon', NeonAdapter::class); + $loader->setParameters([ + 'rootDir' => $this->rootDir, + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'env' => getenv(), + ]); + + return $loader; + } + +} diff --git a/src/DependencyInjection/MemoizingContainer.php b/src/DependencyInjection/MemoizingContainer.php new file mode 100644 index 00000000..a6bde401 --- /dev/null +++ b/src/DependencyInjection/MemoizingContainer.php @@ -0,0 +1,65 @@ + */ + private array $servicesByType = []; + + public function __construct(private Container $originalContainer) + { + } + + public function hasService(string $serviceName): bool + { + return $this->originalContainer->hasService($serviceName); + } + + public function getService(string $serviceName) + { + return $this->originalContainer->getService($serviceName); + } + + public function getByType(string $className) + { + if (array_key_exists($className, $this->servicesByType)) { + return $this->servicesByType[$className]; + } + + $service = $this->originalContainer->getByType($className); + $this->servicesByType[$className] = $service; + + return $service; + } + + public function findServiceNamesByType(string $className): array + { + return $this->originalContainer->findServiceNamesByType($className); + } + + public function getServicesByTag(string $tagName): array + { + return $this->originalContainer->getServicesByTag($tagName); + } + + public function getParameters(): array + { + return $this->originalContainer->getParameters(); + } + + public function hasParameter(string $parameterName): bool + { + return $this->originalContainer->hasParameter($parameterName); + } + + public function getParameter(string $parameterName) + { + return $this->originalContainer->getParameter($parameterName); + } + +} diff --git a/src/DependencyInjection/Neon/OptionalPath.php b/src/DependencyInjection/Neon/OptionalPath.php new file mode 100644 index 00000000..3b05e3f2 --- /dev/null +++ b/src/DependencyInjection/Neon/OptionalPath.php @@ -0,0 +1,13 @@ +process((array) Neon::decode($contents), '', $file); + } catch (Exception $e) { + throw new Exception(sprintf('Error while loading %s: %s', $file, $e->getMessage())); + } + } + + /** + * @param mixed[] $arr + * @return mixed[] + */ + public function process(array $arr, string $fileKey, string $file): array + { + $res = []; + foreach ($arr as $key => $val) { + if (is_string($key) && substr($key, -1) === self::PREVENT_MERGING_SUFFIX) { + if (!is_array($val) && $val !== null) { + throw new InvalidConfigurationException(sprintf('Replacing operator is available only for arrays, item \'%s\' is not array.', $key)); + } + $key = substr($key, 0, -1); + $val[Helpers::PREVENT_MERGING] = true; + } + + $keyToResolve = $fileKey; + if (is_int($key)) { + $keyToResolve .= '[]'; + } else { + $keyToResolve .= '[' . $key . ']'; + } + + if (is_array($val)) { + if (!is_int($key)) { + $fileKeyToPass = $fileKey . '[' . $key . ']'; + } else { + $fileKeyToPass = $fileKey . '[]'; + } + $val = $this->process($val, $fileKeyToPass, $file); + + } elseif ($val instanceof Entity) { + if (!is_int($key)) { + $fileKeyToPass = $fileKey . '(' . $key . ')'; + } else { + $fileKeyToPass = $fileKey . '()'; + } + if ($val->value === Neon::CHAIN) { + $tmp = null; + foreach ($this->process($val->attributes, $fileKeyToPass, $file) as $st) { + $tmp = new Statement( + $tmp === null ? $st->getEntity() : [$tmp, ltrim(implode('::', (array) $st->getEntity()), ':')], + $st->arguments, + ); + } + $val = $tmp; + } else { + if ( + in_array($keyToResolve, [ + '[parameters][excludePaths][]', + '[parameters][excludePaths][analyse][]', + '[parameters][excludePaths][analyseAndScan][]', + ], true) + && count($val->attributes) === 1 + && $val->attributes[0] === '?' + && is_string($val->value) + && !str_contains($val->value, '%') + && !str_starts_with($val->value, '*') + ) { + $fileHelper = $this->createFileHelperByFile($file); + $val = new OptionalPath($fileHelper->normalizePath($fileHelper->absolutizePath($val->value))); + } else { + $tmp = $this->process([$val->value], $fileKeyToPass, $file); + $val = new Statement($tmp[0], $this->process($val->attributes, $fileKeyToPass, $file)); + } + } + } + + if (in_array($keyToResolve, [ + '[parameters][paths][]', + '[parameters][excludePaths][]', + '[parameters][excludePaths][analyse][]', + '[parameters][excludePaths][analyseAndScan][]', + '[parameters][ignoreErrors][][paths][]', + '[parameters][ignoreErrors][][path]', + '[parameters][bootstrapFiles][]', + '[parameters][scanFiles][]', + '[parameters][scanDirectories][]', + '[parameters][tmpDir]', + '[parameters][pro][tmpDir]', + '[parameters][memoryLimitFile]', + '[parameters][benchmarkFile]', + '[parameters][stubFiles][]', + '[parameters][symfony][consoleApplicationLoader]', + '[parameters][symfony][containerXmlPath]', + '[parameters][doctrine][objectManagerLoader]', + ], true) && is_string($val) && !str_contains($val, '%') && !str_starts_with($val, '*')) { + $fileHelper = $this->createFileHelperByFile($file); + $val = $fileHelper->normalizePath($fileHelper->absolutizePath($val)); + } + + if ( + $keyToResolve === '[parameters][excludePaths]' + && $val !== null + && array_values($val) === $val + ) { + $val = ['analyseAndScan' => $val, 'analyse' => []]; + } + + $res[$key] = $val; + } + return $res; + } + + /** + * @param mixed[] $data + */ + public function dump(array $data): string + { + array_walk_recursive( + $data, + static function (&$val): void { + if (!($val instanceof Statement)) { + return; + } + + $val = self::statementToEntity($val); + }, + ); + return "# generated by Nette\n\n" . Neon::encode($data, Neon::BLOCK); + } + + private static function statementToEntity(Statement $val): Entity + { + array_walk_recursive( + $val->arguments, + static function (&$val): void { + if ($val instanceof Statement) { + $val = self::statementToEntity($val); + } elseif ($val instanceof Reference) { + $val = '@' . $val->getValue(); + } + }, + ); + + $entity = $val->getEntity(); + if ($entity instanceof Reference) { + $entity = '@' . $entity->getValue(); + } elseif (is_array($entity)) { + if ($entity[0] instanceof Statement) { + return new Entity( + Neon::CHAIN, + [ + self::statementToEntity($entity[0]), + new Entity('::' . $entity[1], $val->arguments), + ], + ); + } elseif ($entity[0] instanceof Reference) { + $entity = '@' . $entity[0]->getValue() . '::' . $entity[1]; + } elseif (is_string($entity[0])) { + $entity = $entity[0] . '::' . $entity[1]; + } + } + return new Entity($entity, $val->arguments); + } + + private function createFileHelperByFile(string $file): FileHelper + { + $dir = dirname($file); + if (!isset($this->fileHelpers[$dir])) { + $this->fileHelpers[$dir] = new FileHelper($dir); + } + + return $this->fileHelpers[$dir]; + } + +} diff --git a/src/DependencyInjection/NeonLoader.php b/src/DependencyInjection/NeonLoader.php new file mode 100644 index 00000000..96e27625 --- /dev/null +++ b/src/DependencyInjection/NeonLoader.php @@ -0,0 +1,36 @@ +generateBaselineFile === null) { + return parent::load($file, $merge); + } + + $normalizedFile = $this->fileHelper->normalizePath($file); + if ($this->generateBaselineFile === $normalizedFile) { + return []; + } + + return parent::load($file, $merge); + } + +} diff --git a/src/DependencyInjection/Nette/NetteContainer.php b/src/DependencyInjection/Nette/NetteContainer.php new file mode 100644 index 00000000..ea830a38 --- /dev/null +++ b/src/DependencyInjection/Nette/NetteContainer.php @@ -0,0 +1,96 @@ +container->hasService($serviceName); + } + + /** + * @return mixed + */ + public function getService(string $serviceName) + { + return $this->container->getService($serviceName); + } + + /** + * @template T of object + * @param class-string $className + * @return T + */ + public function getByType(string $className) + { + return $this->container->getByType($className); + } + + /** + * @param class-string $className + * @return string[] + */ + public function findServiceNamesByType(string $className): array + { + return $this->container->findByType($className); + } + + /** + * @return mixed[] + */ + public function getServicesByTag(string $tagName): array + { + return $this->tagsToServices($this->container->findByTag($tagName)); + } + + /** + * @return mixed[] + */ + public function getParameters(): array + { + return $this->container->getParameters(); + } + + public function hasParameter(string $parameterName): bool + { + return array_key_exists($parameterName, $this->container->getParameters()); + } + + /** + * @return mixed + */ + public function getParameter(string $parameterName) + { + if (!$this->hasParameter($parameterName)) { + throw new ParameterNotFoundException($parameterName); + } + + return $this->container->getParameter($parameterName); + } + + /** + * @param mixed[] $tags + * @return mixed[] + */ + private function tagsToServices(array $tags): array + { + return array_map(fn (string $serviceName) => $this->getService($serviceName), array_keys($tags)); + } + +} diff --git a/src/DependencyInjection/ParameterNotFoundException.php b/src/DependencyInjection/ParameterNotFoundException.php new file mode 100644 index 00000000..aedb199f --- /dev/null +++ b/src/DependencyInjection/ParameterNotFoundException.php @@ -0,0 +1,17 @@ +min(1); + } + +} diff --git a/src/DependencyInjection/ProjectConfigHelper.php b/src/DependencyInjection/ProjectConfigHelper.php new file mode 100644 index 00000000..a16937aa --- /dev/null +++ b/src/DependencyInjection/ProjectConfigHelper.php @@ -0,0 +1,67 @@ + $projectConfig + * @return list + */ + public static function getServiceClassNames(array $projectConfig): array + { + $services = array_merge( + $projectConfig['services'] ?? [], + $projectConfig['rules'] ?? [], + ); + $classes = []; + foreach ($services as $service) { + $classes = array_merge($classes, self::getClassesFromConfigDefinition($service)); + if (!is_array($service)) { + continue; + } + + foreach (['class', 'factory', 'implement'] as $key) { + if (!isset($service[$key])) { + continue; + } + + $classes = array_merge($classes, self::getClassesFromConfigDefinition($service[$key])); + } + } + + return array_values(array_unique($classes)); + } + + /** + * @param mixed $definition + * @return string[] + */ + private static function getClassesFromConfigDefinition($definition): array + { + if (is_string($definition)) { + return [$definition]; + } + + if ($definition instanceof Statement) { + $entity = $definition->entity; + if (is_string($entity)) { + return [$entity]; + } elseif (is_array($entity) && isset($entity[0]) && is_string($entity[0])) { + return [$entity[0]]; + } + } + + return []; + } + +} diff --git a/src/DependencyInjection/Reflection/ClassReflectionExtensionRegistryProvider.php b/src/DependencyInjection/Reflection/ClassReflectionExtensionRegistryProvider.php new file mode 100644 index 00000000..412c4165 --- /dev/null +++ b/src/DependencyInjection/Reflection/ClassReflectionExtensionRegistryProvider.php @@ -0,0 +1,13 @@ +registry === null) { + $phpClassReflectionExtension = $this->container->getByType(PhpClassReflectionExtension::class); + $annotationsMethodsClassReflectionExtension = $this->container->getByType(AnnotationsMethodsClassReflectionExtension::class); + $annotationsPropertiesClassReflectionExtension = $this->container->getByType(AnnotationsPropertiesClassReflectionExtension::class); + + $mixinMethodsClassReflectionExtension = $this->container->getByType(MixinMethodsClassReflectionExtension::class); + $mixinPropertiesClassReflectionExtension = $this->container->getByType(MixinPropertiesClassReflectionExtension::class); + + $this->registry = new ClassReflectionExtensionRegistry( + array_merge([$phpClassReflectionExtension], $this->container->getServicesByTag(BrokerFactory::PROPERTIES_CLASS_REFLECTION_EXTENSION_TAG), [$annotationsPropertiesClassReflectionExtension, $mixinPropertiesClassReflectionExtension]), + array_merge([$phpClassReflectionExtension], $this->container->getServicesByTag(BrokerFactory::METHODS_CLASS_REFLECTION_EXTENSION_TAG), [$annotationsMethodsClassReflectionExtension, $mixinMethodsClassReflectionExtension]), + $this->container->getServicesByTag(BrokerFactory::ALLOWED_SUB_TYPES_CLASS_REFLECTION_EXTENSION_TAG), + $this->container->getByType(RequireExtendsPropertiesClassReflectionExtension::class), + $this->container->getByType(RequireExtendsMethodsClassReflectionExtension::class), + ); + } + + return $this->registry; + } + +} diff --git a/src/DependencyInjection/RulesExtension.php b/src/DependencyInjection/RulesExtension.php new file mode 100644 index 00000000..92a99120 --- /dev/null +++ b/src/DependencyInjection/RulesExtension.php @@ -0,0 +1,33 @@ +config; + $builder = $this->getContainerBuilder(); + + foreach ($config as $key => $rule) { + $builder->addDefinition($this->prefix((string) $key)) + ->setFactory($rule) + ->setAutowired($rule) + ->addTag(LazyRegistry::RULE_TAG); + } + } + +} diff --git a/src/DependencyInjection/Type/DynamicReturnTypeExtensionRegistryProvider.php b/src/DependencyInjection/Type/DynamicReturnTypeExtensionRegistryProvider.php new file mode 100644 index 00000000..cb1b06e2 --- /dev/null +++ b/src/DependencyInjection/Type/DynamicReturnTypeExtensionRegistryProvider.php @@ -0,0 +1,13 @@ +registry === null) { + $this->registry = new DynamicReturnTypeExtensionRegistry( + $this->container->getByType(ReflectionProvider::class), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_METHOD_RETURN_TYPE_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_STATIC_METHOD_RETURN_TYPE_EXTENSION_TAG), + $this->container->getServicesByTag(BrokerFactory::DYNAMIC_FUNCTION_RETURN_TYPE_EXTENSION_TAG), + ); + } + + return $this->registry; + } + +} diff --git a/src/DependencyInjection/Type/LazyDynamicThrowTypeExtensionProvider.php b/src/DependencyInjection/Type/LazyDynamicThrowTypeExtensionProvider.php new file mode 100644 index 00000000..351a6c64 --- /dev/null +++ b/src/DependencyInjection/Type/LazyDynamicThrowTypeExtensionProvider.php @@ -0,0 +1,34 @@ +container->getServicesByTag(self::FUNCTION_TAG); + } + + public function getDynamicMethodThrowTypeExtensions(): array + { + return $this->container->getServicesByTag(self::METHOD_TAG); + } + + public function getDynamicStaticMethodThrowTypeExtensions(): array + { + return $this->container->getServicesByTag(self::STATIC_METHOD_TAG); + } + +} diff --git a/src/DependencyInjection/Type/LazyExpressionTypeResolverExtensionRegistryProvider.php b/src/DependencyInjection/Type/LazyExpressionTypeResolverExtensionRegistryProvider.php new file mode 100644 index 00000000..fa16aa10 --- /dev/null +++ b/src/DependencyInjection/Type/LazyExpressionTypeResolverExtensionRegistryProvider.php @@ -0,0 +1,30 @@ +registry === null) { + $this->registry = new ExpressionTypeResolverExtensionRegistry( + $this->container->getServicesByTag(BrokerFactory::EXPRESSION_TYPE_RESOLVER_EXTENSION_TAG), + ); + } + + return $this->registry; + } + +} diff --git a/src/DependencyInjection/Type/LazyOperatorTypeSpecifyingExtensionRegistryProvider.php b/src/DependencyInjection/Type/LazyOperatorTypeSpecifyingExtensionRegistryProvider.php new file mode 100644 index 00000000..d1720cb1 --- /dev/null +++ b/src/DependencyInjection/Type/LazyOperatorTypeSpecifyingExtensionRegistryProvider.php @@ -0,0 +1,30 @@ +registry === null) { + $this->registry = new OperatorTypeSpecifyingExtensionRegistry( + $this->container->getServicesByTag(BrokerFactory::OPERATOR_TYPE_SPECIFYING_EXTENSION_TAG), + ); + } + + return $this->registry; + } + +} diff --git a/src/DependencyInjection/Type/LazyParameterClosureTypeExtensionProvider.php b/src/DependencyInjection/Type/LazyParameterClosureTypeExtensionProvider.php new file mode 100644 index 00000000..ecdbf08f --- /dev/null +++ b/src/DependencyInjection/Type/LazyParameterClosureTypeExtensionProvider.php @@ -0,0 +1,34 @@ +container->getServicesByTag(self::FUNCTION_TAG); + } + + public function getMethodParameterClosureTypeExtensions(): array + { + return $this->container->getServicesByTag(self::METHOD_TAG); + } + + public function getStaticMethodParameterClosureTypeExtensions(): array + { + return $this->container->getServicesByTag(self::STATIC_METHOD_TAG); + } + +} diff --git a/src/DependencyInjection/Type/LazyParameterOutTypeExtensionProvider.php b/src/DependencyInjection/Type/LazyParameterOutTypeExtensionProvider.php new file mode 100644 index 00000000..81c6ed2e --- /dev/null +++ b/src/DependencyInjection/Type/LazyParameterOutTypeExtensionProvider.php @@ -0,0 +1,34 @@ +container->getServicesByTag(self::FUNCTION_TAG); + } + + public function getMethodParameterOutTypeExtensions(): array + { + return $this->container->getServicesByTag(self::METHOD_TAG); + } + + public function getStaticMethodParameterOutTypeExtensions(): array + { + return $this->container->getServicesByTag(self::STATIC_METHOD_TAG); + } + +} diff --git a/src/DependencyInjection/Type/OperatorTypeSpecifyingExtensionRegistryProvider.php b/src/DependencyInjection/Type/OperatorTypeSpecifyingExtensionRegistryProvider.php new file mode 100644 index 00000000..436aec35 --- /dev/null +++ b/src/DependencyInjection/Type/OperatorTypeSpecifyingExtensionRegistryProvider.php @@ -0,0 +1,13 @@ +getContainerBuilder(); + $excludePaths = $builder->parameters['excludePaths']; + if ($excludePaths === null) { + return; + } + + $errors = []; + if ($builder->parameters['__validate']) { + $paths = []; + if (array_key_exists('analyse', $excludePaths)) { + $paths = $excludePaths['analyse']; + } + if (array_key_exists('analyseAndScan', $excludePaths)) { + $paths = array_merge($paths, $excludePaths['analyseAndScan']); + } + foreach ($paths as $path) { + if ($path instanceof OptionalPath) { + continue; + } + if (FileExcluder::isAbsolutePath($path)) { + if (is_dir($path)) { + continue; + } + if (is_file($path)) { + continue; + } + } + if (FileExcluder::isFnmatchPattern($path)) { + continue; + } + + $errors[] = sprintf('Path %s is neither a directory, nor a file path, nor a fnmatch pattern.', $path); + } + } + + $newExcludePaths = []; + if (array_key_exists('analyseAndScan', $excludePaths)) { + $newExcludePaths['analyseAndScan'] = $excludePaths['analyseAndScan']; + } + if (array_key_exists('analyse', $excludePaths)) { + $newExcludePaths['analyse'] = $excludePaths['analyse']; + } + + foreach ($newExcludePaths as $key => $p) { + $newExcludePaths[$key] = array_map( + static fn ($path) => $path instanceof OptionalPath ? $path->path : $path, + $p, + ); + } + + $builder->parameters['excludePaths'] = $newExcludePaths; + + if (count($errors) === 0) { + return; + } + + throw new InvalidExcludePathsException($errors); + } + +} diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php new file mode 100644 index 00000000..048fcf3f --- /dev/null +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -0,0 +1,227 @@ +getContainerBuilder(); + if (!$builder->parameters['__validate']) { + return; + } + + $ignoreErrors = $builder->parameters['ignoreErrors']; + if (count($ignoreErrors) === 0) { + return; + } + + /** @throws void */ + $parser = Llk::load(new Read(__DIR__ . '/../../resources/RegexGrammar.pp')); + $reflectionProvider = new DummyReflectionProvider(); + $reflectionProviderProvider = new DirectReflectionProviderProvider($reflectionProvider); + ReflectionProviderStaticAccessor::registerInstance($reflectionProvider); + PhpVersionStaticAccessor::registerInstance(new PhpVersion(PHP_VERSION_ID)); + $composerPhpVersionFactory = new ComposerPhpVersionFactory([]); + $constantResolver = new ConstantResolver($reflectionProviderProvider, [], null, $composerPhpVersionFactory); + + $phpDocParserConfig = new ParserConfig([]); + $ignoredRegexValidator = new IgnoredRegexValidator( + $parser, + new TypeStringResolver( + new Lexer($phpDocParserConfig), + new TypeParser($phpDocParserConfig, new ConstExprParser($phpDocParserConfig)), + new TypeNodeResolver( + new DirectTypeNodeResolverExtensionRegistryProvider( + new class implements TypeNodeResolverExtensionRegistry { + + public function getExtensions(): array + { + return []; + } + + }, + ), + $reflectionProviderProvider, + new DirectTypeAliasResolverProvider(new class implements TypeAliasResolver { + + public function hasTypeAlias(string $aliasName, ?string $classNameScope): bool + { + return false; + } + + public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type + { + return null; + } + + }), + $constantResolver, + new InitializerExprTypeResolver($constantResolver, $reflectionProviderProvider, new PhpVersion(PHP_VERSION_ID), new class implements OperatorTypeSpecifyingExtensionRegistryProvider { + + public function getRegistry(): OperatorTypeSpecifyingExtensionRegistry + { + return new OperatorTypeSpecifyingExtensionRegistry([]); + } + + }, new OversizedArrayBuilder(), true), + ), + ), + ); + + $errors = []; + foreach ($ignoreErrors as $ignoreError) { + if (is_array($ignoreError)) { + if (isset($ignoreError['count'])) { + continue; // ignoreError coming from baseline will be correct + } + if (isset($ignoreError['messages'])) { + $ignoreMessages = $ignoreError['messages']; + } elseif (isset($ignoreError['message'])) { + $ignoreMessages = [$ignoreError['message']]; + } else { + continue; + } + } else { + $ignoreMessages = [$ignoreError]; + } + + foreach ($ignoreMessages as $ignoreMessage) { + $error = $this->validateMessage($ignoredRegexValidator, $ignoreMessage); + if ($error === null) { + continue; + } + $errors[] = $error; + } + } + + $reportUnmatched = (bool) $builder->parameters['reportUnmatchedIgnoredErrors']; + + if ($reportUnmatched) { + foreach ($ignoreErrors as $ignoreError) { + if (!is_array($ignoreError)) { + continue; + } + + if (isset($ignoreError['path'])) { + $ignorePaths = [$ignoreError['path']]; + } elseif (isset($ignoreError['paths'])) { + $ignorePaths = $ignoreError['paths']; + } else { + continue; + } + + foreach ($ignorePaths as $ignorePath) { + if (FileExcluder::isAbsolutePath($ignorePath)) { + if (is_dir($ignorePath)) { + continue; + } + if (is_file($ignorePath)) { + continue; + } + } + if (FileExcluder::isFnmatchPattern($ignorePath)) { + continue; + } + + $errors[] = sprintf('Path %s is neither a directory, nor a file path, nor a fnmatch pattern.', $ignorePath); + } + } + } + + if (count($errors) === 0) { + return; + } + + throw new InvalidIgnoredErrorPatternsException($errors); + } + + private function validateMessage(IgnoredRegexValidator $ignoredRegexValidator, string $ignoreMessage): ?string + { + try { + Strings::match('', $ignoreMessage); + $validationResult = $ignoredRegexValidator->validate($ignoreMessage); + $ignoredTypes = $validationResult->getIgnoredTypes(); + if (count($ignoredTypes) > 0) { + return $this->createIgnoredTypesError($ignoreMessage, $ignoredTypes); + } + + if ($validationResult->hasAnchorsInTheMiddle()) { + return $this->createAnchorInTheMiddleError($ignoreMessage); + } + + if ($validationResult->areAllErrorsIgnored()) { + return sprintf("Ignored error %s has an unescaped '%s' which leads to ignoring all errors. Use '%s' instead.", $ignoreMessage, $validationResult->getWrongSequence(), $validationResult->getEscapedWrongSequence()); + } + } catch (RegexpException $e) { + return $e->getMessage(); + } + return null; + } + + /** + * @param array $ignoredTypes + */ + private function createIgnoredTypesError(string $regex, array $ignoredTypes): string + { + return sprintf( + "Ignored error %s has an unescaped '|' which leads to ignoring more errors than intended. Use '\\|' instead.\n%s", + $regex, + sprintf( + "It ignores all errors containing the following types:\n%s", + implode("\n", array_map(static fn (string $typeDescription): string => sprintf('* %s', $typeDescription), array_keys($ignoredTypes))), + ), + ); + } + + private function createAnchorInTheMiddleError(string $regex): string + { + return sprintf("Ignored error %s has an unescaped anchor '$' in the middle. This leads to unintended behavior. Use '\\$' instead.", $regex); + } + +} diff --git a/src/Diagnose/DiagnoseExtension.php b/src/Diagnose/DiagnoseExtension.php new file mode 100644 index 00000000..792a3206 --- /dev/null +++ b/src/Diagnose/DiagnoseExtension.php @@ -0,0 +1,32 @@ +writeLineFormatted(sprintf( + 'PHP runtime version: %s', + $phpRuntimeVersion->getVersionString(), + )); + + if ( + $this->phpVersion->getSource() === PhpVersion::SOURCE_CONFIG + && is_array($this->configPhpVersion) + ) { + $minVersion = new PhpVersion($this->configPhpVersion['min']); + $maxVersion = new PhpVersion($this->configPhpVersion['max']); + + $output->writeLineFormatted(sprintf( + 'PHP version for analysis: %s-%s (from %s)', + $minVersion->getVersionString(), + $maxVersion->getVersionString(), + $this->phpVersion->getSourceLabel(), + )); + + } else { + $minComposerPhpVersion = $this->composerPhpVersionFactory->getMinVersion(); + $maxComposerPhpVersion = $this->composerPhpVersionFactory->getMaxVersion(); + if ($minComposerPhpVersion !== null && $maxComposerPhpVersion !== null) { + if ($minComposerPhpVersion->getVersionId() !== $maxComposerPhpVersion->getVersionId()) { + $output->writeLineFormatted(sprintf( + 'PHP composer.json required version: %s-%s', + $minComposerPhpVersion->getVersionString(), + $maxComposerPhpVersion->getVersionString(), + )); + } else { + $output->writeLineFormatted(sprintf( + 'PHP composer.json required version: %s', + $minComposerPhpVersion->getVersionString(), + )); + } + } + + $output->writeLineFormatted(sprintf( + 'PHP version for analysis: %s (from %s)', + $this->phpVersion->getVersionString(), + $this->phpVersion->getSourceLabel(), + )); + } + $output->writeLineFormatted(''); + + $output->writeLineFormatted(sprintf( + 'PHPStan version: %s', + ComposerHelper::getPhpStanVersion(), + )); + $output->writeLineFormatted('PHPStan running from:'); + $pharRunning = Phar::running(false); + if ($pharRunning !== '') { + $output->writeLineFormatted(dirname($pharRunning)); + } else { + if (isset($_SERVER['argv'][0]) && is_file($_SERVER['argv'][0])) { + $output->writeLineFormatted($_SERVER['argv'][0]); + } else { + $output->writeLineFormatted('Unknown'); + } + } + $output->writeLineFormatted(''); + + $configFilesFromExtensionInstaller = []; + if (class_exists('PHPStan\ExtensionInstaller\GeneratedConfig')) { + $output->writeLineFormatted('Extension installer:'); + if (count(GeneratedConfig::EXTENSIONS) === 0) { + $output->writeLineFormatted('No extensions installed'); + } + + $generatedConfigReflection = new ReflectionClass('PHPStan\ExtensionInstaller\GeneratedConfig'); + $generatedConfigDirectory = dirname($generatedConfigReflection->getFileName()); + foreach (GeneratedConfig::EXTENSIONS as $name => $extensionConfig) { + $output->writeLineFormatted(sprintf('%s: %s', $name, $extensionConfig['version'] ?? 'Unknown version')); + foreach ($extensionConfig['extra']['includes'] ?? [] as $includedFile) { + $includedFilePath = null; + if (isset($extensionConfig['relative_install_path'])) { + $includedFilePath = sprintf('%s/%s/%s', $generatedConfigDirectory, $extensionConfig['relative_install_path'], $includedFile); + if (!is_file($includedFilePath) || !is_readable($includedFilePath)) { + $includedFilePath = null; + } + } + + if ($includedFilePath === null) { + $includedFilePath = sprintf('%s/%s', $extensionConfig['install_path'], $includedFile); + } + + $configFilesFromExtensionInstaller[] = $this->fileHelper->normalizePath($includedFilePath, '/'); + } + } + } else { + $output->writeLineFormatted('Extension installer: Not installed'); + } + $output->writeLineFormatted(''); + + $thirdPartyIncludedConfigs = []; + foreach ($this->allConfigFiles as $configFile) { + $configFile = $this->fileHelper->normalizePath($configFile, '/'); + if (in_array($configFile, $configFilesFromExtensionInstaller, true)) { + continue; + } + foreach ($this->composerAutoloaderProjectPaths as $composerAutoloaderProjectPath) { + $composerConfig = ComposerHelper::getComposerConfig($composerAutoloaderProjectPath); + if ($composerConfig === null) { + continue; + } + $vendorDir = $this->fileHelper->normalizePath(ComposerHelper::getVendorDirFromComposerConfig($composerAutoloaderProjectPath, $composerConfig), '/'); + if (!str_starts_with($configFile, $vendorDir)) { + continue; + } + + $installedPath = $vendorDir . '/composer/installed.php'; + if (!is_file($installedPath)) { + continue; + } + + $installed = require $installedPath; + + $trimmed = substr($configFile, strlen($vendorDir) + 1); + $parts = explode('/', $trimmed); + $package = implode('/', array_slice($parts, 0, 2)); + $configPath = implode('/', array_slice($parts, 2)); + if (!array_key_exists($package, $installed['versions'])) { + continue; + } + + $packageVersion = $installed['versions'][$package]['pretty_version'] ?? null; + if ($packageVersion === null) { + continue; + } + + $thirdPartyIncludedConfigs[] = [$package, $packageVersion, $configPath]; + } + } + + if (count($thirdPartyIncludedConfigs) > 0) { + $output->writeLineFormatted('Included configs from Composer packages:'); + foreach ($thirdPartyIncludedConfigs as [$package, $packageVersion, $configPath]) { + $output->writeLineFormatted(sprintf('%s (%s): %s', $package, $configPath, $packageVersion)); + } + $output->writeLineFormatted(''); + } + + $composerAutoloaderProjectPathsCount = count($this->composerAutoloaderProjectPaths); + $output->writeLineFormatted(sprintf( + 'Discovered Composer project %s:', + $composerAutoloaderProjectPathsCount === 1 ? 'root' : 'roots', + )); + if ($composerAutoloaderProjectPathsCount === 0) { + $output->writeLineFormatted('None'); + } + foreach ($this->composerAutoloaderProjectPaths as $composerAutoloaderProjectPath) { + $output->writeLineFormatted($composerAutoloaderProjectPath); + } + $output->writeLineFormatted(''); + } + +} diff --git a/src/File/CouldNotReadFileException.php b/src/File/CouldNotReadFileException.php new file mode 100644 index 00000000..69b4757a --- /dev/null +++ b/src/File/CouldNotReadFileException.php @@ -0,0 +1,22 @@ + 0 && in_array($exclude[$len - 1], ['\\', '/'], true)); + + $normalized = $fileHelper->normalizePath($exclude); + + if ($trailingDirSeparator) { + $normalized .= DIRECTORY_SEPARATOR; + } + + if (self::isFnmatchPattern($normalized)) { + $this->fnmatchAnalyseExcludes[] = $normalized; + } else { + if (is_file($normalized)) { + $this->literalAnalyseFilesExcludes[] = $normalized; + } elseif (is_dir($normalized)) { + if (!$trailingDirSeparator) { + $normalized .= DIRECTORY_SEPARATOR; + } + + $this->literalAnalyseDirectoryExcludes[] = $normalized; + } + } + } + + $isWindows = DIRECTORY_SEPARATOR === '\\'; + if ($isWindows) { + $this->fnmatchFlags = FNM_NOESCAPE | FNM_CASEFOLD; + } else { + $this->fnmatchFlags = 0; + } + } + + public function isExcludedFromAnalysing(string $file): bool + { + $file = $this->fileHelper->normalizePath($file); + + foreach ($this->literalAnalyseExcludes as $exclude) { + if (str_starts_with($file, $exclude)) { + return true; + } + } + foreach ($this->literalAnalyseDirectoryExcludes as $exclude) { + if (str_starts_with($file, $exclude)) { + return true; + } + } + foreach ($this->literalAnalyseFilesExcludes as $exclude) { + if ($file === $exclude) { + return true; + } + } + foreach ($this->fnmatchAnalyseExcludes as $exclude) { + if (fnmatch($exclude, $file, $this->fnmatchFlags)) { + return true; + } + } + + return false; + } + + public static function isAbsolutePath(string $path): bool + { + if (DIRECTORY_SEPARATOR === '/') { + if (str_starts_with($path, '/')) { + return true; + } + } elseif (substr($path, 1, 1) === ':') { + return true; + } + + return false; + } + + public static function isFnmatchPattern(string $path): bool + { + return preg_match('~[*?[\]]~', $path) > 0; + } + +} diff --git a/src/File/FileExcluderFactory.php b/src/File/FileExcluderFactory.php new file mode 100644 index 00000000..8030609e --- /dev/null +++ b/src/File/FileExcluderFactory.php @@ -0,0 +1,47 @@ +, analyseAndScan?: array} $excludePaths + */ + public function __construct( + private FileExcluderRawFactory $fileExcluderRawFactory, + private array $excludePaths, + ) + { + } + + public function createAnalyseFileExcluder(): FileExcluder + { + $paths = []; + if (array_key_exists('analyse', $this->excludePaths)) { + $paths = $this->excludePaths['analyse']; + } + if (array_key_exists('analyseAndScan', $this->excludePaths)) { + $paths = array_merge($paths, $this->excludePaths['analyseAndScan']); + } + + return $this->fileExcluderRawFactory->create(array_values(array_unique($paths))); + } + + public function createScanFileExcluder(): FileExcluder + { + $paths = []; + if (array_key_exists('analyseAndScan', $this->excludePaths)) { + $paths = $this->excludePaths['analyseAndScan']; + } + + return $this->fileExcluderRawFactory->create(array_values(array_unique($paths))); + } + +} diff --git a/src/File/FileExcluderRawFactory.php b/src/File/FileExcluderRawFactory.php new file mode 100644 index 00000000..6c35ef8a --- /dev/null +++ b/src/File/FileExcluderRawFactory.php @@ -0,0 +1,16 @@ +fileHelper->normalizePath($path); + } elseif (!file_exists($path)) { + throw new PathNotFoundException($path); + } else { + $finder = new Finder(); + $finder->followLinks(); + foreach ($finder->files()->name('*.{' . implode(',', $this->fileExtensions) . '}')->in($path) as $fileInfo) { + $files[] = $this->fileHelper->normalizePath($fileInfo->getPathname()); + $onlyFiles = false; + } + } + } + + $files = array_values(array_unique(array_filter($files, fn (string $file): bool => !$this->fileExcluder->isExcludedFromAnalysing($file)))); + + return new FileFinderResult($files, $onlyFiles); + } + +} diff --git a/src/File/FileFinderResult.php b/src/File/FileFinderResult.php new file mode 100644 index 00000000..30f4f389 --- /dev/null +++ b/src/File/FileFinderResult.php @@ -0,0 +1,29 @@ +files; + } + + public function isOnlyFiles(): bool + { + return $this->onlyFiles; + } + +} diff --git a/src/File/FileHelper.php b/src/File/FileHelper.php new file mode 100644 index 00000000..8209635a --- /dev/null +++ b/src/File/FileHelper.php @@ -0,0 +1,103 @@ +workingDirectory = $this->normalizePath($workingDirectory); + } + + public function getWorkingDirectory(): string + { + return $this->workingDirectory; + } + + /** @api */ + public function absolutizePath(string $path): string + { + if (DIRECTORY_SEPARATOR === '/') { + if (str_starts_with($path, '/')) { + return $path; + } + } elseif (substr($path, 1, 1) === ':') { + return $path; + } + + if (preg_match('~^[a-z0-9+\-.]+://~i', $path) === 1) { + return $path; + } + + return rtrim($this->getWorkingDirectory(), '/\\') . DIRECTORY_SEPARATOR . ltrim($path, '/\\'); + } + + /** @api */ + public function normalizePath(string $originalPath, string $directorySeparator = DIRECTORY_SEPARATOR): string + { + $isLocalPath = false; + if ($originalPath !== '') { + if ($originalPath[0] === '/') { + $isLocalPath = true; + } elseif (strlen($originalPath) >= 3 && $originalPath[1] === ':' && $originalPath[2] === '\\') { // e.g. C:\ + $isLocalPath = true; + } + } + + $matches = null; + if (!$isLocalPath) { + $matches = Strings::match($originalPath, '~^([a-z0-9+\-.]+)://(.+)$~is'); + } + + if ($matches !== null) { + [, $scheme, $path] = $matches; + $scheme = strtolower($scheme); + } else { + $scheme = null; + $path = $originalPath; + } + + $path = str_replace(['\\', '//', '///', '////'], '/', $path); + + $pathRoot = str_starts_with($path, '/') ? $directorySeparator : ''; + $pathParts = explode('/', trim($path, '/')); + + $normalizedPathParts = []; + foreach ($pathParts as $pathPart) { + if ($pathPart === '.') { + continue; + } + if ($pathPart === '..') { + $removedPart = array_pop($normalizedPathParts); + if ($scheme === 'phar' && $removedPart !== null && str_ends_with($removedPart, '.phar')) { + $scheme = null; + } + } else { + $normalizedPathParts[] = $pathPart; + } + } + + return ($scheme !== null ? $scheme . '://' : '') . $pathRoot . implode($directorySeparator, $normalizedPathParts); + } + +} diff --git a/src/File/FileMonitor.php b/src/File/FileMonitor.php new file mode 100644 index 00000000..1cf2a208 --- /dev/null +++ b/src/File/FileMonitor.php @@ -0,0 +1,94 @@ +|null */ + private ?array $fileHashes = null; + + /** @var array|null */ + private ?array $paths = null; + + public function __construct(private FileFinder $fileFinder) + { + } + + /** + * @param array $paths + */ + public function initialize(array $paths): void + { + $finderResult = $this->fileFinder->findFiles($paths); + $fileHashes = []; + foreach ($finderResult->getFiles() as $filePath) { + $fileHashes[$filePath] = $this->getFileHash($filePath); + } + + $this->fileHashes = $fileHashes; + $this->paths = $paths; + } + + public function getChanges(): FileMonitorResult + { + if ($this->fileHashes === null || $this->paths === null) { + throw new ShouldNotHappenException(); + } + $finderResult = $this->fileFinder->findFiles($this->paths); + $oldFileHashes = $this->fileHashes; + $fileHashes = []; + $newFiles = []; + $changedFiles = []; + $deletedFiles = []; + foreach ($finderResult->getFiles() as $filePath) { + if (!array_key_exists($filePath, $oldFileHashes)) { + $newFiles[] = $filePath; + $fileHashes[$filePath] = $this->getFileHash($filePath); + continue; + } + + $oldHash = $oldFileHashes[$filePath]; + unset($oldFileHashes[$filePath]); + $newHash = $this->getFileHash($filePath); + $fileHashes[$filePath] = $newHash; + if ($oldHash === $newHash) { + continue; + } + + $changedFiles[] = $filePath; + } + + $this->fileHashes = $fileHashes; + + foreach (array_keys($oldFileHashes) as $file) { + $deletedFiles[] = $file; + } + + return new FileMonitorResult( + $newFiles, + $changedFiles, + $deletedFiles, + count($fileHashes), + ); + } + + private function getFileHash(string $filePath): string + { + $hash = sha1_file($filePath); + + if ($hash === false) { + throw new CouldNotReadFileException($filePath); + } + + return $hash; + } + +} diff --git a/src/File/FileMonitorResult.php b/src/File/FileMonitorResult.php new file mode 100644 index 00000000..677899f7 --- /dev/null +++ b/src/File/FileMonitorResult.php @@ -0,0 +1,45 @@ +changedFiles; + } + + public function hasAnyChanges(): bool + { + return count($this->newFiles) > 0 + || count($this->changedFiles) > 0 + || count($this->deletedFiles) > 0; + } + + public function getTotalFilesCount(): int + { + return $this->totalFilesCount; + } + +} diff --git a/src/File/FileReader.php b/src/File/FileReader.php new file mode 100644 index 00000000..029a1587 --- /dev/null +++ b/src/File/FileReader.php @@ -0,0 +1,37 @@ +directorySeparator = $directorySeparator; + $pathBeginning = null; + $pathToTrimArray = null; + $trimBeginning = static function (string $path): array { + if (str_starts_with($path, '/')) { + return [ + '/', + substr($path, 1), + ]; + } elseif (substr($path, 1, 1) === ':') { + return [ + substr($path, 0, 3), + substr($path, 3), + ]; + } + + return ['', $path]; + }; + + if ( + !in_array($currentWorkingDirectory, ['', '/'], true) + && !(strlen($currentWorkingDirectory) === 3 && substr($currentWorkingDirectory, 1, 1) === ':') + ) { + [$pathBeginning, $currentWorkingDirectory] = $trimBeginning($currentWorkingDirectory); + + $pathToTrimArray = explode($directorySeparator, $currentWorkingDirectory); + } + foreach ($analysedPaths as $pathNumber => $path) { + [$tempPathBeginning, $path] = $trimBeginning($path); + + $pathArray = explode($directorySeparator, $path); + $pathTempParts = []; + $pathArraySize = count($pathArray); + foreach ($pathArray as $i => $pathPart) { + if ($i === $pathArraySize - 1 && str_ends_with($pathPart, '.php')) { + continue; + } + if (!isset($pathToTrimArray[$i])) { + if ($pathNumber !== 0) { + $pathToTrimArray = $pathTempParts; + continue 2; + } + } elseif ($pathToTrimArray[$i] !== $pathPart) { + $pathToTrimArray = $pathTempParts; + continue 2; + } + + $pathTempParts[] = $pathPart; + } + + $pathBeginning = $tempPathBeginning; + $pathToTrimArray = $pathTempParts; + } + + if ($pathToTrimArray === null || count($pathToTrimArray) === 0) { + return; + } + + $pathToTrim = $pathBeginning . implode($directorySeparator, $pathToTrimArray); + $realPathToTrim = realpath($pathToTrim); + if ($realPathToTrim !== false) { + $pathToTrim = $realPathToTrim; + } + + $this->pathToTrim = $pathToTrim; + } + + public function getRelativePath(string $filename): string + { + if ( + $this->pathToTrim !== null + && str_starts_with($filename, $this->pathToTrim) + ) { + return ltrim(substr($filename, strlen($this->pathToTrim)), $this->directorySeparator); + } + + return $this->fallbackRelativePathHelper->getRelativePath($filename); + } + +} diff --git a/src/File/NullRelativePathHelper.php b/src/File/NullRelativePathHelper.php new file mode 100644 index 00000000..9429f118 --- /dev/null +++ b/src/File/NullRelativePathHelper.php @@ -0,0 +1,14 @@ +getFilenameParts($filename)); + } + + /** + * @return string[] + */ + public function getFilenameParts(string $filename): array + { + $schemePosition = strpos($filename, '://'); + if ($schemePosition !== false) { + $filename = substr($filename, $schemePosition + 3); + } + $parentParts = explode('/', trim(str_replace('\\', '/', $this->parentDirectory), '/')); + $parentPartsCount = count($parentParts); + $filenameParts = explode('/', trim(str_replace('\\', '/', $filename), '/')); + $filenamePartsCount = count($filenameParts); + + $i = 0; + for (; $i < $filenamePartsCount; $i++) { + if ($parentPartsCount < $i + 1) { + break; + } + + $parentPath = implode('/', array_slice($parentParts, 0, $i + 1)); + $filenamePath = implode('/', array_slice($filenameParts, 0, $i + 1)); + + if ($parentPath !== $filenamePath) { + break; + } + } + + if ($i === 0) { + return [$filename]; + } + + $dotsCount = $parentPartsCount - $i; + + if ($dotsCount < 0) { + throw new ShouldNotHappenException(); + } + + return array_merge(array_fill(0, $dotsCount, '..'), array_slice($filenameParts, $i)); + } + +} diff --git a/src/File/PathNotFoundException.php b/src/File/PathNotFoundException.php new file mode 100644 index 00000000..fd30f95a --- /dev/null +++ b/src/File/PathNotFoundException.php @@ -0,0 +1,22 @@ +path; + } + +} diff --git a/src/File/RelativePathHelper.php b/src/File/RelativePathHelper.php new file mode 100644 index 00000000..3bcf7cdb --- /dev/null +++ b/src/File/RelativePathHelper.php @@ -0,0 +1,12 @@ +currentWorkingDirectory !== '' && str_starts_with($filename, $this->currentWorkingDirectory)) { + return str_replace('\\', '/', substr($filename, strlen($this->currentWorkingDirectory) + 1)); + } + + return str_replace('\\', '/', $filename); + } + +} diff --git a/src/File/SystemAgnosticSimpleRelativePathHelper.php b/src/File/SystemAgnosticSimpleRelativePathHelper.php new file mode 100644 index 00000000..b0f312e8 --- /dev/null +++ b/src/File/SystemAgnosticSimpleRelativePathHelper.php @@ -0,0 +1,27 @@ +fileHelper->getWorkingDirectory(); + if ($cwd !== '' && str_starts_with($filename, $cwd)) { + return substr($filename, strlen($cwd) + 1); + } + + return $filename; + } + +} diff --git a/src/Internal/BytesHelper.php b/src/Internal/BytesHelper.php new file mode 100644 index 00000000..a6847ad2 --- /dev/null +++ b/src/Internal/BytesHelper.php @@ -0,0 +1,32 @@ + $arrays + * @return iterable + */ + public static function combinations(array $arrays): iterable + { + // from https://stackoverflow.com/a/70800936/565782 by Arnaud Le Blanc + if ($arrays === []) { + yield []; + return; + } + + $head = array_shift($arrays); + + foreach ($head as $elem) { + foreach (self::combinations($arrays) as $combination) { + $comb = [$elem]; + foreach ($combination as $c) { + $comb[] = $c; + } + yield $comb; + } + } + } + +} diff --git a/src/Internal/ComposerHelper.php b/src/Internal/ComposerHelper.php new file mode 100644 index 00000000..f512ef45 --- /dev/null +++ b/src/Internal/ComposerHelper.php @@ -0,0 +1,92 @@ +|null */ + public static function getComposerConfig(string $root): ?array + { + $composerJsonPath = self::getComposerJsonPath($root); + + if (!is_file($composerJsonPath)) { + return null; + } + + try { + $composerJsonContents = FileReader::read($composerJsonPath); + + return Json::decode($composerJsonContents, Json::FORCE_ARRAY); + } catch (CouldNotReadFileException | JsonException) { + return null; + } + } + + private static function getComposerJsonPath(string $root): string + { + $envComposer = getenv('COMPOSER'); + $fileName = is_string($envComposer) ? $envComposer : 'composer.json'; + $fileName = basename(trim($fileName)); + + return $root . '/' . $fileName; + } + + /** + * @param array $composerConfig + */ + public static function getVendorDirFromComposerConfig(string $root, array $composerConfig): string + { + $vendorDirectory = $composerConfig['config']['vendor-dir'] ?? 'vendor'; + + return $root . '/' . trim($vendorDirectory, '/'); + } + + /** + * @param array $composerConfig + */ + public static function getBinDirFromComposerConfig(string $root, array $composerConfig): string + { + $vendorDirectory = $composerConfig['config']['bin-dir'] ?? 'vendor/bin'; + + return $root . '/' . trim($vendorDirectory, '/'); + } + + public static function getPhpStanVersion(): string + { + if (self::$phpstanVersion !== null) { + return self::$phpstanVersion; + } + + $installed = require __DIR__ . '/../../vendor/composer/installed.php'; + $rootPackage = $installed['root'] ?? null; + if ($rootPackage === null) { + return self::$phpstanVersion = self::UNKNOWN_VERSION; + } + + if (preg_match('/[^v\d.]/', $rootPackage['pretty_version']) === 0) { + // Handles tagged versions, see https://github.com/Jean85/pretty-package-versions/blob/2.0.5/src/Version.php#L31 + return self::$phpstanVersion = $rootPackage['pretty_version']; + } + + return self::$phpstanVersion = $rootPackage['pretty_version'] . '@' . substr((string) $rootPackage['reference'], 0, 7); + } + +} diff --git a/src/Internal/DeprecatedAttributeHelper.php b/src/Internal/DeprecatedAttributeHelper.php new file mode 100644 index 00000000..d74ab74a --- /dev/null +++ b/src/Internal/DeprecatedAttributeHelper.php @@ -0,0 +1,46 @@ + $attributes + */ + public static function getDeprecatedDescription(array $attributes): ?string + { + $deprecated = ReflectionAttributeHelper::filterAttributesByName($attributes, 'Deprecated'); + foreach ($deprecated as $attr) { + $arguments = $attr->getArguments(); + foreach ($arguments as $i => $arg) { + if (!is_string($arg)) { + continue; + } + + if (is_int($i)) { + if ($i !== 0) { + continue; + } + + return $arg; + } + + if ($i !== 'message') { + continue; + } + + return $arg; + } + } + + return null; + } + +} diff --git a/src/Internal/DirectoryCreator.php b/src/Internal/DirectoryCreator.php new file mode 100644 index 00000000..2cf1543a --- /dev/null +++ b/src/Internal/DirectoryCreator.php @@ -0,0 +1,37 @@ +getSubNodeNames() as $subNodeName) { + $subNodes[$subNodeName] = $node->$subNodeName; + } + + return new AnonymousClassNode( + $node->name, + $subNodes, + $node->getAttributes(), + ); + } + + public function isAnonymous(): bool + { + return true; + } + +} diff --git a/src/Node/BooleanAndNode.php b/src/Node/BooleanAndNode.php new file mode 100644 index 00000000..4256bec8 --- /dev/null +++ b/src/Node/BooleanAndNode.php @@ -0,0 +1,48 @@ +getAttributes()); + } + + /** + * @return BooleanAnd|LogicalAnd + */ + public function getOriginalNode() + { + return $this->originalNode; + } + + public function getRightScope(): Scope + { + return $this->rightScope; + } + + public function getType(): string + { + return 'PHPStan_Node_BooleanAndNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/BooleanOrNode.php b/src/Node/BooleanOrNode.php new file mode 100644 index 00000000..f1cc8397 --- /dev/null +++ b/src/Node/BooleanOrNode.php @@ -0,0 +1,48 @@ +getAttributes()); + } + + /** + * @return BooleanOr|LogicalOr + */ + public function getOriginalNode() + { + return $this->originalNode; + } + + public function getRightScope(): Scope + { + return $this->rightScope; + } + + public function getType(): string + { + return 'PHPStan_Node_BooleanOrNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/BreaklessWhileLoopNode.php b/src/Node/BreaklessWhileLoopNode.php new file mode 100644 index 00000000..0189011f --- /dev/null +++ b/src/Node/BreaklessWhileLoopNode.php @@ -0,0 +1,50 @@ +getAttributes()); + } + + public function getOriginalNode(): While_ + { + return $this->originalNode; + } + + /** + * @return StatementExitPoint[] + */ + public function getExitPoints(): array + { + return $this->exitPoints; + } + + public function getType(): string + { + return 'PHPStan_Node_BreaklessWhileLoop'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/CatchWithUnthrownExceptionNode.php b/src/Node/CatchWithUnthrownExceptionNode.php new file mode 100644 index 00000000..61db9576 --- /dev/null +++ b/src/Node/CatchWithUnthrownExceptionNode.php @@ -0,0 +1,49 @@ +getAttributes()); + } + + public function getOriginalNode(): Catch_ + { + return $this->originalNode; + } + + public function getCaughtType(): Type + { + return $this->caughtType; + } + + public function getOriginalCaughtType(): Type + { + return $this->originalCaughtType; + } + + public function getType(): string + { + return 'PHPStan_Node_CatchWithUnthrownExceptionNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/ClassConstantsNode.php b/src/Node/ClassConstantsNode.php new file mode 100644 index 00000000..6a492187 --- /dev/null +++ b/src/Node/ClassConstantsNode.php @@ -0,0 +1,66 @@ +getAttributes()); + } + + public function getClass(): ClassLike + { + return $this->class; + } + + /** + * @return ClassConst[] + */ + public function getConstants(): array + { + return $this->constants; + } + + /** + * @return ClassConstantFetch[] + */ + public function getFetches(): array + { + return $this->fetches; + } + + public function getType(): string + { + return 'PHPStan_Node_ClassConstantsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + +} diff --git a/src/Node/ClassMethod.php b/src/Node/ClassMethod.php new file mode 100644 index 00000000..68eb377f --- /dev/null +++ b/src/Node/ClassMethod.php @@ -0,0 +1,29 @@ +node; + } + + public function isDeclaredInTrait(): bool + { + return $this->isDeclaredInTrait; + } + +} diff --git a/src/Node/ClassMethodsNode.php b/src/Node/ClassMethodsNode.php new file mode 100644 index 00000000..5830b89c --- /dev/null +++ b/src/Node/ClassMethodsNode.php @@ -0,0 +1,65 @@ + $methodCalls + */ + public function __construct(private ClassLike $class, private array $methods, private array $methodCalls, private ClassReflection $classReflection) + { + parent::__construct($class->getAttributes()); + } + + public function getClass(): ClassLike + { + return $this->class; + } + + /** + * @return ClassMethod[] + */ + public function getMethods(): array + { + return $this->methods; + } + + /** + * @return array + */ + public function getMethodCalls(): array + { + return $this->methodCalls; + } + + public function getType(): string + { + return 'PHPStan_Node_ClassMethodsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + +} diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php new file mode 100644 index 00000000..9abd7ca9 --- /dev/null +++ b/src/Node/ClassPropertiesNode.php @@ -0,0 +1,416 @@ + $propertyUsages + * @param array $methodCalls + * @param array $returnStatementNodes + * @param list $propertyAssigns + */ + public function __construct( + private ClassLike $class, + private ReadWritePropertiesExtensionProvider $readWritePropertiesExtensionProvider, + private array $properties, + private array $propertyUsages, + private array $methodCalls, + private array $returnStatementNodes, + private array $propertyAssigns, + private ClassReflection $classReflection, + ) + { + parent::__construct($class->getAttributes()); + } + + public function getClass(): ClassLike + { + return $this->class; + } + + /** + * @return ClassPropertyNode[] + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * @return array + */ + public function getPropertyUsages(): array + { + return $this->propertyUsages; + } + + public function getType(): string + { + return 'PHPStan_Node_ClassPropertiesNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + /** + * @param string[] $constructors + * @return array{array, array, array} + */ + public function getUninitializedProperties( + Scope $scope, + array $constructors, + ): array + { + if (!$this->getClass() instanceof Class_) { + return [[], [], []]; + } + $classReflection = $this->getClassReflection(); + + $uninitializedProperties = []; + $originalProperties = []; + $initialInitializedProperties = []; + $initializedProperties = []; + $extensions = $this->readWritePropertiesExtensionProvider->getExtensions(); + $initializedViaExtension = []; + foreach ($this->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + if ($property->isAbstract()) { + continue; + } + if ($property->getNativeType() === null) { + continue; + } + if ($property->getDefault() !== null) { + continue; + } + $originalProperties[$property->getName()] = $property; + $is = TrinaryLogic::createFromBoolean($property->isPromoted() && !$property->isPromotedFromTrait()); + if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { + $propertyReflection = $classReflection->getNativeProperty($property->getName()); + if ($propertyReflection->isVirtual()->yes()) { + continue; + } + + foreach ($extensions as $extension) { + if (!$extension->isInitialized($propertyReflection, $property->getName())) { + continue; + } + $is = TrinaryLogic::createYes(); + $initializedViaExtension[$property->getName()] = true; + break; + } + } + $initialInitializedProperties[$property->getName()] = $is; + foreach ($constructors as $constructor) { + $initializedProperties[$constructor][$property->getName()] = $is; + } + if ($is->yes()) { + continue; + } + $uninitializedProperties[$property->getName()] = $property; + } + + if ($constructors === []) { + return [$uninitializedProperties, [], []]; + } + + $initializedInConstructor = []; + if ($classReflection->hasConstructor()) { + $initializedInConstructor = array_diff_key($uninitializedProperties, $this->collectUninitializedProperties([$classReflection->getConstructor()->getName()], $uninitializedProperties)); + } + + $methodsCalledFromConstructor = $this->getMethodsCalledFromConstructor($classReflection, $initialInitializedProperties, $initializedProperties, $constructors, $initializedInConstructor); + $prematureAccess = []; + $additionalAssigns = []; + + foreach ($this->getPropertyUsages() as $usage) { + $fetch = $usage->getFetch(); + if (!$fetch instanceof PropertyFetch) { + continue; + } + $usageScope = $usage->getScope(); + if ($usageScope->getFunction() === null) { + continue; + } + $function = $usageScope->getFunction(); + if (!$function instanceof MethodReflection) { + continue; + } + if ($function->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + if (!array_key_exists($function->getName(), $methodsCalledFromConstructor)) { + continue; + } + + $initializedPropertiesMap = $methodsCalledFromConstructor[$function->getName()]; + + if (!$fetch->name instanceof Identifier) { + continue; + } + $propertyName = $fetch->name->toString(); + $fetchedOnType = $usageScope->getType($fetch->var); + if (TypeUtils::findThisType($fetchedOnType) === null) { + continue; + } + + $propertyReflection = $usageScope->getPropertyReflection($fetchedOnType, $propertyName); + if ($propertyReflection === null) { + continue; + } + if ($propertyReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + + if ($usage instanceof PropertyWrite) { + if (array_key_exists($propertyName, $initializedPropertiesMap)) { + $hasInitialization = $initializedPropertiesMap[$propertyName]->or($usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName))); + if ( + !$hasInitialization->no() + && !$usage->isPromotedPropertyWrite() + && !array_key_exists($propertyName, $initializedViaExtension) + ) { + $additionalAssigns[] = [ + $propertyName, + $fetch->getStartLine(), + $originalProperties[$propertyName], + ]; + } + } + } elseif (array_key_exists($propertyName, $initializedPropertiesMap)) { + if ( + strtolower($function->getName()) !== '__construct' + && array_key_exists($propertyName, $initializedInConstructor) + && in_array($function->getName(), $constructors, true) + ) { + continue; + } + $hasInitialization = $initializedPropertiesMap[$propertyName]->or($usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName))); + if (!$hasInitialization->yes() && $usageScope->isInAnonymousFunction() && $usageScope->getParentScope() !== null) { + $hasInitialization = $hasInitialization->or($usageScope->getParentScope()->hasExpressionType(new PropertyInitializationExpr($propertyName))); + } + if (!$hasInitialization->yes()) { + $prematureAccess[] = [ + $propertyName, + $fetch->getStartLine(), + $originalProperties[$propertyName], + $usageScope->getFile(), + $usageScope->getFileDescription(), + ]; + } + } + } + + return [ + $this->collectUninitializedProperties(array_keys($methodsCalledFromConstructor), $uninitializedProperties), + $prematureAccess, + $additionalAssigns, + ]; + } + + /** + * @param list $constructors + * @param array $uninitializedProperties + * @return array + */ + private function collectUninitializedProperties(array $constructors, array $uninitializedProperties): array + { + foreach ($constructors as $constructor) { + $lowerConstructorName = strtolower($constructor); + if (!array_key_exists($lowerConstructorName, $this->returnStatementNodes)) { + continue; + } + + $returnStatementsNode = $this->returnStatementNodes[$lowerConstructorName]; + $methodScope = null; + foreach ($returnStatementsNode->getExecutionEnds() as $executionEnd) { + $statementResult = $executionEnd->getStatementResult(); + $endNode = $executionEnd->getNode(); + if ($statementResult->isAlwaysTerminating()) { + if ($endNode instanceof Node\Stmt\Expression) { + $exprType = $statementResult->getScope()->getType($endNode->expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + continue; + } + } + } + if ($methodScope === null) { + $methodScope = $statementResult->getScope(); + continue; + } + + $methodScope = $methodScope->mergeWith($statementResult->getScope()); + } + + foreach ($returnStatementsNode->getReturnStatements() as $returnStatement) { + if ($methodScope === null) { + $methodScope = $returnStatement->getScope(); + continue; + } + $methodScope = $methodScope->mergeWith($returnStatement->getScope()); + } + + if ($methodScope === null) { + continue; + } + + foreach (array_keys($uninitializedProperties) as $propertyName) { + if (!$methodScope->hasExpressionType(new PropertyInitializationExpr($propertyName))->yes()) { + continue; + } + + unset($uninitializedProperties[$propertyName]); + } + } + + return $uninitializedProperties; + } + + /** + * @param string[] $methods + * @param array $initialInitializedProperties + * @param array> $initializedProperties + * @param array $initializedInConstructorProperties + * + * @return array> + */ + private function getMethodsCalledFromConstructor( + ClassReflection $classReflection, + array $initialInitializedProperties, + array $initializedProperties, + array $methods, + array $initializedInConstructorProperties, + ): array + { + $originalMap = $initializedProperties; + $originalMethods = $methods; + + foreach ($this->methodCalls as $methodCall) { + $methodCallNode = $methodCall->getNode(); + if ($methodCallNode instanceof Array_) { + continue; + } + if (!$methodCallNode->name instanceof Identifier) { + continue; + } + $callScope = $methodCall->getScope(); + if ($methodCallNode instanceof Node\Expr\MethodCall) { + $calledOnType = $callScope->getType($methodCallNode->var); + } else { + if (!$methodCallNode->class instanceof Name) { + continue; + } + + $calledOnType = $callScope->resolveTypeByName($methodCallNode->class); + } + + if (TypeUtils::findThisType($calledOnType) === null) { + continue; + } + + $inMethod = $callScope->getFunction(); + if (!$inMethod instanceof MethodReflection) { + continue; + } + if (!in_array($inMethod->getName(), $methods, true)) { + continue; + } + + if ($inMethod->getName() !== '__construct') { + foreach ($initializedInConstructorProperties as $propertyName => $propertyNode) { + $initializedProperties[$inMethod->getName()][$propertyName] = TrinaryLogic::createYes(); + } + } + + $methodName = $methodCallNode->name->toString(); + if (array_key_exists($methodName, $initializedProperties)) { + foreach ($this->getInitializedProperties($callScope, $initializedProperties[$inMethod->getName()] ?? $initialInitializedProperties) as $propertyName => $isInitialized) { + $initializedProperties[$methodName][$propertyName] = $initializedProperties[$methodName][$propertyName]->and($isInitialized); + } + continue; + } + $methodReflection = $callScope->getMethodReflection($calledOnType, $methodName); + if ($methodReflection === null) { + continue; + } + if ($methodReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + $initializedProperties[$methodName] = $this->getInitializedProperties($callScope, $initializedProperties[$inMethod->getName()] ?? $initialInitializedProperties); + $methods[] = $methodName; + } + + if ($originalMap === $initializedProperties && $originalMethods === $methods) { + return $initializedProperties; + } + + return $this->getMethodsCalledFromConstructor($classReflection, $initialInitializedProperties, $initializedProperties, $methods, $initializedInConstructorProperties); + } + + /** + * @param array $initialInitializedProperties + * @return array + */ + private function getInitializedProperties(Scope $scope, array $initialInitializedProperties): array + { + foreach ($initialInitializedProperties as $propertyName => $isInitialized) { + $initialInitializedProperties[$propertyName] = $isInitialized->or($scope->hasExpressionType(new PropertyInitializationExpr($propertyName))); + } + + return $initialInitializedProperties; + } + + /** + * @return list + */ + public function getPropertyAssigns(): array + { + return $this->propertyAssigns; + } + +} diff --git a/src/Node/ClassPropertyNode.php b/src/Node/ClassPropertyNode.php new file mode 100644 index 00000000..589ca5ba --- /dev/null +++ b/src/Node/ClassPropertyNode.php @@ -0,0 +1,169 @@ +getAttributes()); + } + + public function getName(): string + { + return $this->name; + } + + public function getFlags(): int + { + return $this->flags; + } + + public function getDefault(): ?Expr + { + return $this->default; + } + + public function isPromoted(): bool + { + return $this->isPromoted; + } + + public function isPromotedFromTrait(): bool + { + return $this->isPromotedFromTrait; + } + + public function getPhpDoc(): ?string + { + return $this->phpDoc; + } + + public function getPhpDocType(): ?Type + { + return $this->phpDocType; + } + + public function isPublic(): bool + { + return ($this->flags & Modifiers::PUBLIC) !== 0 + || ($this->flags & Modifiers::VISIBILITY_MASK) === 0; + } + + public function isProtected(): bool + { + return (bool) ($this->flags & Modifiers::PROTECTED); + } + + public function isPrivate(): bool + { + return (bool) ($this->flags & Modifiers::PRIVATE); + } + + public function isStatic(): bool + { + return (bool) ($this->flags & Modifiers::STATIC); + } + + public function isReadOnly(): bool + { + return (bool) ($this->flags & Modifiers::READONLY) || $this->isReadonlyClass; + } + + public function isReadOnlyByPhpDoc(): bool + { + return $this->isReadonlyByPhpDoc; + } + + public function isDeclaredInTrait(): bool + { + return $this->isDeclaredInTrait; + } + + public function isAllowedPrivateMutation(): bool + { + return $this->isAllowedPrivateMutation; + } + + public function isAbstract(): bool + { + return (bool) ($this->flags & Modifiers::ABSTRACT); + } + + public function getNativeType(): ?Type + { + return $this->type; + } + + /** + * @return Node\Identifier|Node\Name|Node\ComplexType|null + */ + public function getNativeTypeNode() + { + return $this->originalNode->type; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getType(): string + { + return 'PHPStan_Node_ClassPropertyNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + public function hasHooks(): bool + { + return $this->getHooks() !== []; + } + + /** + * @return Node\PropertyHook[] + */ + public function getHooks(): array + { + return $this->originalNode->hooks; + } + + public function isVirtual(): bool + { + return $this->classReflection->getNativeProperty($this->name)->isVirtual()->yes(); + } + +} diff --git a/src/Node/ClassStatementsGatherer.php b/src/Node/ClassStatementsGatherer.php new file mode 100644 index 00000000..a7d282a0 --- /dev/null +++ b/src/Node/ClassStatementsGatherer.php @@ -0,0 +1,294 @@ + */ + private array $propertyUsages = []; + + /** @var Node\Stmt\ClassConst[] */ + private array $constants = []; + + /** @var ClassConstantFetch[] */ + private array $constantFetches = []; + + /** @var array */ + private array $returnStatementNodes = []; + + /** @var list */ + private array $propertyAssigns = []; + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function __construct( + private ClassReflection $classReflection, + callable $nodeCallback, + ) + { + $this->nodeCallback = $nodeCallback; + } + + /** + * @return ClassPropertyNode[] + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * @return ClassMethod[] + */ + public function getMethods(): array + { + return $this->methods; + } + + /** + * @return Method\MethodCall[] + */ + public function getMethodCalls(): array + { + return $this->methodCalls; + } + + /** + * @return array + */ + public function getPropertyUsages(): array + { + return $this->propertyUsages; + } + + /** + * @return Node\Stmt\ClassConst[] + */ + public function getConstants(): array + { + return $this->constants; + } + + /** + * @return ClassConstantFetch[] + */ + public function getConstantFetches(): array + { + return $this->constantFetches; + } + + /** + * @return array + */ + public function getReturnStatementsNodes(): array + { + return $this->returnStatementNodes; + } + + /** + * @return list + */ + public function getPropertyAssigns(): array + { + return $this->propertyAssigns; + } + + public function __invoke(Node $node, Scope $scope): void + { + $nodeCallback = $this->nodeCallback; + $nodeCallback($node, $scope); + $this->gatherNodes($node, $scope); + } + + private function gatherNodes(Node $node, Scope $scope): void + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + if ($scope->getClassReflection()->getName() !== $this->classReflection->getName()) { + return; + } + if ($node instanceof ClassPropertyNode) { + $this->properties[] = $node; + if ($node->isPromoted()) { + $this->propertyUsages[] = new PropertyWrite( + new PropertyFetch(new Expr\Variable('this'), new Identifier($node->getName())), + $scope, + true, + ); + } + return; + } + if ($node instanceof Node\Stmt\ClassMethod) { + $this->methods[] = new ClassMethod($node, $scope->isInTrait()); + return; + } + if ($node instanceof Node\Stmt\ClassConst) { + $this->constants[] = $node; + return; + } + if ($node instanceof MethodCall || $node instanceof StaticCall) { + $this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope); + if ($node instanceof StaticCall && $node->name instanceof Identifier && $node->name->toLowerString() === '__construct') { + $this->tryToApplyPropertyWritesFromAncestorConstructor($node, $scope); + } + return; + } + if ($node instanceof MethodCallableNode || $node instanceof StaticMethodCallableNode) { + $this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node->getOriginalNode(), $scope); + return; + } + if ($node instanceof MethodReturnStatementsNode) { + $this->returnStatementNodes[strtolower($node->getMethodName())] = $node; + return; + } + if ( + $node instanceof Expr\FuncCall + && $node->name instanceof Node\Name + && in_array($node->name->toLowerString(), self::PROPERTY_ENUMERATING_FUNCTIONS, true) + ) { + $this->tryToApplyPropertyReads($node, $scope); + return; + } + if ($node instanceof Array_ && count($node->items) === 2) { + $this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope); + return; + } + if ($node instanceof Expr\ClassConstFetch) { + $this->constantFetches[] = new ClassConstantFetch($node, $scope); + return; + } + if ($node instanceof PropertyAssignNode) { + $this->propertyUsages[] = new PropertyWrite($node->getPropertyFetch(), $scope, false); + $this->propertyAssigns[] = new PropertyAssign($node, $scope); + return; + } + if (!$node instanceof Expr) { + return; + } + if ($node instanceof Expr\AssignOp\Coalesce) { + $this->gatherNodes($node->var, $scope); + return; + } + if ($node instanceof Expr\AssignRef) { + if (!$node->expr instanceof PropertyFetch && !$node->expr instanceof StaticPropertyFetch) { + $this->gatherNodes($node->expr, $scope); + return; + } + + $this->propertyUsages[] = new PropertyRead($node->expr, $scope); + $this->propertyUsages[] = new PropertyWrite($node->expr, $scope, false); + return; + } + if ($node instanceof FunctionCallableNode) { + $node = $node->getOriginalNode(); + } elseif ($node instanceof InstantiationCallableNode) { + $node = $node->getOriginalNode(); + } + + $inAssign = $scope->isInExpressionAssign($node); + if ($inAssign) { + return; + } + + while ($node instanceof ArrayDimFetch) { + $node = $node->var; + } + if (!$node instanceof PropertyFetch && !$node instanceof StaticPropertyFetch) { + return; + } + + $this->propertyUsages[] = new PropertyRead($node, $scope); + } + + private function tryToApplyPropertyReads(Expr\FuncCall $node, Scope $scope): void + { + $args = $node->getArgs(); + if (count($args) === 0) { + return; + } + + $firstArgValue = $args[0]->value; + if (TypeUtils::findThisType($scope->getType($firstArgValue)) === null) { + return; + } + + $classProperties = $this->classReflection->getNativeReflection()->getProperties(); + foreach ($classProperties as $property) { + if ($property->isStatic()) { + continue; + } + $this->propertyUsages[] = new PropertyRead( + new PropertyFetch(new Expr\Variable('this'), new Identifier($property->getName())), + $scope, + ); + } + } + + private function tryToApplyPropertyWritesFromAncestorConstructor(StaticCall $ancestorConstructorCall, Scope $scope): void + { + if (!$ancestorConstructorCall->class instanceof Node\Name) { + return; + } + + $calledOnType = $scope->resolveTypeByName($ancestorConstructorCall->class); + if ($calledOnType->getClassReflection() === null || TypeUtils::findThisType($calledOnType) === null) { + return; + } + + $classReflection = $calledOnType->getClassReflection()->getNativeReflection(); + foreach ($classReflection->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED) as $property) { + if (!$property->isPromoted() || $property->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + $this->propertyUsages[] = new PropertyWrite( + new PropertyFetch(new Expr\Variable('this'), new Identifier($property->getName()), $ancestorConstructorCall->getAttributes()), + $scope, + false, + ); + } + } + +} diff --git a/src/Node/ClosureReturnStatementsNode.php b/src/Node/ClosureReturnStatementsNode.php new file mode 100644 index 00000000..2bc314c0 --- /dev/null +++ b/src/Node/ClosureReturnStatementsNode.php @@ -0,0 +1,100 @@ + $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $impurePoints + */ + public function __construct( + Closure $closureExpr, + private array $returnStatements, + private array $yieldStatements, + private StatementResult $statementResult, + private array $executionEnds, + private array $impurePoints, + ) + { + parent::__construct($closureExpr->getAttributes()); + $this->closureExpr = $closureExpr; + } + + public function getClosureExpr(): Closure + { + return $this->closureExpr; + } + + public function hasNativeReturnTypehint(): bool + { + return $this->closureExpr->returnType !== null; + } + + public function getReturnStatements(): array + { + return $this->returnStatements; + } + + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getYieldStatements(): array + { + return $this->yieldStatements; + } + + public function isGenerator(): bool + { + return count($this->yieldStatements) > 0; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function returnsByRef(): bool + { + return $this->closureExpr->byRef; + } + + public function getType(): string + { + return 'PHPStan_Node_ClosureReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/CollectedDataNode.php b/src/Node/CollectedDataNode.php new file mode 100644 index 00000000..eabaa414 --- /dev/null +++ b/src/Node/CollectedDataNode.php @@ -0,0 +1,74 @@ + + * @template TValue + * @param class-string $collectorType + * @return array> + */ + public function get(string $collectorType): array + { + $result = []; + foreach ($this->collectedData as $collectedData) { + if ($collectedData->getCollectorType() !== $collectorType) { + continue; + } + + $filePath = $collectedData->getFilePath(); + if (!array_key_exists($filePath, $result)) { + $result[$filePath] = []; + } + + $result[$filePath][] = $collectedData->getData(); + } + + return $result; + } + + /** + * Indicates that only files were passed to the analyser, not directory paths. + * + * True being returned strongly suggests that it's a partial analysis, not full project analysis. + */ + public function isOnlyFilesAnalysis(): bool + { + return $this->onlyFiles; + } + + public function getType(): string + { + return 'PHPStan_Node_CollectedDataNode'; + } + + /** + * @return array{} + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Constant/ClassConstantFetch.php b/src/Node/Constant/ClassConstantFetch.php new file mode 100644 index 00000000..42b5870c --- /dev/null +++ b/src/Node/Constant/ClassConstantFetch.php @@ -0,0 +1,29 @@ +node; + } + + public function getScope(): Scope + { + return $this->scope; + } + +} diff --git a/src/Node/DoWhileLoopConditionNode.php b/src/Node/DoWhileLoopConditionNode.php new file mode 100644 index 00000000..de6ae5c4 --- /dev/null +++ b/src/Node/DoWhileLoopConditionNode.php @@ -0,0 +1,47 @@ +getAttributes()); + } + + public function getCond(): Expr + { + return $this->cond; + } + + /** + * @return StatementExitPoint[] + */ + public function getExitPoints(): array + { + return $this->exitPoints; + } + + public function getType(): string + { + return 'PHPStan_Node_ClosureReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/ExecutionEndNode.php b/src/Node/ExecutionEndNode.php new file mode 100644 index 00000000..6919842b --- /dev/null +++ b/src/Node/ExecutionEndNode.php @@ -0,0 +1,53 @@ +getAttributes()); + } + + public function getNode(): Node\Stmt + { + return $this->node; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function hasNativeReturnTypehint(): bool + { + return $this->hasNativeReturnTypehint; + } + + public function getType(): string + { + return 'PHPStan_Node_ExecutionEndNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/AlwaysRememberedExpr.php b/src/Node/Expr/AlwaysRememberedExpr.php new file mode 100644 index 00000000..a0079472 --- /dev/null +++ b/src/Node/Expr/AlwaysRememberedExpr.php @@ -0,0 +1,46 @@ +expr; + } + + public function getExprType(): Type + { + return $this->type; + } + + public function getNativeExprType(): Type + { + return $this->nativeType; + } + + public function getType(): string + { + return 'PHPStan_Node_AlwaysRememberedExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return ['expr']; + } + +} diff --git a/src/Node/Expr/ExistingArrayDimFetch.php b/src/Node/Expr/ExistingArrayDimFetch.php new file mode 100644 index 00000000..015091b5 --- /dev/null +++ b/src/Node/Expr/ExistingArrayDimFetch.php @@ -0,0 +1,40 @@ +var; + } + + public function getDim(): Expr + { + return $this->dim; + } + + public function getType(): string + { + return 'PHPStan_Node_ExistingArrayDimFetch'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/GetIterableKeyTypeExpr.php b/src/Node/Expr/GetIterableKeyTypeExpr.php new file mode 100644 index 00000000..140f1dd6 --- /dev/null +++ b/src/Node/Expr/GetIterableKeyTypeExpr.php @@ -0,0 +1,35 @@ +expr; + } + + public function getType(): string + { + return 'PHPStan_Node_GetIterableKeyTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/GetIterableValueTypeExpr.php b/src/Node/Expr/GetIterableValueTypeExpr.php new file mode 100644 index 00000000..956ef698 --- /dev/null +++ b/src/Node/Expr/GetIterableValueTypeExpr.php @@ -0,0 +1,35 @@ +expr; + } + + public function getType(): string + { + return 'PHPStan_Node_GetIterableValueTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/GetOffsetValueTypeExpr.php b/src/Node/Expr/GetOffsetValueTypeExpr.php new file mode 100644 index 00000000..cea922da --- /dev/null +++ b/src/Node/Expr/GetOffsetValueTypeExpr.php @@ -0,0 +1,40 @@ +var; + } + + public function getDim(): Expr + { + return $this->dim; + } + + public function getType(): string + { + return 'PHPStan_Node_GetOffsetValueTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/OriginalPropertyTypeExpr.php b/src/Node/Expr/OriginalPropertyTypeExpr.php new file mode 100644 index 00000000..3ff0c66b --- /dev/null +++ b/src/Node/Expr/OriginalPropertyTypeExpr.php @@ -0,0 +1,35 @@ +propertyFetch; + } + + public function getType(): string + { + return 'PHPStan_Node_OriginalPropertyTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/ParameterVariableOriginalValueExpr.php b/src/Node/Expr/ParameterVariableOriginalValueExpr.php new file mode 100644 index 00000000..acf306fc --- /dev/null +++ b/src/Node/Expr/ParameterVariableOriginalValueExpr.php @@ -0,0 +1,35 @@ +variableName; + } + + public function getType(): string + { + return 'PHPStan_Node_ParameterVariableOriginalValueExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/PropertyInitializationExpr.php b/src/Node/Expr/PropertyInitializationExpr.php new file mode 100644 index 00000000..c114579f --- /dev/null +++ b/src/Node/Expr/PropertyInitializationExpr.php @@ -0,0 +1,35 @@ +propertyName; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyInitializationExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/SetExistingOffsetValueTypeExpr.php b/src/Node/Expr/SetExistingOffsetValueTypeExpr.php new file mode 100644 index 00000000..dfaadab4 --- /dev/null +++ b/src/Node/Expr/SetExistingOffsetValueTypeExpr.php @@ -0,0 +1,45 @@ +var; + } + + public function getDim(): Expr + { + return $this->dim; + } + + public function getValue(): Expr + { + return $this->value; + } + + public function getType(): string + { + return 'PHPStan_Node_SetExistingOffsetValueTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/SetOffsetValueTypeExpr.php b/src/Node/Expr/SetOffsetValueTypeExpr.php new file mode 100644 index 00000000..2d3db6b2 --- /dev/null +++ b/src/Node/Expr/SetOffsetValueTypeExpr.php @@ -0,0 +1,45 @@ +var; + } + + public function getDim(): ?Expr + { + return $this->dim; + } + + public function getValue(): Expr + { + return $this->value; + } + + public function getType(): string + { + return 'PHPStan_Node_SetOffsetValueTypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/TypeExpr.php b/src/Node/Expr/TypeExpr.php new file mode 100644 index 00000000..7043268e --- /dev/null +++ b/src/Node/Expr/TypeExpr.php @@ -0,0 +1,40 @@ +exprType; + } + + public function getType(): string + { + return 'PHPStan_Node_TypeExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Expr/UnsetOffsetExpr.php b/src/Node/Expr/UnsetOffsetExpr.php new file mode 100644 index 00000000..4b44250f --- /dev/null +++ b/src/Node/Expr/UnsetOffsetExpr.php @@ -0,0 +1,40 @@ +var; + } + + public function getDim(): Expr + { + return $this->dim; + } + + public function getType(): string + { + return 'PHPStan_Node_UnsetOffsetExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/FileNode.php b/src/Node/FileNode.php new file mode 100644 index 00000000..90749e10 --- /dev/null +++ b/src/Node/FileNode.php @@ -0,0 +1,45 @@ +getAttributes() : []); + } + + /** + * @return Node[] + */ + public function getNodes(): array + { + return $this->nodes; + } + + public function getType(): string + { + return 'PHPStan_Node_FileNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/FinallyExitPointsNode.php b/src/Node/FinallyExitPointsNode.php new file mode 100644 index 00000000..24683130 --- /dev/null +++ b/src/Node/FinallyExitPointsNode.php @@ -0,0 +1,53 @@ +finallyExitPoints; + } + + /** + * @return StatementExitPoint[] + */ + public function getTryCatchExitPoints(): array + { + return $this->tryCatchExitPoints; + } + + public function getType(): string + { + return 'PHPStan_Node_FinallyExitPointsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/FunctionCallableNode.php b/src/Node/FunctionCallableNode.php new file mode 100644 index 00000000..2abd88dc --- /dev/null +++ b/src/Node/FunctionCallableNode.php @@ -0,0 +1,46 @@ +originalNode->getAttributes()); + } + + /** + * @return Expr|Name + */ + public function getName() + { + return $this->name; + } + + public function getOriginalNode(): Expr\FuncCall + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_FunctionCallableNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/FunctionReturnStatementsNode.php b/src/Node/FunctionReturnStatementsNode.php new file mode 100644 index 00000000..4452efeb --- /dev/null +++ b/src/Node/FunctionReturnStatementsNode.php @@ -0,0 +1,107 @@ + $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $impurePoints + */ + public function __construct( + private Function_ $function, + private array $returnStatements, + private array $yieldStatements, + private StatementResult $statementResult, + private array $executionEnds, + private array $impurePoints, + private PhpFunctionFromParserNodeReflection $functionReflection, + ) + { + parent::__construct($function->getAttributes()); + } + + public function getReturnStatements(): array + { + return $this->returnStatements; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function returnsByRef(): bool + { + return $this->function->byRef; + } + + public function hasNativeReturnTypehint(): bool + { + return $this->function->returnType !== null; + } + + public function getYieldStatements(): array + { + return $this->yieldStatements; + } + + public function isGenerator(): bool + { + return count($this->yieldStatements) > 0; + } + + public function getType(): string + { + return 'PHPStan_Node_FunctionReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + public function getFunctionReflection(): PhpFunctionFromParserNodeReflection + { + return $this->functionReflection; + } + + /** + * @return Stmt[] + */ + public function getStatements(): array + { + return $this->function->getStmts(); + } + +} diff --git a/src/Node/InArrowFunctionNode.php b/src/Node/InArrowFunctionNode.php new file mode 100644 index 00000000..a0687dbf --- /dev/null +++ b/src/Node/InArrowFunctionNode.php @@ -0,0 +1,48 @@ +getAttributes()); + $this->originalNode = $originalNode; + } + + public function getClosureType(): ClosureType + { + return $this->closureType; + } + + public function getOriginalNode(): Node\Expr\ArrowFunction + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InArrowFunctionNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InClassMethodNode.php b/src/Node/InClassMethodNode.php new file mode 100644 index 00000000..892df887 --- /dev/null +++ b/src/Node/InClassMethodNode.php @@ -0,0 +1,53 @@ +getAttributes()); + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getMethodReflection(): PhpMethodFromParserNodeReflection + { + return $this->methodReflection; + } + + public function getOriginalNode(): Node\Stmt\ClassMethod + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Stmt_InClassMethodNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InClassNode.php b/src/Node/InClassNode.php new file mode 100644 index 00000000..a29396ae --- /dev/null +++ b/src/Node/InClassNode.php @@ -0,0 +1,44 @@ +getAttributes()); + } + + public function getOriginalNode(): ClassLike + { + return $this->originalNode; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getType(): string + { + return 'PHPStan_Stmt_InClassNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InClosureNode.php b/src/Node/InClosureNode.php new file mode 100644 index 00000000..c3adebcd --- /dev/null +++ b/src/Node/InClosureNode.php @@ -0,0 +1,48 @@ +getAttributes()); + $this->originalNode = $originalNode; + } + + public function getClosureType(): ClosureType + { + return $this->closureType; + } + + public function getOriginalNode(): Closure + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InClosureNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InForeachNode.php b/src/Node/InForeachNode.php new file mode 100644 index 00000000..9fa9e4b7 --- /dev/null +++ b/src/Node/InForeachNode.php @@ -0,0 +1,35 @@ +getAttributes()); + } + + public function getOriginalNode(): Foreach_ + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InForeachNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InFunctionNode.php b/src/Node/InFunctionNode.php new file mode 100644 index 00000000..77bfa724 --- /dev/null +++ b/src/Node/InFunctionNode.php @@ -0,0 +1,46 @@ +getAttributes()); + } + + public function getFunctionReflection(): PhpFunctionFromParserNodeReflection + { + return $this->functionReflection; + } + + public function getOriginalNode(): Node\Stmt\Function_ + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Stmt_InFunctionNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InPropertyHookNode.php b/src/Node/InPropertyHookNode.php new file mode 100644 index 00000000..249cde96 --- /dev/null +++ b/src/Node/InPropertyHookNode.php @@ -0,0 +1,61 @@ +getAttributes()); + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getHookReflection(): PhpMethodFromParserNodeReflection + { + return $this->hookReflection; + } + + public function getPropertyReflection(): PhpPropertyReflection + { + return $this->propertyReflection; + } + + public function getOriginalNode(): Node\PropertyHook + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InPropertyHookNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InTraitNode.php b/src/Node/InTraitNode.php new file mode 100644 index 00000000..63dd7207 --- /dev/null +++ b/src/Node/InTraitNode.php @@ -0,0 +1,48 @@ +getAttributes()); + } + + public function getOriginalNode(): Node\Stmt\Trait_ + { + return $this->originalNode; + } + + public function getTraitReflection(): ClassReflection + { + return $this->traitReflection; + } + + public function getImplementingClassReflection(): ClassReflection + { + return $this->implementingClassReflection; + } + + public function getType(): string + { + return 'PHPStan_Stmt_InTraitNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InstantiationCallableNode.php b/src/Node/InstantiationCallableNode.php new file mode 100644 index 00000000..b7f09d2b --- /dev/null +++ b/src/Node/InstantiationCallableNode.php @@ -0,0 +1,46 @@ +originalNode->getAttributes()); + } + + /** + * @return Expr|Name + */ + public function getClass() + { + return $this->class; + } + + public function getOriginalNode(): Expr\New_ + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InstantiationCallableNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/InvalidateExprNode.php b/src/Node/InvalidateExprNode.php new file mode 100644 index 00000000..d68d7070 --- /dev/null +++ b/src/Node/InvalidateExprNode.php @@ -0,0 +1,38 @@ +getAttributes()); + } + + public function getExpr(): Expr + { + return $this->expr; + } + + public function getType(): string + { + return 'PHPStan_Node_InvalidateExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/IssetExpr.php b/src/Node/IssetExpr.php new file mode 100644 index 00000000..5aca0fc8 --- /dev/null +++ b/src/Node/IssetExpr.php @@ -0,0 +1,42 @@ +expr; + } + + public function getType(): string + { + return 'PHPStan_Node_IssetExpr'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/LiteralArrayItem.php b/src/Node/LiteralArrayItem.php new file mode 100644 index 00000000..9919ddaa --- /dev/null +++ b/src/Node/LiteralArrayItem.php @@ -0,0 +1,29 @@ +scope; + } + + public function getArrayItem(): ?ArrayItem + { + return $this->arrayItem; + } + +} diff --git a/src/Node/LiteralArrayNode.php b/src/Node/LiteralArrayNode.php new file mode 100644 index 00000000..0b0463df --- /dev/null +++ b/src/Node/LiteralArrayNode.php @@ -0,0 +1,44 @@ +getAttributes()); + } + + /** + * @return LiteralArrayItem[] + */ + public function getItemNodes(): array + { + return $this->itemNodes; + } + + public function getType(): string + { + return 'PHPStan_Node_LiteralArray'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/MatchExpressionArm.php b/src/Node/MatchExpressionArm.php new file mode 100644 index 00000000..f92e0e20 --- /dev/null +++ b/src/Node/MatchExpressionArm.php @@ -0,0 +1,37 @@ +body; + } + + /** + * @return MatchExpressionArmCondition[] + */ + public function getConditions(): array + { + return $this->conditions; + } + + public function getLine(): int + { + return $this->line; + } + +} diff --git a/src/Node/MatchExpressionArmBody.php b/src/Node/MatchExpressionArmBody.php new file mode 100644 index 00000000..d9d6e6d3 --- /dev/null +++ b/src/Node/MatchExpressionArmBody.php @@ -0,0 +1,29 @@ +scope; + } + + public function getBody(): Expr + { + return $this->body; + } + +} diff --git a/src/Node/MatchExpressionArmCondition.php b/src/Node/MatchExpressionArmCondition.php new file mode 100644 index 00000000..d5ebeb0b --- /dev/null +++ b/src/Node/MatchExpressionArmCondition.php @@ -0,0 +1,34 @@ +condition; + } + + public function getScope(): Scope + { + return $this->scope; + } + + public function getLine(): int + { + return $this->line; + } + +} diff --git a/src/Node/MatchExpressionNode.php b/src/Node/MatchExpressionNode.php new file mode 100644 index 00000000..5437be19 --- /dev/null +++ b/src/Node/MatchExpressionNode.php @@ -0,0 +1,60 @@ +getAttributes()); + } + + public function getCondition(): Expr + { + return $this->condition; + } + + /** + * @return MatchExpressionArm[] + */ + public function getArms(): array + { + return $this->arms; + } + + public function getEndScope(): Scope + { + return $this->endScope; + } + + public function getType(): string + { + return 'PHPStan_Node_MatchExpression'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Method/MethodCall.php b/src/Node/Method/MethodCall.php new file mode 100644 index 00000000..8cc2b20e --- /dev/null +++ b/src/Node/Method/MethodCall.php @@ -0,0 +1,37 @@ +node; + } + + public function getScope(): Scope + { + return $this->scope; + } + +} diff --git a/src/Node/MethodCallableNode.php b/src/Node/MethodCallableNode.php new file mode 100644 index 00000000..b24f77e1 --- /dev/null +++ b/src/Node/MethodCallableNode.php @@ -0,0 +1,55 @@ +getAttributes()); + } + + public function getVar(): Expr + { + return $this->var; + } + + /** + * @return Expr|Identifier + */ + public function getName() + { + return $this->name; + } + + public function getOriginalNode(): Expr\MethodCall + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_MethodCallableNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/MethodReturnStatementsNode.php b/src/Node/MethodReturnStatementsNode.php new file mode 100644 index 00000000..52054634 --- /dev/null +++ b/src/Node/MethodReturnStatementsNode.php @@ -0,0 +1,127 @@ + $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $impurePoints + */ + public function __construct( + ClassMethod $method, + private array $returnStatements, + private array $yieldStatements, + private StatementResult $statementResult, + private array $executionEnds, + private array $impurePoints, + private ClassReflection $classReflection, + private PhpMethodFromParserNodeReflection $methodReflection, + ) + { + parent::__construct($method->getAttributes()); + $this->classMethod = $method; + } + + public function getReturnStatements(): array + { + return $this->returnStatements; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function returnsByRef(): bool + { + return $this->classMethod->byRef; + } + + public function hasNativeReturnTypehint(): bool + { + return $this->classMethod->returnType !== null; + } + + public function getMethodName(): string + { + return $this->classMethod->name->toString(); + } + + public function getYieldStatements(): array + { + return $this->yieldStatements; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getMethodReflection(): PhpMethodFromParserNodeReflection + { + return $this->methodReflection; + } + + /** + * @return Stmt[] + */ + public function getStatements(): array + { + $stmts = $this->classMethod->getStmts(); + if ($stmts === null) { + return []; + } + + return $stmts; + } + + public function isGenerator(): bool + { + return count($this->yieldStatements) > 0; + } + + public function getType(): string + { + return 'PHPStan_Node_MethodReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/NoopExpressionNode.php b/src/Node/NoopExpressionNode.php new file mode 100644 index 00000000..03ffbe23 --- /dev/null +++ b/src/Node/NoopExpressionNode.php @@ -0,0 +1,40 @@ +originalExpr->getAttributes()); + } + + public function getOriginalExpr(): Expr + { + return $this->originalExpr; + } + + public function hasAssign(): bool + { + return $this->hasAssign; + } + + public function getType(): string + { + return 'PHPStan_Node_NoopExpressionNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Printer/ExprPrinter.php b/src/Node/Printer/ExprPrinter.php new file mode 100644 index 00000000..5f704ebc --- /dev/null +++ b/src/Node/Printer/ExprPrinter.php @@ -0,0 +1,30 @@ +getAttribute('phpstan_cache_printer'); + if ($exprString === null) { + $exprString = $this->printer->prettyPrintExpr($expr); + $expr->setAttribute('phpstan_cache_printer', $exprString); + } + + return $exprString; + } + +} diff --git a/src/Node/Printer/NodeTypePrinter.php b/src/Node/Printer/NodeTypePrinter.php new file mode 100644 index 00000000..15f4513e --- /dev/null +++ b/src/Node/Printer/NodeTypePrinter.php @@ -0,0 +1,53 @@ +type); + } + + if ($type instanceof Node\UnionType) { + return implode('|', array_map(static function ($innerType): string { + $printedType = self::printType($innerType); + if ($printedType === null) { + throw new ShouldNotHappenException(); + } + + return $printedType; + }, $type->types)); + } + + if ($type instanceof Node\IntersectionType) { + return implode('&', array_map(static function ($innerType): string { + $printedType = self::printType($innerType); + if ($printedType === null) { + throw new ShouldNotHappenException(); + } + + return $printedType; + }, $type->types)); + } + + if ($type instanceof Node\Identifier || $type instanceof Node\Name) { + return $type->toString(); + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php new file mode 100644 index 00000000..ea75c07a --- /dev/null +++ b/src/Node/Printer/Printer.php @@ -0,0 +1,94 @@ +getExprType()->describe(VerbosityLevel::precise())); + } + + protected function pPHPStan_Node_GetOffsetValueTypeExpr(GetOffsetValueTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanGetOffsetValueType(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); + } + + protected function pPHPStan_Node_UnsetOffsetExpr(UnsetOffsetExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanUnsetOffset(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); + } + + protected function pPHPStan_Node_GetIterableValueTypeExpr(GetIterableValueTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanGetIterableValueType(%s)', $this->p($expr->getExpr())); + } + + protected function pPHPStan_Node_GetIterableKeyTypeExpr(GetIterableKeyTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanGetIterableKeyType(%s)', $this->p($expr->getExpr())); + } + + protected function pPHPStan_Node_ExistingArrayDimFetch(ExistingArrayDimFetch $expr): string // phpcs:ignore + { + return sprintf('__phpstanExistingArrayDimFetch(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); + } + + protected function pPHPStan_Node_OriginalPropertyTypeExpr(OriginalPropertyTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanOriginalPropertyType(%s)', $this->p($expr->getPropertyFetch())); + } + + protected function pPHPStan_Node_SetOffsetValueTypeExpr(SetOffsetValueTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanSetOffsetValueType(%s, %s, %s)', $this->p($expr->getVar()), $expr->getDim() !== null ? $this->p($expr->getDim()) : 'null', $this->p($expr->getValue())); + } + + protected function pPHPStan_Node_SetExistingOffsetValueTypeExpr(SetExistingOffsetValueTypeExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanSetExistingOffsetValueType(%s, %s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim()), $this->p($expr->getValue())); + } + + protected function pPHPStan_Node_AlwaysRememberedExpr(AlwaysRememberedExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanRembered(%s)', $this->p($expr->getExpr())); + } + + protected function pPHPStan_Node_PropertyInitializationExpr(PropertyInitializationExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanPropertyInitialization(%s)', $expr->getPropertyName()); + } + + protected function pPHPStan_Node_ParameterVariableOriginalValueExpr(ParameterVariableOriginalValueExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanParameterVariableOriginalValue(%s)', $expr->getVariableName()); + } + + protected function pPHPStan_Node_IssetExpr(IssetExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanIssetExpr(%s)', $this->p($expr->getExpr())); + } + +} diff --git a/src/Node/Property/PropertyAssign.php b/src/Node/Property/PropertyAssign.php new file mode 100644 index 00000000..0b886505 --- /dev/null +++ b/src/Node/Property/PropertyAssign.php @@ -0,0 +1,32 @@ +assign; + } + + public function getScope(): Scope + { + return $this->scope; + } + +} diff --git a/src/Node/Property/PropertyRead.php b/src/Node/Property/PropertyRead.php new file mode 100644 index 00000000..53d1ab87 --- /dev/null +++ b/src/Node/Property/PropertyRead.php @@ -0,0 +1,36 @@ +fetch; + } + + public function getScope(): Scope + { + return $this->scope; + } + +} diff --git a/src/Node/Property/PropertyWrite.php b/src/Node/Property/PropertyWrite.php new file mode 100644 index 00000000..9801b879 --- /dev/null +++ b/src/Node/Property/PropertyWrite.php @@ -0,0 +1,38 @@ +fetch; + } + + public function getScope(): Scope + { + return $this->scope; + } + + public function isPromotedPropertyWrite(): bool + { + return $this->promotedPropertyWrite; + } + +} diff --git a/src/Node/PropertyAssignNode.php b/src/Node/PropertyAssignNode.php new file mode 100644 index 00000000..c22ec213 --- /dev/null +++ b/src/Node/PropertyAssignNode.php @@ -0,0 +1,49 @@ +getAttributes()); + } + + public function getPropertyFetch(): Expr\PropertyFetch|Expr\StaticPropertyFetch + { + return $this->propertyFetch; + } + + public function getAssignedExpr(): Expr + { + return $this->assignedExpr; + } + + public function isAssignOp(): bool + { + return $this->assignOp; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyAssignNodeNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/PropertyHookReturnStatementsNode.php b/src/Node/PropertyHookReturnStatementsNode.php new file mode 100644 index 00000000..1e93a742 --- /dev/null +++ b/src/Node/PropertyHookReturnStatementsNode.php @@ -0,0 +1,112 @@ + $returnStatements + * @param list $executionEnds + * @param ImpurePoint[] $impurePoints + */ + public function __construct( + private PropertyHook $hook, + private array $returnStatements, + private StatementResult $statementResult, + private array $executionEnds, + private array $impurePoints, + private ClassReflection $classReflection, + private PhpMethodFromParserNodeReflection $hookReflection, + private PhpPropertyReflection $propertyReflection, + ) + { + parent::__construct($hook->getAttributes()); + } + + public function getPropertyHookNode(): PropertyHook + { + return $this->hook; + } + + public function returnsByRef(): bool + { + return $this->hook->byRef; + } + + public function hasNativeReturnTypehint(): bool + { + return false; + } + + public function getYieldStatements(): array + { + return []; + } + + public function isGenerator(): bool + { + return false; + } + + public function getReturnStatements(): array + { + return $this->returnStatements; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getHookReflection(): PhpMethodFromParserNodeReflection + { + return $this->hookReflection; + } + + public function getPropertyReflection(): PhpPropertyReflection + { + return $this->propertyReflection; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyHookReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/PropertyHookStatementNode.php b/src/Node/PropertyHookStatementNode.php new file mode 100644 index 00000000..2320cf4d --- /dev/null +++ b/src/Node/PropertyHookStatementNode.php @@ -0,0 +1,52 @@ +propertyHook->getAttributes()); + } + + public function getPropertyHook(): PropertyHook + { + return $this->propertyHook; + } + + /** + * @return null + */ + public function getReturnType() + { + return null; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyHookStatementNode'; + } + + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/ReturnStatement.php b/src/Node/ReturnStatement.php new file mode 100644 index 00000000..8bbaca73 --- /dev/null +++ b/src/Node/ReturnStatement.php @@ -0,0 +1,33 @@ +returnNode = $returnNode; + } + + public function getScope(): Scope + { + return $this->scope; + } + + public function getReturnNode(): Return_ + { + return $this->returnNode; + } + +} diff --git a/src/Node/ReturnStatementsNode.php b/src/Node/ReturnStatementsNode.php new file mode 100644 index 00000000..41aa89d8 --- /dev/null +++ b/src/Node/ReturnStatementsNode.php @@ -0,0 +1,43 @@ + + */ + public function getReturnStatements(): array; + + public function getStatementResult(): StatementResult; + + /** + * @return list + */ + public function getExecutionEnds(): array; + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array; + + public function returnsByRef(): bool; + + public function hasNativeReturnTypehint(): bool; + + /** + * @return list + */ + public function getYieldStatements(): array; + + public function isGenerator(): bool; + +} diff --git a/src/Node/StaticMethodCallableNode.php b/src/Node/StaticMethodCallableNode.php new file mode 100644 index 00000000..23226a63 --- /dev/null +++ b/src/Node/StaticMethodCallableNode.php @@ -0,0 +1,59 @@ +getAttributes()); + } + + /** + * @return Expr|Name + */ + public function getClass() + { + return $this->class; + } + + /** + * @return Identifier|Expr + */ + public function getName() + { + return $this->name; + } + + public function getOriginalNode(): Expr\StaticCall + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_StaticMethodCallableNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/UnreachableStatementNode.php b/src/Node/UnreachableStatementNode.php new file mode 100644 index 00000000..4b29bbe7 --- /dev/null +++ b/src/Node/UnreachableStatementNode.php @@ -0,0 +1,46 @@ +getAttributes()); + } + + public function getOriginalStatement(): Stmt + { + return $this->originalStatement; + } + + public function getType(): string + { + return 'PHPStan_Stmt_UnreachableStatementNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + + /** + * @return Stmt[] + */ + public function getNextStatements(): array + { + return $this->nextStatements; + } + +} diff --git a/src/Node/VarTagChangedExpressionTypeNode.php b/src/Node/VarTagChangedExpressionTypeNode.php new file mode 100644 index 00000000..d30d7f6c --- /dev/null +++ b/src/Node/VarTagChangedExpressionTypeNode.php @@ -0,0 +1,41 @@ +getAttributes()); + } + + public function getVarTag(): VarTag + { + return $this->varTag; + } + + public function getExpr(): Expr + { + return $this->expr; + } + + public function getType(): string + { + return 'PHPStan_Node_VarTagChangedExpressionType'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/VariableAssignNode.php b/src/Node/VariableAssignNode.php new file mode 100644 index 00000000..e6db5054 --- /dev/null +++ b/src/Node/VariableAssignNode.php @@ -0,0 +1,49 @@ +getAttributes()); + } + + public function getVariable(): Expr\Variable + { + return $this->variable; + } + + public function getAssignedExpr(): Expr + { + return $this->assignedExpr; + } + + public function isAssignOp(): bool + { + return $this->assignOp; + } + + public function getType(): string + { + return 'PHPStan_Node_VariableAssignNodeNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/VirtualNode.php b/src/Node/VirtualNode.php new file mode 100644 index 00000000..09d79cc2 --- /dev/null +++ b/src/Node/VirtualNode.php @@ -0,0 +1,12 @@ +processTimeout = max($processTimeout, self::DEFAULT_TIMEOUT); + } + + /** + * @param Closure(int ): void|null $postFileCallback + * @param (callable(list, list, string[]): void)|null $onFileAnalysisHandler + * @return PromiseInterface + */ + public function analyse( + LoopInterface $loop, + Schedule $schedule, + string $mainScript, + ?Closure $postFileCallback, + ?string $projectConfigFile, + InputInterface $input, + ?callable $onFileAnalysisHandler, + ): PromiseInterface + { + $jobs = array_reverse($schedule->getJobs()); + + $numberOfProcesses = $schedule->getNumberOfProcesses(); + $someChildEnded = false; + $errors = []; + $filteredPhpErrors = []; + $allPhpErrors = []; + $locallyIgnoredErrors = []; + $linesToIgnore = []; + $unmatchedLineIgnores = []; + $peakMemoryUsages = []; + $internalErrors = []; + $internalErrorsCount = 0; + $collectedData = []; + $dependencies = []; + $reachedInternalErrorsCountLimit = false; + $exportedNodes = []; + + /** @var Deferred $deferred */ + $deferred = new Deferred(); + + $server = new TcpServer('127.0.0.1:0', $loop); + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$exportedNodes, &$peakMemoryUsages): void { + if (count($jobs) > 0 && $internalErrorsCount === 0) { + $internalErrors[] = new InternalError( + 'Some parallel worker jobs have not finished.', + 'running parallel worker', + [], + null, + true, + ); + $internalErrorsCount++; + } + + $deferred->resolve(new AnalyserResult( + $errors, + $filteredPhpErrors, + $allPhpErrors, + $locallyIgnoredErrors, + $linesToIgnore, + $unmatchedLineIgnores, + $internalErrors, + $collectedData, + $internalErrorsCount === 0 ? $dependencies : null, + $exportedNodes, + $reachedInternalErrorsCountLimit, + array_sum($peakMemoryUsages), // not 100% correct as the peak usages of workers might not have met + )); + }); + $server->on('connection', function (ConnectionInterface $connection) use (&$jobs): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; + // phpcs:enable + $decoder = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, $this->decoderBufferSize); + $encoder = new Encoder($connection, $jsonInvalidUtf8Ignore); + $decoder->on('data', function (array $data) use (&$jobs, $decoder, $encoder): void { + if ($data['action'] !== 'hello') { + return; + } + + $identifier = $data['identifier']; + $process = $this->processPool->getProcess($identifier); + $process->bindConnection($decoder, $encoder); + if (count($jobs) === 0) { + $this->processPool->tryQuitProcess($identifier); + return; + } + + $job = array_pop($jobs); + $process->request(['action' => 'analyse', 'files' => $job]); + }); + }); + /** @var string $serverAddress */ + $serverAddress = $server->getAddress(); + + /** @var int<0, 65535> $serverPort */ + $serverPort = parse_url($serverAddress, PHP_URL_PORT); + + $handleError = function (Throwable $error) use (&$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit): void { + $internalErrors[] = new InternalError( + $error->getMessage(), + 'communicating with parallel worker', + InternalError::prepareTrace($error), + $error->getTraceAsString(), + !$error instanceof ProcessTimedOutException, + ); + $internalErrorsCount++; + $reachedInternalErrorsCountLimit = true; + $this->processPool->quitAll(); + }; + + for ($i = 0; $i < $numberOfProcesses; $i++) { + if (count($jobs) === 0) { + break; + } + + $processIdentifier = Random::generate(); + $commandOptions = [ + '--port', + (string) $serverPort, + '--identifier', + $processIdentifier, + ]; + + $process = new Process(ProcessHelper::getWorkerCommand( + $mainScript, + 'worker', + $projectConfigFile, + $commandOptions, + $input, + ), $loop, $this->processTimeout); + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler): void { + $fileErrors = []; + foreach ($json['errors'] as $jsonError) { + $fileErrors[] = Error::decode($jsonError); + } + foreach ($json['internalErrors'] as $internalJsonError) { + $internalErrors[] = InternalError::decode($internalJsonError); + } + + foreach ($json['filteredPhpErrors'] as $filteredPhpError) { + $filteredPhpErrors[] = Error::decode($filteredPhpError); + } + + foreach ($json['allPhpErrors'] as $allPhpError) { + $allPhpErrors[] = Error::decode($allPhpError); + } + + $locallyIgnoredFileErrors = []; + foreach ($json['locallyIgnoredErrors'] as $locallyIgnoredJsonError) { + $locallyIgnoredFileErrors[] = Error::decode($locallyIgnoredJsonError); + } + + if ($onFileAnalysisHandler !== null) { + $onFileAnalysisHandler($fileErrors, $locallyIgnoredFileErrors, $json['files']); + } + + foreach ($fileErrors as $fileError) { + $errors[] = $fileError; + } + + foreach ($locallyIgnoredFileErrors as $locallyIgnoredFileError) { + $locallyIgnoredErrors[] = $locallyIgnoredFileError; + } + + foreach ($json['collectedData'] as $jsonData) { + $collectedData[] = CollectedData::decode($jsonData); + } + + /** + * @var string $file + * @var array $fileDependencies + */ + foreach ($json['dependencies'] as $file => $fileDependencies) { + $dependencies[$file] = $fileDependencies; + } + + foreach ($json['linesToIgnore'] as $file => $fileLinesToIgnore) { + if (count($fileLinesToIgnore) === 0) { + continue; + } + $linesToIgnore[$file] = $fileLinesToIgnore; + } + + foreach ($json['unmatchedLineIgnores'] as $file => $fileUnmatchedLineIgnores) { + if (count($fileUnmatchedLineIgnores) === 0) { + continue; + } + $unmatchedLineIgnores[$file] = $fileUnmatchedLineIgnores; + } + + /** + * @var string $file + * @var array $fileExportedNodes + */ + foreach ($json['exportedNodes'] as $file => $fileExportedNodes) { + if (count($fileExportedNodes) === 0) { + continue; + } + $exportedNodes[$file] = array_map(static function (array $node): RootExportedNode { + $class = $node['type']; + + return $class::decode($node['data']); + }, $fileExportedNodes); + } + + if ($postFileCallback !== null) { + $postFileCallback(count($json['files'])); + } + + if (!isset($peakMemoryUsages[$processIdentifier]) || $peakMemoryUsages[$processIdentifier] < $json['memoryUsage']) { + $peakMemoryUsages[$processIdentifier] = $json['memoryUsage']; + } + + $internalErrorsCount += $json['internalErrorsCount']; + if ($internalErrorsCount >= $this->internalErrorsCountLimit) { + $reachedInternalErrorsCountLimit = true; + $this->processPool->quitAll(); + } + + if (count($jobs) === 0) { + $this->processPool->tryQuitProcess($processIdentifier); + return; + } + + $job = array_pop($jobs); + $process->request(['action' => 'analyse', 'files' => $job]); + }, $handleError, function ($exitCode, string $output) use (&$someChildEnded, &$peakMemoryUsages, &$internalErrors, &$internalErrorsCount, $processIdentifier): void { + if ($someChildEnded === false) { + $peakMemoryUsages['main'] = memory_get_usage(true); + } + $someChildEnded = true; + + if ($exitCode === 0) { + $this->processPool->tryQuitProcess($processIdentifier); + return; + } + if ($exitCode === null) { + $this->processPool->tryQuitProcess($processIdentifier); + return; + } + + $memoryLimitMessage = 'PHPStan process crashed because it reached configured PHP memory limit'; + if (str_contains($output, $memoryLimitMessage)) { + foreach ($internalErrors as $internalError) { + if (!str_contains($internalError->getMessage(), $memoryLimitMessage)) { + continue; + } + + $this->processPool->tryQuitProcess($processIdentifier); + return; + } + $internalErrors[] = new InternalError(sprintf( + "Child process error: %s: %s\n%s\n", + $memoryLimitMessage, + ini_get('memory_limit'), + 'Increase your memory limit in php.ini or run PHPStan with --memory-limit CLI option.', + ), 'running parallel worker', [], null, false); + $internalErrorsCount++; + $this->processPool->tryQuitProcess($processIdentifier); + return; + } + + $internalErrors[] = new InternalError(sprintf('Child process error (exit code %d): %s', $exitCode, $output), 'running parallel worker', [], null, true); + $internalErrorsCount++; + $this->processPool->tryQuitProcess($processIdentifier); + }); + $this->processPool->attachProcess($processIdentifier, $process); + } + + return $deferred->promise(); + } + +} diff --git a/src/Parallel/Process.php b/src/Parallel/Process.php new file mode 100644 index 00000000..4c0d5905 --- /dev/null +++ b/src/Parallel/Process.php @@ -0,0 +1,159 @@ +stdOut = $tmpStdOut; + $this->stdErr = $tmpStdErr; + $this->process = new \React\ChildProcess\Process($this->command, null, null, [ + 1 => $this->stdOut, + 2 => $this->stdErr, + ]); + $this->process->start($this->loop); + $this->onData = $onData; + $this->onError = $onError; + $this->process->on('exit', function ($exitCode) use ($onExit): void { + $this->cancelTimer(); + + $output = ''; + rewind($this->stdOut); + $stdOut = stream_get_contents($this->stdOut); + if (is_string($stdOut)) { + $output .= $stdOut; + } + + rewind($this->stdErr); + $stdErr = stream_get_contents($this->stdErr); + if (is_string($stdErr)) { + $output .= $stdErr; + } + $onExit($exitCode, $output); + fclose($this->stdOut); + fclose($this->stdErr); + }); + } + + private function cancelTimer(): void + { + if ($this->timer === null) { + return; + } + + $this->loop->cancelTimer($this->timer); + $this->timer = null; + } + + /** + * @param mixed[] $data + */ + public function request(array $data): void + { + $this->cancelTimer(); + if ($this->in === null) { + throw new ShouldNotHappenException(); + } + $this->in->write($data); + $this->timer = $this->loop->addTimer($this->timeoutSeconds, function (): void { + $onError = $this->onError; + $onError(new ProcessTimedOutException(sprintf('Child process timed out after %.1f seconds. Try making it longer with parallel.processTimeout setting.', $this->timeoutSeconds))); + }); + } + + public function quit(): void + { + $this->cancelTimer(); + if (!$this->process->isRunning()) { + return; + } + + foreach ($this->process->pipes as $pipe) { + $pipe->close(); + } + + if ($this->in === null) { + return; + } + + $this->in->end(); + } + + public function bindConnection(ReadableStreamInterface $out, WritableStreamInterface $in): void + { + $out->on('data', function (array $json): void { + $this->cancelTimer(); + if ($json['action'] !== 'result') { + return; + } + + $onData = $this->onData; + $onData($json['result']); + }); + $this->in = $in; + $out->on('error', function (Throwable $error): void { + $onError = $this->onError; + $onError($error); + }); + $in->on('error', function (Throwable $error): void { + $onError = $this->onError; + $onError($error); + }); + } + +} diff --git a/src/Parallel/ProcessPool.php b/src/Parallel/ProcessPool.php new file mode 100644 index 00000000..97d62e7f --- /dev/null +++ b/src/Parallel/ProcessPool.php @@ -0,0 +1,74 @@ + */ + private array $processes = []; + + /** @var callable(): void */ + private $onServerClose; + + /** + * @param callable(): void $onServerClose + */ + public function __construct(private TcpServer $server, callable $onServerClose) + { + $this->onServerClose = $onServerClose; + } + + public function getProcess(string $identifier): Process + { + if (!array_key_exists($identifier, $this->processes)) { + throw new ShouldNotHappenException(sprintf('Process %s not found.', $identifier)); + } + + return $this->processes[$identifier]; + } + + public function attachProcess(string $identifier, Process $process): void + { + $this->processes[$identifier] = $process; + } + + public function tryQuitProcess(string $identifier): void + { + if (!array_key_exists($identifier, $this->processes)) { + return; + } + + $this->quitProcess($identifier); + } + + private function quitProcess(string $identifier): void + { + $process = $this->getProcess($identifier); + $process->quit(); + unset($this->processes[$identifier]); + if (count($this->processes) !== 0) { + return; + } + + $this->server->close(); + $callback = $this->onServerClose; + $callback(); + } + + public function quitAll(): void + { + foreach (array_keys($this->processes) as $identifier) { + $this->quitProcess($identifier); + } + } + +} diff --git a/src/Parallel/ProcessTimedOutException.php b/src/Parallel/ProcessTimedOutException.php new file mode 100644 index 00000000..fd048ad8 --- /dev/null +++ b/src/Parallel/ProcessTimedOutException.php @@ -0,0 +1,11 @@ +> $jobs + */ + public function __construct(private int $numberOfProcesses, private array $jobs) + { + } + + public function getNumberOfProcesses(): int + { + return $this->numberOfProcesses; + } + + /** + * @return array> + */ + public function getJobs(): array + { + return $this->jobs; + } + +} diff --git a/src/Parallel/Scheduler.php b/src/Parallel/Scheduler.php new file mode 100644 index 00000000..d1489632 --- /dev/null +++ b/src/Parallel/Scheduler.php @@ -0,0 +1,75 @@ + $files + */ + public function scheduleWork( + int $cpuCores, + array $files, + ): Schedule + { + $jobs = array_chunk($files, $this->jobSize); + $numberOfProcesses = min( + max((int) floor(count($jobs) / $this->minimumNumberOfJobsPerProcess), 1), + $cpuCores, + ); + + $usedNumberOfProcesses = min($numberOfProcesses, $this->maximumNumberOfProcesses); + $this->storedData = [$cpuCores, count($files), count($jobs), $usedNumberOfProcesses]; + + return new Schedule($usedNumberOfProcesses, $jobs); + } + + public function print(Output $output): void + { + if ($this->storedData === null) { + return; + } + + [$cpuCores, $filesCount, $jobsCount, $usedNumberOfProcesses] = $this->storedData; + + $output->writeLineFormatted('Parallel processing scheduler:'); + $output->writeLineFormatted(sprintf( + '# of detected CPU %s: %s%d', + $cpuCores === 1 ? 'core' : 'cores', + $cpuCores === 1 ? '' : ' ', + $cpuCores, + )); + $output->writeLineFormatted(sprintf('# of analysed files: %d', $filesCount)); + $output->writeLineFormatted(sprintf('# of jobs: %d', $jobsCount)); + $output->writeLineFormatted(sprintf('# of spawned processes: %d', $usedNumberOfProcesses)); + $output->writeLineFormatted(''); + } + +} diff --git a/src/Parser/AnonymousClassVisitor.php b/src/Parser/AnonymousClassVisitor.php new file mode 100644 index 00000000..847b034d --- /dev/null +++ b/src/Parser/AnonymousClassVisitor.php @@ -0,0 +1,53 @@ +> */ + private array $nodesPerLine = []; + + public function beforeTraverse(array $nodes): ?array + { + $this->nodesPerLine = []; + return null; + } + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Stmt\Class_ || !$node->isAnonymous()) { + return null; + } + + $node = AnonymousClassNode::createFromClassNode($node); + $node->setAttribute('anonymousClass', true); // We keep this for backward compatibility + $this->nodesPerLine[$node->getStartLine()][] = $node; + + return $node; + } + + public function afterTraverse(array $nodes): ?array + { + foreach ($this->nodesPerLine as $nodesOnLine) { + if (count($nodesOnLine) === 1) { + continue; + } + for ($i = 0; $i < count($nodesOnLine); $i++) { + $nodesOnLine[$i]->setAttribute(self::ATTRIBUTE_LINE_INDEX, $i + 1); + } + } + + $this->nodesPerLine = []; + return null; + } + +} diff --git a/src/Parser/ArrayFilterArgVisitor.php b/src/Parser/ArrayFilterArgVisitor.php new file mode 100644 index 00000000..1705e2fd --- /dev/null +++ b/src/Parser/ArrayFilterArgVisitor.php @@ -0,0 +1,28 @@ +name instanceof Node\Name) { + $functionName = $node->name->toLowerString(); + if ($functionName === 'array_filter') { + $args = $node->getRawArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + } + return null; + } + +} diff --git a/src/Parser/ArrayFindArgVisitor.php b/src/Parser/ArrayFindArgVisitor.php new file mode 100644 index 00000000..b3e9a661 --- /dev/null +++ b/src/Parser/ArrayFindArgVisitor.php @@ -0,0 +1,29 @@ +name instanceof Node\Name) { + $functionName = $node->name->toLowerString(); + if (in_array($functionName, ['array_all', 'array_any', 'array_find', 'array_find_key'], true)) { + $args = $node->getRawArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + } + return null; + } + +} diff --git a/src/Parser/ArrayMapArgVisitor.php b/src/Parser/ArrayMapArgVisitor.php new file mode 100644 index 00000000..7b0d9885 --- /dev/null +++ b/src/Parser/ArrayMapArgVisitor.php @@ -0,0 +1,33 @@ +name instanceof Node\Name && !$node->isFirstClassCallable()) { + $functionName = $node->name->toLowerString(); + if ($functionName === 'array_map') { + $args = $node->getArgs(); + if (isset($args[0])) { + $slicedArgs = array_slice($args, 1); + if (count($slicedArgs) > 0) { + $args[0]->value->setAttribute(self::ATTRIBUTE_NAME, $slicedArgs); + } + } + } + } + return null; + } + +} diff --git a/src/Parser/ArrayWalkArgVisitor.php b/src/Parser/ArrayWalkArgVisitor.php new file mode 100644 index 00000000..8610dc83 --- /dev/null +++ b/src/Parser/ArrayWalkArgVisitor.php @@ -0,0 +1,28 @@ +name instanceof Node\Name) { + $functionName = $node->name->toLowerString(); + if ($functionName === 'array_walk') { + $args = $node->getRawArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + } + return null; + } + +} diff --git a/src/Parser/ArrowFunctionArgVisitor.php b/src/Parser/ArrowFunctionArgVisitor.php new file mode 100644 index 00000000..ff9b329f --- /dev/null +++ b/src/Parser/ArrowFunctionArgVisitor.php @@ -0,0 +1,42 @@ +isFirstClassCallable()) { + return null; + } + + if ($node->name instanceof Node\Expr\Assign && $node->name->expr instanceof Node\Expr\ArrowFunction) { + $arrow = $node->name->expr; + } elseif ($node->name instanceof Node\Expr\ArrowFunction) { + $arrow = $node->name; + } else { + return null; + } + + $args = $node->getArgs(); + + if (count($args) > 0) { + $arrow->setAttribute(self::ATTRIBUTE_NAME, $args); + } + + return null; + } + +} diff --git a/src/Parser/CachedParser.php b/src/Parser/CachedParser.php new file mode 100644 index 00000000..2bf99b38 --- /dev/null +++ b/src/Parser/CachedParser.php @@ -0,0 +1,98 @@ +*/ + private array $cachedNodesByString = []; + + private int $cachedNodesByStringCount = 0; + + /** @var array */ + private array $parsedByString = []; + + public function __construct( + private Parser $originalParser, + private int $cachedNodesByStringCountMax, + ) + { + } + + /** + * @param string $file path to a file to parse + * @return Node\Stmt[] + */ + public function parseFile(string $file): array + { + if ($this->cachedNodesByStringCountMax !== 0 && $this->cachedNodesByStringCount >= $this->cachedNodesByStringCountMax) { + $this->cachedNodesByString = array_slice( + $this->cachedNodesByString, + 1, + null, + true, + ); + + --$this->cachedNodesByStringCount; + } + + $sourceCode = FileReader::read($file); + if (!isset($this->cachedNodesByString[$sourceCode]) || isset($this->parsedByString[$sourceCode])) { + $this->cachedNodesByString[$sourceCode] = $this->originalParser->parseFile($file); + $this->cachedNodesByStringCount++; + unset($this->parsedByString[$sourceCode]); + } + + return $this->cachedNodesByString[$sourceCode]; + } + + /** + * @return Node\Stmt[] + */ + public function parseString(string $sourceCode): array + { + if ($this->cachedNodesByStringCountMax !== 0 && $this->cachedNodesByStringCount >= $this->cachedNodesByStringCountMax) { + $this->cachedNodesByString = array_slice( + $this->cachedNodesByString, + 1, + null, + true, + ); + + --$this->cachedNodesByStringCount; + } + + if (!isset($this->cachedNodesByString[$sourceCode])) { + $this->cachedNodesByString[$sourceCode] = $this->originalParser->parseString($sourceCode); + $this->cachedNodesByStringCount++; + $this->parsedByString[$sourceCode] = true; + } + + return $this->cachedNodesByString[$sourceCode]; + } + + public function getCachedNodesByStringCount(): int + { + return $this->cachedNodesByStringCount; + } + + public function getCachedNodesByStringCountMax(): int + { + return $this->cachedNodesByStringCountMax; + } + + /** + * @return array + */ + public function getCachedNodesByString(): array + { + return $this->cachedNodesByString; + } + +} diff --git a/src/Parser/CleaningParser.php b/src/Parser/CleaningParser.php new file mode 100644 index 00000000..965cf6d1 --- /dev/null +++ b/src/Parser/CleaningParser.php @@ -0,0 +1,42 @@ +traverser = new NodeTraverser(); + $this->traverser->addVisitor(new CleaningVisitor()); + $this->traverser->addVisitor(new RemoveUnusedCodeByPhpVersionIdVisitor($phpVersion->getVersionString())); + } + + public function parseFile(string $file): array + { + return $this->clean($this->wrappedParser->parseFile($file)); + } + + public function parseString(string $sourceCode): array + { + return $this->clean($this->wrappedParser->parseString($sourceCode)); + } + + /** + * @param Stmt[] $ast + * @return Stmt[] + */ + private function clean(array $ast): array + { + /** @var Stmt[] */ + return $this->traverser->traverse($ast); + } + +} diff --git a/src/Parser/CleaningVisitor.php b/src/Parser/CleaningVisitor.php new file mode 100644 index 00000000..7230cec9 --- /dev/null +++ b/src/Parser/CleaningVisitor.php @@ -0,0 +1,105 @@ +nodeFinder = new NodeFinder(); + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Function_) { + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); + return $node; + } + + if ($node instanceof Node\Stmt\ClassMethod && $node->stmts !== null) { + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); + return $node; + } + + if ($node instanceof Node\Expr\Closure) { + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); + return $node; + } + + if ($node instanceof Node\PropertyHook && is_array($node->body)) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + $node->body = $this->keepVariadicsAndYields($node->body, $propertyName); + return $node; + } + } + + return null; + } + + /** + * @param Node\Stmt[] $stmts + * @return Node\Stmt[] + */ + private function keepVariadicsAndYields(array $stmts, ?string $hookedPropertyName): array + { + $results = $this->nodeFinder->find($stmts, static function (Node $node) use ($hookedPropertyName): bool { + if ($node instanceof Node\Expr\YieldFrom || $node instanceof Node\Expr\Yield_) { + return true; + } + if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) { + return in_array($node->name->toLowerString(), ParametersAcceptor::VARIADIC_FUNCTIONS, true); + } + + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + return true; + } + + if ($hookedPropertyName !== null) { + if ( + $node instanceof Node\Expr\PropertyFetch + && $node->var instanceof Node\Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && $node->name->toString() === $hookedPropertyName + ) { + return true; + } + } + + return false; + }); + $newStmts = []; + foreach ($results as $result) { + if ( + $result instanceof Node\Expr\Yield_ + || $result instanceof Node\Expr\YieldFrom + || $result instanceof Node\Expr\Closure + || $result instanceof Node\Expr\ArrowFunction + || $result instanceof Node\Expr\PropertyFetch + ) { + $newStmts[] = new Node\Stmt\Expression($result); + continue; + } + if (!$result instanceof Node\Expr\FuncCall) { + continue; + } + + $newStmts[] = new Node\Stmt\Expression(new Node\Expr\FuncCall(new Node\Name\FullyQualified('func_get_args'))); + } + + return $newStmts; + } + +} diff --git a/src/Parser/ClosureArgVisitor.php b/src/Parser/ClosureArgVisitor.php new file mode 100644 index 00000000..4e8f46a8 --- /dev/null +++ b/src/Parser/ClosureArgVisitor.php @@ -0,0 +1,42 @@ +isFirstClassCallable()) { + return null; + } + + if ($node->name instanceof Node\Expr\Assign && $node->name->expr instanceof Node\Expr\Closure) { + $closure = $node->name->expr; + } elseif ($node->name instanceof Node\Expr\Closure) { + $closure = $node->name; + } else { + return null; + } + + $args = $node->getArgs(); + + if (count($args) > 0) { + $closure->setAttribute(self::ATTRIBUTE_NAME, $args); + } + + return null; + } + +} diff --git a/src/Parser/ClosureBindArgVisitor.php b/src/Parser/ClosureBindArgVisitor.php new file mode 100644 index 00000000..279345e6 --- /dev/null +++ b/src/Parser/ClosureBindArgVisitor.php @@ -0,0 +1,34 @@ +class instanceof Node\Name + && $node->class->toLowerString() === 'closure' + && $node->name instanceof Identifier + && $node->name->toLowerString() === 'bind' + && !$node->isFirstClassCallable() + ) { + $args = $node->getArgs(); + if (count($args) > 1) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + return null; + } + +} diff --git a/src/Parser/ClosureBindToVarVisitor.php b/src/Parser/ClosureBindToVarVisitor.php new file mode 100644 index 00000000..e550986a --- /dev/null +++ b/src/Parser/ClosureBindToVarVisitor.php @@ -0,0 +1,31 @@ +name instanceof Identifier + && $node->name->toLowerString() === 'bindto' + && !$node->isFirstClassCallable() + ) { + $args = $node->getArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, $node->var); + } + } + return null; + } + +} diff --git a/src/Parser/CurlSetOptArgVisitor.php b/src/Parser/CurlSetOptArgVisitor.php new file mode 100644 index 00000000..c8357766 --- /dev/null +++ b/src/Parser/CurlSetOptArgVisitor.php @@ -0,0 +1,28 @@ +name instanceof Node\Name) { + $functionName = $node->name->toLowerString(); + if ($functionName === 'curl_setopt') { + $args = $node->getRawArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + } + return null; + } + +} diff --git a/src/Parser/DeclarePositionVisitor.php b/src/Parser/DeclarePositionVisitor.php new file mode 100644 index 00000000..8bf21c65 --- /dev/null +++ b/src/Parser/DeclarePositionVisitor.php @@ -0,0 +1,45 @@ +isFirstStatement = true; + return null; + } + + public function enterNode(Node $node): ?Node + { + // ignore shebang + if ( + $this->isFirstStatement + && $node instanceof Node\Stmt\InlineHTML + && str_starts_with($node->value, '#!') + ) { + return null; + } + + if ($node instanceof Node\Stmt) { + if ($node instanceof Node\Stmt\Declare_) { + $node->setAttribute(self::ATTRIBUTE_NAME, $this->isFirstStatement); + } + + $this->isFirstStatement = false; + } + + return null; + } + +} diff --git a/src/Parser/ImmediatelyInvokedClosureVisitor.php b/src/Parser/ImmediatelyInvokedClosureVisitor.php new file mode 100644 index 00000000..8763e588 --- /dev/null +++ b/src/Parser/ImmediatelyInvokedClosureVisitor.php @@ -0,0 +1,23 @@ +name instanceof Node\Expr\Closure) { + $node->name->setAttribute(self::ATTRIBUTE_NAME, true); + } + + return null; + } + +} diff --git a/src/Parser/LastConditionVisitor.php b/src/Parser/LastConditionVisitor.php new file mode 100644 index 00000000..bf2eea13 --- /dev/null +++ b/src/Parser/LastConditionVisitor.php @@ -0,0 +1,94 @@ +elseifs !== []) { + $lastElseIf = count($node->elseifs) - 1; + + $elseIsMissingOrThrowing = $node->else === null + || ( + count($node->else->stmts) === 1 + && $node->else->stmts[0] instanceof Node\Stmt\Expression + && $node->else->stmts[0]->expr instanceof Node\Expr\Throw_ + ); + + foreach ($node->elseifs as $i => $elseif) { + $isLast = $i === $lastElseIf && $elseIsMissingOrThrowing; + $elseif->cond->setAttribute(self::ATTRIBUTE_NAME, $isLast); + } + } + + if ($node instanceof Node\Expr\Match_ && $node->arms !== []) { + $lastArm = count($node->arms) - 1; + + foreach ($node->arms as $i => $arm) { + if ($arm->conds === null || $arm->conds === []) { + continue; + } + + $isLast = $i === $lastArm; + $index = count($arm->conds) - 1; + $arm->conds[$index]->setAttribute(self::ATTRIBUTE_NAME, $isLast); + $arm->conds[$index]->setAttribute(self::ATTRIBUTE_IS_MATCH_NAME, true); + } + } + + if ( + $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Stmt\ClassMethod + || $node instanceof Node\Stmt\If_ + || $node instanceof Node\Stmt\ElseIf_ + || $node instanceof Node\Stmt\Else_ + || $node instanceof Node\Stmt\Case_ + || $node instanceof Node\Stmt\Catch_ + || $node instanceof Node\Stmt\Do_ + || $node instanceof Node\Stmt\Finally_ + || $node instanceof Node\Stmt\For_ + || $node instanceof Node\Stmt\Foreach_ + || $node instanceof Node\Stmt\Namespace_ + || $node instanceof Node\Stmt\TryCatch + || $node instanceof Node\Stmt\While_ + ) { + $statements = $node->stmts ?? []; + $statementCount = count($statements); + + if ($statementCount < 2) { + return null; + } + + $lastStatement = $statements[$statementCount - 1]; + + if (!$lastStatement instanceof Node\Stmt\Expression) { + return null; + } + + if (!$lastStatement->expr instanceof Node\Expr\Throw_) { + return null; + } + + if (!$statements[$statementCount - 2] instanceof Node\Stmt\If_ || $statements[$statementCount - 2]->else !== null) { + return null; + } + + $if = $statements[$statementCount - 2]; + $cond = count($if->elseifs) > 0 ? $if->elseifs[count($if->elseifs) - 1]->cond : $if->cond; + $cond->setAttribute(self::ATTRIBUTE_NAME, true); + } + + return null; + } + +} diff --git a/src/Parser/LexerFactory.php b/src/Parser/LexerFactory.php new file mode 100644 index 00000000..f44dbeeb --- /dev/null +++ b/src/Parser/LexerFactory.php @@ -0,0 +1,31 @@ +phpVersion->getVersionId() === PHP_VERSION_ID) { + return new Lexer(); + } + + return new Lexer\Emulative(\PhpParser\PhpVersion::fromString($this->phpVersion->getVersionString())); + } + + public function createEmulative(): Lexer\Emulative + { + return new Lexer\Emulative(); + } + +} diff --git a/src/Parser/LineAttributesVisitor.php b/src/Parser/LineAttributesVisitor.php new file mode 100644 index 00000000..55acf631 --- /dev/null +++ b/src/Parser/LineAttributesVisitor.php @@ -0,0 +1,29 @@ +getStartLine() === -1) { + $node->setAttribute('startLine', $this->startLine); + } + + if ($node->getEndLine() === -1) { + $node->setAttribute('endLine', $this->endLine); + } + + return $node; + } + +} diff --git a/src/Parser/MagicConstantParamDefaultVisitor.php b/src/Parser/MagicConstantParamDefaultVisitor.php new file mode 100644 index 00000000..aab77700 --- /dev/null +++ b/src/Parser/MagicConstantParamDefaultVisitor.php @@ -0,0 +1,22 @@ +default instanceof Node\Scalar\MagicConst) { + $node->default->setAttribute(self::ATTRIBUTE_NAME, true); + } + return null; + } + +} diff --git a/src/Parser/NewAssignedToPropertyVisitor.php b/src/Parser/NewAssignedToPropertyVisitor.php new file mode 100644 index 00000000..320dbd71 --- /dev/null +++ b/src/Parser/NewAssignedToPropertyVisitor.php @@ -0,0 +1,27 @@ +var instanceof Node\Expr\PropertyFetch || $node->var instanceof Node\Expr\StaticPropertyFetch) + && $node->expr instanceof Node\Expr\New_ + ) { + $node->expr->setAttribute(self::ATTRIBUTE_NAME, $node->var); + } + } + return null; + } + +} diff --git a/src/Parser/ParentStmtTypesVisitor.php b/src/Parser/ParentStmtTypesVisitor.php new file mode 100644 index 00000000..4bc0306b --- /dev/null +++ b/src/Parser/ParentStmtTypesVisitor.php @@ -0,0 +1,51 @@ +> */ + private array $typeStack = []; + + public function beforeTraverse(array $nodes): ?array + { + $this->typeStack = []; + return null; + } + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Stmt && !$node instanceof Node\Expr\Closure) { + return null; + } + + if (count($this->typeStack) > 0) { + $node->setAttribute(self::ATTRIBUTE_NAME, $this->typeStack); + } + $this->typeStack[] = get_class($node); + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if (!$node instanceof Node\Stmt && !$node instanceof Node\Expr\Closure) { + return null; + } + + array_pop($this->typeStack); + + return null; + } + +} diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php new file mode 100644 index 00000000..7e1551b7 --- /dev/null +++ b/src/Parser/Parser.php @@ -0,0 +1,25 @@ + $error->getRawMessage(), $errors))); + if (count($errors) > 0) { + $this->attributes = $errors[0]->getAttributes(); + } else { + $this->attributes = []; + } + } + + /** + * @return Error[] + */ + public function getErrors(): array + { + return $this->errors; + } + + public function getParsedFile(): ?string + { + return $this->parsedFile; + } + + /** + * @return mixed[] + */ + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Parser/PathRoutingParser.php b/src/Parser/PathRoutingParser.php new file mode 100644 index 00000000..df70b8db --- /dev/null +++ b/src/Parser/PathRoutingParser.php @@ -0,0 +1,81 @@ + bool(true) */ + private array $analysedFiles = []; + + public function __construct( + private FileHelper $fileHelper, + private Parser $currentPhpVersionRichParser, + private Parser $currentPhpVersionSimpleParser, + private Parser $php8Parser, + ) + { + } + + /** + * @param string[] $files + */ + public function setAnalysedFiles(array $files): void + { + $this->analysedFiles = array_fill_keys($files, true); + } + + public function parseFile(string $file): array + { + $normalizedPath = $this->fileHelper->normalizePath($file, '/'); + if (str_contains($normalizedPath, 'vendor/jetbrains/phpstorm-stubs')) { + return $this->php8Parser->parseFile($file); + } + if (str_contains($normalizedPath, 'vendor/phpstan/php-8-stubs/stubs')) { + return $this->php8Parser->parseFile($file); + } + + $file = $this->fileHelper->normalizePath($file); + if (!isset($this->analysedFiles[$file])) { + // check symlinked file that still might be in analysedFiles + $pathParts = explode(DIRECTORY_SEPARATOR, $file); + for ($i = count($pathParts); $i > 1; $i--) { + $joinedPartOfPath = implode(DIRECTORY_SEPARATOR, array_slice($pathParts, 0, $i)); + if (!@is_link($joinedPartOfPath)) { + continue; + } + + $realFilePath = realpath($file); + if ($realFilePath !== false) { + $normalizedRealFilePath = $this->fileHelper->normalizePath($realFilePath); + if (isset($this->analysedFiles[$normalizedRealFilePath])) { + return $this->currentPhpVersionRichParser->parseFile($file); + } + } + break; + } + + return $this->currentPhpVersionSimpleParser->parseFile($file); + } + + return $this->currentPhpVersionRichParser->parseFile($file); + } + + public function parseString(string $sourceCode): array + { + return $this->currentPhpVersionSimpleParser->parseString($sourceCode); + } + +} diff --git a/src/Parser/PhpParserDecorator.php b/src/Parser/PhpParserDecorator.php new file mode 100644 index 00000000..941c53d7 --- /dev/null +++ b/src/Parser/PhpParserDecorator.php @@ -0,0 +1,41 @@ +wrappedParser->parseString($code); + } catch (ParserErrorsException $e) { + $message = $e->getMessage(); + if ($e->getParsedFile() !== null) { + $message .= sprintf(' in file %s', $e->getParsedFile()); + } + throw new Error($message, $e->getAttributes()); + } + } + + public function getTokens(): array + { + throw new ShouldNotHappenException('PhpParserDecorator::getTokens() should not be called'); + } + +} diff --git a/src/Parser/PhpParserFactory.php b/src/Parser/PhpParserFactory.php new file mode 100644 index 00000000..b1283ff2 --- /dev/null +++ b/src/Parser/PhpParserFactory.php @@ -0,0 +1,29 @@ +phpVersion->getVersionString()); + if ($this->phpVersion->getVersionId() >= 80000) { + return new Php8($this->lexer, $phpVersion); + } + + return new Php7($this->lexer, $phpVersion); + } + +} diff --git a/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php b/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php new file mode 100644 index 00000000..d03bdd1a --- /dev/null +++ b/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php @@ -0,0 +1,99 @@ +elseifs) > 0) { + return null; + } + + if ($node->else === null) { + return null; + } + + $cond = $node->cond; + if ( + !$cond instanceof Node\Expr\BinaryOp\Smaller + && !$cond instanceof Node\Expr\BinaryOp\SmallerOrEqual + && !$cond instanceof Node\Expr\BinaryOp\Greater + && !$cond instanceof Node\Expr\BinaryOp\GreaterOrEqual + && !$cond instanceof Node\Expr\BinaryOp\Equal + && !$cond instanceof Node\Expr\BinaryOp\NotEqual + && !$cond instanceof Node\Expr\BinaryOp\Identical + && !$cond instanceof Node\Expr\BinaryOp\NotIdentical + ) { + return null; + } + + $operator = $cond->getOperatorSigil(); + if ($operator === '===') { + $operator = '=='; + } elseif ($operator === '!==') { + $operator = '!='; + } + + $operands = $this->getOperands($cond->left, $cond->right); + if ($operands === null) { + return null; + } + + $result = version_compare($operands[0], $operands[1], $operator); + if ($result) { + // remove else + $node->cond = new Node\Expr\ConstFetch(new Node\Name('true')); + $node->else = null; + + return $node; + } + + // remove if + $node->cond = new Node\Expr\ConstFetch(new Node\Name('false')); + $node->stmts = []; + + return $node; + } + + /** + * @return array{string, string}|null + */ + private function getOperands(Node\Expr $left, Node\Expr $right): ?array + { + if ( + $left instanceof Node\Scalar\Int_ + && $right instanceof Node\Expr\ConstFetch + && $right->name->toString() === 'PHP_VERSION_ID' + ) { + return [(new PhpVersion($left->value))->getVersionString(), $this->phpVersionString]; + } + + if ( + $right instanceof Node\Scalar\Int_ + && $left instanceof Node\Expr\ConstFetch + && $left->name->toString() === 'PHP_VERSION_ID' + ) { + return [$this->phpVersionString, (new PhpVersion($right->value))->getVersionString()]; + } + + return null; + } + +} diff --git a/src/Parser/RichParser.php b/src/Parser/RichParser.php new file mode 100644 index 00000000..99c15c96 --- /dev/null +++ b/src/Parser/RichParser.php @@ -0,0 +1,347 @@ +parseString(FileReader::read($file)); + } catch (ParserErrorsException $e) { + throw new ParserErrorsException($e->getErrors(), $file); + } + } + + /** + * @return Node\Stmt[] + */ + public function parseString(string $sourceCode): array + { + $errorHandler = new Collecting(); + $nodes = $this->parser->parse($sourceCode, $errorHandler); + + $tokens = $this->parser->getTokens(); + if ($errorHandler->hasErrors()) { + throw new ParserErrorsException($errorHandler->getErrors(), null); + } + if ($nodes === null) { + throw new ShouldNotHappenException(); + } + + $nodeTraverser = new NodeTraverser(); + $nodeTraverser->addVisitor($this->nameResolver); + + $traitCollectingVisitor = new TraitCollectingVisitor(); + $nodeTraverser->addVisitor($traitCollectingVisitor); + + foreach ($this->container->getServicesByTag(self::VISITOR_SERVICE_TAG) as $visitor) { + $nodeTraverser->addVisitor($visitor); + } + + /** @var array */ + $nodes = $nodeTraverser->traverse($nodes); + ['lines' => $linesToIgnore, 'errors' => $ignoreParseErrors] = $this->getLinesToIgnore($tokens); + if (isset($nodes[0])) { + $nodes[0]->setAttribute('linesToIgnore', $linesToIgnore); + if (count($ignoreParseErrors) > 0) { + $nodes[0]->setAttribute('linesToIgnoreParseErrors', $ignoreParseErrors); + } + } + + foreach ($traitCollectingVisitor->traits as $trait) { + $preexisting = $trait->getAttribute('linesToIgnore', []); + $filteredLinesToIgnore = array_filter($linesToIgnore, static fn (int $line): bool => $line >= $trait->getStartLine() && $line <= $trait->getEndLine(), ARRAY_FILTER_USE_KEY); + foreach ($preexisting as $line => $ignores) { + $filteredLinesToIgnore[$line] = $ignores; + } + $trait->setAttribute('linesToIgnore', $filteredLinesToIgnore); + } + + return $nodes; + } + + /** + * @param Token[] $tokens + * @return array{lines: array|null>, errors: array>} + */ + private function getLinesToIgnore(array $tokens): array + { + $lines = []; + $previousToken = null; + $pendingToken = null; + $errors = []; + foreach ($tokens as $token) { + $type = $token->id; + $line = $token->line; + if ($type !== T_COMMENT && $type !== T_DOC_COMMENT) { + if ($type !== T_WHITESPACE) { + if ($pendingToken !== null) { + [$pendingText, $pendingIgnorePos, $tokenLine, $pendingLine] = $pendingToken; + + try { + $identifiers = $this->parseIdentifiers($pendingText, $pendingIgnorePos); + } catch (IgnoreParseException $e) { + $errors[] = [$tokenLine + $e->getPhpDocLine(), $e->getMessage()]; + $pendingToken = null; + continue; + } + + if ($line !== $pendingLine + 1) { + $lineToAdd = $pendingLine; + } else { + $lineToAdd = $line; + } + + foreach ($identifiers as $identifier) { + $lines[$lineToAdd][] = $identifier; + } + + $pendingToken = null; + } + $previousToken = $token; + } + continue; + } + + $text = $token->text; + $isNextLine = str_contains($text, '@phpstan-ignore-next-line'); + $isCurrentLine = str_contains($text, '@phpstan-ignore-line'); + + if ($type === T_DOC_COMMENT) { + $lines += $this->getLinesToIgnoreForTokenByIgnoreComment($text, $line, '@phpstan-ignore-line', false); + if ($isNextLine) { + $pattern = sprintf('~%s~si', implode('|', [self::PHPDOC_TAG_REGEX, self::PHPDOC_DOCTRINE_TAG_REGEX])); + $r = preg_match_all($pattern, $text, $pregMatches, PREG_OFFSET_CAPTURE); + if ($r !== false) { + $c = count($pregMatches[0]); + if ($c > 0) { + [$lastMatchTag, $lastMatchOffset] = $pregMatches[0][$c - 1]; + if ($lastMatchTag === '@phpstan-ignore-next-line') { + // this will let us ignore errors outside of PHPDoc + // and also cut off the PHPDoc text before the last tag + $lineToIgnore = $line + 1 + substr_count($text, "\n"); + $lines[$lineToIgnore] = null; + $text = substr($text, 0, $lastMatchOffset); + } + } + } + + $lines += $this->getLinesToIgnoreForTokenByIgnoreComment($text, $line, '@phpstan-ignore-next-line', true); + } + + if ($isNextLine || $isCurrentLine) { + continue; + } + + } else { + if ($isNextLine) { + $line++; + } + if ($isNextLine || $isCurrentLine) { + $line += substr_count($token->text, "\n"); + + $lines[$line] = null; + continue; + } + } + + $ignorePos = strpos($text, '@phpstan-ignore'); + if ($ignorePos === false) { + continue; + } + + $ignoreLine = substr_count(substr($text, 0, $ignorePos), "\n") - 1; + + if ($previousToken !== null && $previousToken->line === $line) { + try { + foreach ($this->parseIdentifiers($text, $ignorePos) as $identifier) { + $lines[$line][] = $identifier; + } + } catch (IgnoreParseException $e) { + $errors[] = [$token->line + $e->getPhpDocLine() + $ignoreLine, $e->getMessage()]; + } + + continue; + } + + $line += substr_count($token->text, "\n"); + $pendingToken = [$text, $ignorePos, $token->line + $ignoreLine, $line]; + } + + if ($pendingToken !== null) { + [$pendingText, $pendingIgnorePos, $tokenLine, $pendingLine] = $pendingToken; + + try { + foreach ($this->parseIdentifiers($pendingText, $pendingIgnorePos) as $identifier) { + $lines[$pendingLine][] = $identifier; + } + } catch (IgnoreParseException $e) { + $errors[] = [$tokenLine + $e->getPhpDocLine(), $e->getMessage()]; + } + } + + $processedErrors = []; + foreach ($errors as [$line, $message]) { + $processedErrors[$line][] = $message; + } + + return [ + 'lines' => $lines, + 'errors' => $processedErrors, + ]; + } + + /** + * @return array + */ + private function getLinesToIgnoreForTokenByIgnoreComment( + string $tokenText, + int $tokenLine, + string $ignoreComment, + bool $ignoreNextLine, + ): array + { + $lines = []; + $positionsOfIgnoreComment = []; + $offset = 0; + + while (($pos = strpos($tokenText, $ignoreComment, $offset)) !== false) { + $positionsOfIgnoreComment[] = $pos; + $offset = $pos + 1; + } + + foreach ($positionsOfIgnoreComment as $pos) { + $line = $tokenLine + substr_count(substr($tokenText, 0, $pos), "\n") + ($ignoreNextLine ? 1 : 0); + $lines[$line] = null; + } + + return $lines; + } + + /** + * @return non-empty-list + * @throws IgnoreParseException + */ + private function parseIdentifiers(string $text, int $ignorePos): array + { + $text = substr($text, $ignorePos + strlen('@phpstan-ignore')); + $originalTokens = $this->ignoreLexer->tokenize($text); + $tokens = []; + + foreach ($originalTokens as $originalToken) { + if ($originalToken[IgnoreLexer::TYPE_OFFSET] === IgnoreLexer::TOKEN_WHITESPACE) { + continue; + } + $tokens[] = $originalToken; + } + + $c = count($tokens); + + $identifiers = []; + $openParenthesisCount = 0; + $expected = [IgnoreLexer::TOKEN_IDENTIFIER]; + + for ($i = 0; $i < $c; $i++) { + $lastTokenTypeLabel = isset($tokenType) ? $this->ignoreLexer->getLabel($tokenType) : '@phpstan-ignore'; + [IgnoreLexer::VALUE_OFFSET => $content, IgnoreLexer::TYPE_OFFSET => $tokenType, IgnoreLexer::LINE_OFFSET => $tokenLine] = $tokens[$i]; + + if ($expected !== null && !in_array($tokenType, $expected, true)) { + $tokenTypeLabel = $this->ignoreLexer->getLabel($tokenType); + $otherTokenContent = $tokenType === IgnoreLexer::TOKEN_OTHER ? sprintf(" '%s'", $content) : ''; + $expectedLabels = implode(' or ', array_map(fn ($token) => $this->ignoreLexer->getLabel($token), $expected)); + + throw new IgnoreParseException(sprintf('Unexpected %s%s after %s, expected %s', $tokenTypeLabel, $otherTokenContent, $lastTokenTypeLabel, $expectedLabels), $tokenLine); + } + + if ($tokenType === IgnoreLexer::TOKEN_OPEN_PARENTHESIS) { + $openParenthesisCount++; + $expected = null; + continue; + } + + if ($tokenType === IgnoreLexer::TOKEN_CLOSE_PARENTHESIS) { + $openParenthesisCount--; + if ($openParenthesisCount === 0) { + $expected = [IgnoreLexer::TOKEN_COMMA, IgnoreLexer::TOKEN_END]; + } + continue; + } + + if ($openParenthesisCount > 0) { + continue; // waiting for comment end + } + + if ($tokenType === IgnoreLexer::TOKEN_IDENTIFIER) { + $identifiers[] = $content; + $expected = [IgnoreLexer::TOKEN_COMMA, IgnoreLexer::TOKEN_END, IgnoreLexer::TOKEN_OPEN_PARENTHESIS]; + continue; + } + + if ($tokenType === IgnoreLexer::TOKEN_COMMA) { + $expected = [IgnoreLexer::TOKEN_IDENTIFIER]; + continue; + } + } + + if ($openParenthesisCount > 0) { + throw new IgnoreParseException('Unexpected end, unclosed opening parenthesis', $tokenLine ?? 1); + } + + if (count($identifiers) === 0) { + throw new IgnoreParseException('Missing identifier', 1); + } + + return $identifiers; + } + +} diff --git a/src/Parser/SimpleParser.php b/src/Parser/SimpleParser.php new file mode 100644 index 00000000..92d61550 --- /dev/null +++ b/src/Parser/SimpleParser.php @@ -0,0 +1,61 @@ +parseString(FileReader::read($file)); + } catch (ParserErrorsException $e) { + throw new ParserErrorsException($e->getErrors(), $file); + } + } + + /** + * @return Node\Stmt[] + */ + public function parseString(string $sourceCode): array + { + $errorHandler = new Collecting(); + $nodes = $this->parser->parse($sourceCode, $errorHandler); + if ($errorHandler->hasErrors()) { + throw new ParserErrorsException($errorHandler->getErrors(), null); + } + if ($nodes === null) { + throw new ShouldNotHappenException(); + } + + $nodeTraverser = new NodeTraverser(); + $nodeTraverser->addVisitor($this->nameResolver); + $nodeTraverser->addVisitor($this->variadicMethodsVisitor); + $nodeTraverser->addVisitor($this->variadicFunctionsVisitor); + + /** @var array */ + return $nodeTraverser->traverse($nodes); + } + +} diff --git a/src/Parser/StandaloneThrowExprVisitor.php b/src/Parser/StandaloneThrowExprVisitor.php new file mode 100644 index 00000000..d0f23d93 --- /dev/null +++ b/src/Parser/StandaloneThrowExprVisitor.php @@ -0,0 +1,29 @@ +expr instanceof Node\Expr\Throw_) { + return null; + } + + $node->expr->setAttribute(self::ATTRIBUTE_NAME, true); + + return $node; + } + +} diff --git a/src/Parser/StubParser.php b/src/Parser/StubParser.php new file mode 100644 index 00000000..3cb2a763 --- /dev/null +++ b/src/Parser/StubParser.php @@ -0,0 +1,57 @@ +parseString(FileReader::read($file)); + } catch (ParserErrorsException $e) { + throw new ParserErrorsException($e->getErrors(), $file); + } + } + + /** + * @return Node\Stmt[] + */ + public function parseString(string $sourceCode): array + { + $errorHandler = new Collecting(); + $nodes = $this->parser->parse($sourceCode, $errorHandler); + if ($errorHandler->hasErrors()) { + throw new ParserErrorsException($errorHandler->getErrors(), null); + } + if ($nodes === null) { + throw new ShouldNotHappenException(); + } + + $nodeTraverser = new NodeTraverser(); + $nodeTraverser->addVisitor($this->nameResolver); + + /** @var array */ + return $nodeTraverser->traverse($nodes); + } + +} diff --git a/src/Parser/TraitCollectingVisitor.php b/src/Parser/TraitCollectingVisitor.php new file mode 100644 index 00000000..31ed5c52 --- /dev/null +++ b/src/Parser/TraitCollectingVisitor.php @@ -0,0 +1,26 @@ + */ + public array $traits = []; + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Stmt\Trait_) { + return null; + } + + $this->traits[] = $node; + + return null; + } + +} diff --git a/src/Parser/TryCatchTypeVisitor.php b/src/Parser/TryCatchTypeVisitor.php new file mode 100644 index 00000000..87982ee9 --- /dev/null +++ b/src/Parser/TryCatchTypeVisitor.php @@ -0,0 +1,75 @@ +|null> */ + private array $typeStack = []; + + public function beforeTraverse(array $nodes): ?array + { + $this->typeStack = []; + return null; + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt || $node instanceof Node\Expr\Match_) { + if (count($this->typeStack) > 0) { + $node->setAttribute(self::ATTRIBUTE_NAME, $this->typeStack[count($this->typeStack) - 1]); + } + } + + if ($node instanceof Node\FunctionLike) { + $this->typeStack[] = null; + } + + if ($node instanceof Node\Stmt\TryCatch) { + $types = []; + foreach (array_reverse($this->typeStack) as $stackTypes) { + if ($stackTypes === null) { + break; + } + + foreach ($stackTypes as $type) { + $types[] = $type; + } + } + foreach ($node->catches as $catch) { + foreach ($catch->types as $type) { + $types[] = $type->toString(); + } + } + + $this->typeStack[] = $types; + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ( + !$node instanceof Node\Stmt\TryCatch + && !$node instanceof Node\FunctionLike + ) { + return null; + } + + array_pop($this->typeStack); + + return null; + } + +} diff --git a/src/Parser/TypeTraverserInstanceofVisitor.php b/src/Parser/TypeTraverserInstanceofVisitor.php new file mode 100644 index 00000000..6f3e3745 --- /dev/null +++ b/src/Parser/TypeTraverserInstanceofVisitor.php @@ -0,0 +1,57 @@ +depth = 0; + return null; + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Expr\Instanceof_ && $this->depth > 0) { + $node->setAttribute(self::ATTRIBUTE_NAME, true); + return null; + } + + if ( + $node instanceof Node\Expr\StaticCall + && $node->class instanceof Node\Name + && $node->class->toLowerString() === 'phpstan\\type\\typetraverser' + && $node->name instanceof Node\Identifier + && $node->name->toLowerString() === 'map' + ) { + $this->depth++; + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ( + $node instanceof Node\Expr\StaticCall + && $node->class instanceof Node\Name + && $node->class->toLowerString() === 'phpstan\\type\\typetraverser' + && $node->name instanceof Node\Identifier + && $node->name->toLowerString() === 'map' + ) { + $this->depth--; + } + + return null; + } + +} diff --git a/src/Parser/VariadicFunctionsVisitor.php b/src/Parser/VariadicFunctionsVisitor.php new file mode 100644 index 00000000..1b45cd5e --- /dev/null +++ b/src/Parser/VariadicFunctionsVisitor.php @@ -0,0 +1,95 @@ + */ + public static array $cache = []; + + /** @var array */ + private array $variadicFunctions = []; + + public const ATTRIBUTE_NAME = 'variadicFunctions'; + + public function beforeTraverse(array $nodes): ?array + { + $this->topNode = null; + $this->variadicFunctions = []; + $this->inNamespace = null; + $this->inFunction = null; + + return null; + } + + public function enterNode(Node $node): ?Node + { + if ($this->topNode === null) { + $this->topNode = $node; + } + + if ($node instanceof Node\Stmt\Namespace_ && $node->name !== null) { + $this->inNamespace = $node->name->toString(); + } + + if ($node instanceof Node\Stmt\Function_) { + $this->inFunction = $this->inNamespace !== null ? $this->inNamespace . '\\' . $node->name->name : $node->name->name; + } + + if ( + $this->inFunction !== null + && $node instanceof Node\Expr\FuncCall + && $node->name instanceof Name + && in_array((string) $node->name, ParametersAcceptor::VARIADIC_FUNCTIONS, true) + && !array_key_exists($this->inFunction, $this->variadicFunctions) + ) { + $this->variadicFunctions[$this->inFunction] = true; + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Namespace_ && $node->name !== null) { + $this->inNamespace = null; + } + + if ($node instanceof Node\Stmt\Function_ && $this->inFunction !== null) { + $this->variadicFunctions[$this->inFunction] ??= false; + $this->inFunction = null; + } + + return null; + } + + public function afterTraverse(array $nodes): ?array + { + if ($this->topNode !== null && $this->variadicFunctions !== []) { + foreach ($this->variadicFunctions as $name => $variadic) { + self::$cache[$name] = $variadic; + } + $functions = array_filter($this->variadicFunctions, static fn (bool $variadic) => $variadic); + $this->topNode->setAttribute(self::ATTRIBUTE_NAME, $functions); + } + + return null; + } + +} diff --git a/src/Parser/VariadicMethodsVisitor.php b/src/Parser/VariadicMethodsVisitor.php new file mode 100644 index 00000000..01e32b8c --- /dev/null +++ b/src/Parser/VariadicMethodsVisitor.php @@ -0,0 +1,136 @@ + */ + private array $classStack = []; + + private ?string $inMethod = null; + + /** @var array> */ + public static array $cache = []; + + /** @var array> */ + private array $variadicMethods = []; + + public function beforeTraverse(array $nodes): ?array + { + $this->topNode = null; + $this->variadicMethods = []; + $this->inNamespace = null; + $this->classStack = []; + $this->inMethod = null; + + return null; + } + + public function enterNode(Node $node): ?Node + { + if ($this->topNode === null) { + $this->topNode = $node; + } + + if ($node instanceof Node\Stmt\Namespace_ && $node->name !== null) { + $this->inNamespace = $node->name->toString(); + } + + if ($node instanceof Node\Stmt\ClassLike) { + if (!$node->name instanceof Node\Identifier) { + $className = sprintf('%s:%s:%s', self::ANONYMOUS_CLASS_PREFIX, $node->getStartLine(), $node->getEndLine()); + $this->classStack[] = $className; + } else { + $className = $node->name->name; + $this->classStack[] = $this->inNamespace !== null ? $this->inNamespace . '\\' . $className : $className; + } + } + + if ($node instanceof ClassMethod) { + $this->inMethod = $node->name->name; + } + + if ( + $this->inMethod !== null + && $node instanceof Node\Expr\FuncCall + && $node->name instanceof Name + && in_array((string) $node->name, ParametersAcceptor::VARIADIC_FUNCTIONS, true) + ) { + $lastClass = $this->classStack[count($this->classStack) - 1] ?? null; + if ($lastClass !== null) { + if ( + !array_key_exists($lastClass, $this->variadicMethods) + || !array_key_exists($this->inMethod, $this->variadicMethods[$lastClass]) + ) { + $this->variadicMethods[$lastClass][$this->inMethod] = true; + } + } + + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ($node instanceof ClassMethod) { + $lastClass = $this->classStack[count($this->classStack) - 1] ?? null; + if ($lastClass !== null) { + $this->variadicMethods[$lastClass][$this->inMethod] ??= false; + } + $this->inMethod = null; + } + + if ($node instanceof Node\Stmt\ClassLike) { + array_pop($this->classStack); + } + + if ($node instanceof Node\Stmt\Namespace_ && $node->name !== null) { + $this->inNamespace = null; + } + + return null; + } + + public function afterTraverse(array $nodes): ?array + { + if ($this->topNode !== null && $this->variadicMethods !== []) { + $filteredMethods = []; + foreach ($this->variadicMethods as $class => $methods) { + foreach ($methods as $name => $variadic) { + self::$cache[$class][$name] = $variadic; + if (!$variadic) { + continue; + } + + $filteredMethods[$class][$name] = true; + } + } + $this->topNode->setAttribute(self::ATTRIBUTE_NAME, $filteredMethods); + } + + return null; + } + +} diff --git a/src/Php/ComposerPhpVersionFactory.php b/src/Php/ComposerPhpVersionFactory.php new file mode 100644 index 00000000..1925b218 --- /dev/null +++ b/src/Php/ComposerPhpVersionFactory.php @@ -0,0 +1,122 @@ +initialized = true; + + // don't limit minVersion... PHPStan can analyze even PHP5 + $this->maxVersion = new PhpVersion(PhpVersionFactory::MAX_PHP_VERSION); + + // fallback to composer.json based php-version constraint + $composerPhpVersion = $this->getComposerRequireVersion(); + if ($composerPhpVersion === null) { + return; + } + + $parser = new VersionParser(); + $constraint = $parser->parseConstraints($composerPhpVersion); + + if (!$constraint->getLowerBound()->isZero()) { + $minVersion = $this->buildVersion($constraint->getLowerBound()->getVersion(), false); + + if ($minVersion !== null) { + $this->minVersion = new PhpVersion($minVersion->getVersionId()); + } + } + if ($constraint->getUpperBound()->isPositiveInfinity()) { + return; + } + + $this->maxVersion = $this->buildVersion($constraint->getUpperBound()->getVersion(), true); + } + + public function getMinVersion(): ?PhpVersion + { + if ($this->initialized === false) { + $this->initializeVersions(); + } + + return $this->minVersion; + } + + public function getMaxVersion(): ?PhpVersion + { + if ($this->initialized === false) { + $this->initializeVersions(); + } + + return $this->maxVersion; + } + + private function getComposerRequireVersion(): ?string + { + $composerPhpVersion = null; + + if (count($this->composerAutoloaderProjectPaths) > 0) { + $composer = ComposerHelper::getComposerConfig(end($this->composerAutoloaderProjectPaths)); + if ($composer !== null) { + $requiredVersion = $composer['require']['php'] ?? null; + + if (is_string($requiredVersion)) { + $composerPhpVersion = $requiredVersion; + } + } + } + + return $composerPhpVersion; + } + + private function buildVersion(string $version, bool $isMaxVersion): ?PhpVersion + { + $matches = Strings::match($version, '#^(\d+)\.(\d+)(?:\.(\d+))?#'); + if ($matches === null) { + return null; + } + + $major = $matches[1]; + $minor = $matches[2]; + $patch = $matches[3] ?? 0; + $versionId = (int) sprintf('%d%02d%02d', $major, $minor, $patch); + + if ($isMaxVersion && $version === '6.0.0.0-dev') { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP5_VERSION); + } elseif ($isMaxVersion && $version === '8.0.0.0-dev') { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP7_VERSION); + } else { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP_VERSION); + } + + return new PhpVersion($versionId); + } + +} diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php new file mode 100644 index 00000000..5b33ccfa --- /dev/null +++ b/src/Php/PhpVersion.php @@ -0,0 +1,399 @@ +source; + } + + public function getSourceLabel(): string + { + switch ($this->source) { + case self::SOURCE_RUNTIME: + return 'runtime'; + case self::SOURCE_CONFIG: + return 'config'; + case self::SOURCE_COMPOSER_PLATFORM_PHP: + return 'config.platform.php in composer.json'; + } + + return 'unknown'; + } + + public function getVersionId(): int + { + return $this->versionId; + } + + public function getMajorVersionId(): int + { + return (int) floor($this->versionId / 10000); + } + + public function getMinorVersionId(): int + { + return (int) floor(($this->versionId % 10000) / 100); + } + + public function getPatchVersionId(): int + { + return (int) floor($this->versionId % 100); + } + + public function getVersionString(): string + { + $first = $this->getMajorVersionId(); + $second = $this->getMinorVersionId(); + $third = $this->getPatchVersionId(); + + return $first . '.' . $second . ($third !== 0 ? '.' . $third : ''); + } + + public function supportsNullCoalesceAssign(): bool + { + return $this->versionId >= 70400; + } + + public function supportsParameterContravariance(): bool + { + return $this->versionId >= 70400; + } + + public function supportsReturnCovariance(): bool + { + return $this->versionId >= 70400; + } + + public function supportsNoncapturingCatches(): bool + { + return $this->versionId >= 80000; + } + + public function supportsNativeUnionTypes(): bool + { + return $this->versionId >= 80000; + } + + public function deprecatesRequiredParameterAfterOptional(): bool + { + return $this->versionId >= 80000; + } + + public function deprecatesRequiredParameterAfterOptionalNullableAndDefaultNull(): bool + { + return $this->versionId >= 80100; + } + + public function deprecatesRequiredParameterAfterOptionalUnionOrMixed(): bool + { + return $this->versionId >= 80300; + } + + public function supportsLessOverridenParametersWithVariadic(): bool + { + return $this->versionId >= 80000; + } + + public function supportsThrowExpression(): bool + { + return $this->versionId >= 80000; + } + + public function supportsClassConstantOnExpression(): bool + { + return $this->versionId >= 80000; + } + + public function supportsLegacyConstructor(): bool + { + return $this->versionId < 80000; + } + + public function supportsPromotedProperties(): bool + { + return $this->versionId >= 80000; + } + + public function supportsParameterTypeWidening(): bool + { + return $this->versionId >= 70200; + } + + public function supportsUnsetCast(): bool + { + return $this->versionId < 80000; + } + + public function supportsNamedArguments(): bool + { + return $this->versionId >= 80000; + } + + public function throwsTypeErrorForInternalFunctions(): bool + { + return $this->versionId >= 80000; + } + + public function throwsValueErrorForInternalFunctions(): bool + { + return $this->versionId >= 80000; + } + + public function supportsHhPrintfSpecifier(): bool + { + return $this->versionId >= 80000; + } + + public function isEmptyStringValidAliasForNoneInMbSubstituteCharacter(): bool + { + return $this->versionId < 80000; + } + + public function supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter(): bool + { + return $this->versionId >= 70200; + } + + public function isNumericStringValidArgInMbSubstituteCharacter(): bool + { + return $this->versionId < 80000; + } + + public function isNullValidArgInMbSubstituteCharacter(): bool + { + return $this->versionId >= 80000; + } + + public function isInterfaceConstantImplicitlyFinal(): bool + { + return $this->versionId < 80100; + } + + public function supportsFinalConstants(): bool + { + return $this->versionId >= 80100; + } + + public function supportsReadOnlyProperties(): bool + { + return $this->versionId >= 80100; + } + + public function supportsEnums(): bool + { + return $this->versionId >= 80100; + } + + public function supportsPureIntersectionTypes(): bool + { + return $this->versionId >= 80100; + } + + public function supportsCaseInsensitiveConstantNames(): bool + { + return $this->versionId < 80000; + } + + public function hasStricterRoundFunctions(): bool + { + return $this->versionId >= 80000; + } + + public function hasTentativeReturnTypes(): bool + { + return $this->versionId >= 80100; + } + + public function supportsFirstClassCallables(): bool + { + return $this->versionId >= 80100; + } + + public function supportsArrayUnpackingWithStringKeys(): bool + { + return $this->versionId >= 80100; + } + + public function throwsOnInvalidMbStringEncoding(): bool + { + return $this->versionId >= 80000; + } + + public function supportsPassNoneEncodings(): bool + { + return $this->versionId < 70300; + } + + public function producesWarningForFinalPrivateMethods(): bool + { + return $this->versionId >= 80000; + } + + public function deprecatesDynamicProperties(): bool + { + return $this->versionId >= 80200; + } + + public function strSplitReturnsEmptyArray(): bool + { + return $this->versionId >= 80200; + } + + public function supportsDisjunctiveNormalForm(): bool + { + return $this->versionId >= 80200; + } + + public function serializableRequiresMagicMethods(): bool + { + return $this->versionId >= 80100; + } + + public function arrayFunctionsReturnNullWithNonArray(): bool + { + return $this->versionId < 80000; + } + + // see https://www.php.net/manual/en/migration80.incompatible.php#migration80.incompatible.core.string-number-comparision + public function castsNumbersToStringsOnLooseComparison(): bool + { + return $this->versionId >= 80000; + } + + public function nonNumericStringAndIntegerIsFalseOnLooseComparison(): bool + { + return $this->versionId >= 80000; + } + + public function supportsCallableInstanceMethods(): bool + { + return $this->versionId < 80000; + } + + public function supportsJsonValidate(): bool + { + return $this->versionId >= 80300; + } + + public function supportsConstantsInTraits(): bool + { + return $this->versionId >= 80200; + } + + public function supportsNativeTypesInClassConstants(): bool + { + return $this->versionId >= 80300; + } + + public function supportsAbstractTraitMethods(): bool + { + return $this->versionId >= 80000; + } + + public function supportsOverrideAttribute(): bool + { + return $this->versionId >= 80300; + } + + public function supportsDynamicClassConstantFetch(): bool + { + return $this->versionId >= 80300; + } + + public function supportsReadOnlyClasses(): bool + { + return $this->versionId >= 80200; + } + + public function supportsReadOnlyAnonymousClasses(): bool + { + return $this->versionId >= 80300; + } + + public function supportsNeverReturnTypeInArrowFunction(): bool + { + return $this->versionId >= 80200; + } + + public function supportsPregUnmatchedAsNull(): bool + { + // while PREG_UNMATCHED_AS_NULL is defined in php-src since 7.2.x it starts working as expected with 7.4.x + // https://3v4l.org/v3HE4 + return $this->versionId >= 70400; + } + + public function supportsPregCaptureOnlyNamedGroups(): bool + { + // https://php.watch/versions/8.2/preg-n-no-capture-modifier + return $this->versionId >= 80200; + } + + public function supportsPropertyHooks(): bool + { + return $this->versionId >= 80400; + } + + public function supportsAsymmetricVisibility(): bool + { + return $this->versionId >= 80400; + } + + public function supportsLazyObjects(): bool + { + return $this->versionId >= 80400; + } + + public function hasDateTimeExceptions(): bool + { + return $this->versionId >= 80300; + } + + public function isCurloptUrlCheckingFileSchemeWithOpenBasedir(): bool + { + // Before PHP 8.0, when setting CURLOPT_URL, an unparsable URL or a file:// scheme would fail if open_basedir is used + // https://github.com/php/php-src/blob/php-7.4.33/ext/curl/interface.c#L139-L158 + // https://github.com/php/php-src/blob/php-8.0.0/ext/curl/interface.c#L128-L130 + return $this->versionId < 80000; + } + + public function highlightStringDoesNotReturnFalse(): bool + { + return $this->versionId >= 80400; + } + + public function deprecatesImplicitlyNullableParameterTypes(): bool + { + return $this->versionId >= 80400; + } + + public function substrReturnFalseInsteadOfEmptyString(): bool + { + return $this->versionId < 80000; + } + +} diff --git a/src/Php/PhpVersionFactory.php b/src/Php/PhpVersionFactory.php new file mode 100644 index 00000000..3ffccfb8 --- /dev/null +++ b/src/Php/PhpVersionFactory.php @@ -0,0 +1,45 @@ +versionId; + if ($versionId !== null) { + $source = PhpVersion::SOURCE_CONFIG; + } elseif ($this->composerPhpVersion !== null) { + $parts = explode('.', $this->composerPhpVersion); + $tmp = (int) $parts[0] * 10000 + (int) ($parts[1] ?? 0) * 100 + (int) ($parts[2] ?? 0); + $tmp = max($tmp, self::MIN_PHP_VERSION); + $versionId = min($tmp, self::MAX_PHP_VERSION); + $source = PhpVersion::SOURCE_COMPOSER_PLATFORM_PHP; + } else { + $versionId = PHP_VERSION_ID; + $source = PhpVersion::SOURCE_RUNTIME; + } + + return new PhpVersion($versionId, $source); + } + +} diff --git a/src/Php/PhpVersionFactoryFactory.php b/src/Php/PhpVersionFactoryFactory.php new file mode 100644 index 00000000..8fc1270c --- /dev/null +++ b/src/Php/PhpVersionFactoryFactory.php @@ -0,0 +1,63 @@ +composerAutoloaderProjectPaths) > 0) { + $composerJsonPath = end($this->composerAutoloaderProjectPaths) . '/composer.json'; + if (is_file($composerJsonPath)) { + try { + $composerJsonContents = FileReader::read($composerJsonPath); + $composer = Json::decode($composerJsonContents, Json::FORCE_ARRAY); + $platformVersion = $composer['config']['platform']['php'] ?? null; + if (is_string($platformVersion)) { + $composerPhpVersion = $platformVersion; + } + } catch (CouldNotReadFileException | JsonException) { + // pass + } + } + } + + $versionId = null; + + if (is_int($this->phpVersion)) { + $versionId = $this->phpVersion; + } + + if (is_array($this->phpVersion)) { + $versionId = $this->phpVersion['min']; + } + + return new PhpVersionFactory($versionId, $composerPhpVersion); + } + +} diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php new file mode 100644 index 00000000..5ae68459 --- /dev/null +++ b/src/Php/PhpVersions.php @@ -0,0 +1,47 @@ +phpVersions; + } + + public function supportsNoncapturingCatches(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + + public function producesWarningForFinalPrivateMethods(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + + public function supportsNamedArguments(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + + public function supportsNamedArgumentAfterUnpackedArgument(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80100, null)->isSuperTypeOf($this->phpVersions)->result; + } + +} diff --git a/src/PhpDoc/ConstExprNodeResolver.php b/src/PhpDoc/ConstExprNodeResolver.php new file mode 100644 index 00000000..2f0d8862 --- /dev/null +++ b/src/PhpDoc/ConstExprNodeResolver.php @@ -0,0 +1,140 @@ +resolveArrayNode($node, $nameScope); + } + + if ($node instanceof ConstExprFalseNode) { + return new ConstantBooleanType(false); + } + + if ($node instanceof ConstExprTrueNode) { + return new ConstantBooleanType(true); + } + + if ($node instanceof ConstExprFloatNode) { + return new ConstantFloatType((float) $node->value); + } + + if ($node instanceof ConstExprIntegerNode) { + return new ConstantIntegerType((int) $node->value); + } + + if ($node instanceof ConstExprNullNode) { + return new NullType(); + } + + if ($node instanceof ConstExprStringNode) { + return new ConstantStringType($node->value); + } + + if ($node instanceof ConstFetchNode) { + if ($nameScope->getClassName() !== null) { + switch (strtolower($node->className)) { + case 'static': + case 'self': + $className = $nameScope->getClassName(); + break; + + case 'parent': + if ($this->getReflectionProvider()->hasClass($nameScope->getClassName())) { + $classReflection = $this->getReflectionProvider()->getClass($nameScope->getClassName()); + if ($classReflection->getParentClass() === null) { + return new ErrorType(); + + } + + $className = $classReflection->getParentClass()->getName(); + } + break; + } + } + if (!isset($className)) { + $className = $nameScope->resolveStringName($node->className); + } + if (!$this->getReflectionProvider()->hasClass($className)) { + return new ErrorType(); + } + $classReflection = $this->getReflectionProvider()->getClass($className); + if (!$classReflection->hasConstant($node->name)) { + return new ErrorType(); + } + if ($classReflection->isEnum() && $classReflection->hasEnumCase($node->name)) { + return new EnumCaseObjectType($classReflection->getName(), $node->name); + } + + $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($node->name); + if ($reflectionConstant === false) { + return new ErrorType(); + } + $declaringClass = $reflectionConstant->getDeclaringClass(); + + return $this->initializerExprTypeResolver->getType( + $reflectionConstant->getValueExpression(), + InitializerExprContext::fromClass($declaringClass->getName(), $declaringClass->getFileName() ?: null), + ); + } + + return new ErrorType(); + } + + private function resolveArrayNode(ConstExprArrayNode $node, NameScope $nameScope): Type + { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($node->items as $item) { + if ($item->key === null) { + $key = null; + } else { + $key = $this->resolve($item->key, $nameScope); + } + $arrayBuilder->setOffsetValueType($key, $this->resolve($item->value, $nameScope)); + } + + return $arrayBuilder->getArray(); + } + + private function getReflectionProvider(): ReflectionProvider + { + return $this->reflectionProviderProvider->getReflectionProvider(); + } + +} diff --git a/src/PhpDoc/DefaultStubFilesProvider.php b/src/PhpDoc/DefaultStubFilesProvider.php new file mode 100644 index 00000000..41390949 --- /dev/null +++ b/src/PhpDoc/DefaultStubFilesProvider.php @@ -0,0 +1,75 @@ +cachedFiles !== null) { + return $this->cachedFiles; + } + + $files = $this->stubFiles; + $extensions = $this->container->getServicesByTag(StubFilesExtension::EXTENSION_TAG); + foreach ($extensions as $extension) { + foreach ($extension->getFiles() as $extensionFile) { + $files[] = $extensionFile; + } + } + + return $this->cachedFiles = $files; + } + + public function getProjectStubFiles(): array + { + if ($this->cachedProjectFiles !== null) { + return $this->cachedProjectFiles; + } + + $filteredStubFiles = $this->getStubFiles(); + foreach ($this->composerAutoloaderProjectPaths as $composerAutoloaderProjectPath) { + $composerConfig = ComposerHelper::getComposerConfig($composerAutoloaderProjectPath); + if ($composerConfig === null) { + continue; + } + + $vendorDir = ComposerHelper::getVendorDirFromComposerConfig($composerAutoloaderProjectPath, $composerConfig); + $vendorDir = strtr($vendorDir, '\\', '/'); + $filteredStubFiles = array_filter( + $filteredStubFiles, + static fn (string $file): bool => !str_contains(strtr($file, '\\', '/'), $vendorDir) + ); + } + + return $this->cachedProjectFiles = array_values($filteredStubFiles); + } + +} diff --git a/src/PhpDoc/DirectTypeNodeResolverExtensionRegistryProvider.php b/src/PhpDoc/DirectTypeNodeResolverExtensionRegistryProvider.php new file mode 100644 index 00000000..68fc7b51 --- /dev/null +++ b/src/PhpDoc/DirectTypeNodeResolverExtensionRegistryProvider.php @@ -0,0 +1,18 @@ +registry; + } + +} diff --git a/src/PhpDoc/JsonValidateStubFilesExtension.php b/src/PhpDoc/JsonValidateStubFilesExtension.php new file mode 100644 index 00000000..c4c47bfa --- /dev/null +++ b/src/PhpDoc/JsonValidateStubFilesExtension.php @@ -0,0 +1,24 @@ +phpVersion->supportsJsonValidate()) { + return []; + } + + return [__DIR__ . '/../../stubs/json_validate.stub']; + } + +} diff --git a/src/PhpDoc/LazyTypeNodeResolverExtensionRegistryProvider.php b/src/PhpDoc/LazyTypeNodeResolverExtensionRegistryProvider.php new file mode 100644 index 00000000..b70744c0 --- /dev/null +++ b/src/PhpDoc/LazyTypeNodeResolverExtensionRegistryProvider.php @@ -0,0 +1,29 @@ +registry === null) { + $this->registry = new TypeNodeResolverExtensionAwareRegistry( + $this->container->getByType(TypeNodeResolver::class), + $this->container->getServicesByTag(TypeNodeResolverExtension::EXTENSION_TAG), + ); + } + + return $this->registry; + } + +} diff --git a/src/PhpDoc/PhpDocBlock.php b/src/PhpDoc/PhpDocBlock.php new file mode 100644 index 00000000..38beefe9 --- /dev/null +++ b/src/PhpDoc/PhpDocBlock.php @@ -0,0 +1,409 @@ + $parameterNameMapping + * @param array $parents + */ + private function __construct( + private string $docComment, + private ?string $file, + private ClassReflection $classReflection, + private ?string $trait, + private bool $explicit, + private array $parameterNameMapping, + private array $parents, + ) + { + } + + public function getDocComment(): string + { + return $this->docComment; + } + + public function getFile(): ?string + { + return $this->file; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getTrait(): ?string + { + return $this->trait; + } + + public function isExplicit(): bool + { + return $this->explicit; + } + + /** + * @return array + */ + public function getParents(): array + { + return $this->parents; + } + + /** + * @template T + * @param array $array + * @return array + */ + public function transformArrayKeysWithParameterNameMapping(array $array): array + { + $newArray = []; + foreach ($array as $key => $value) { + if (!array_key_exists($key, $this->parameterNameMapping)) { + continue; + } + $newArray[$this->parameterNameMapping[$key]] = $value; + } + + return $newArray; + } + + public function transformConditionalReturnTypeWithParameterNameMapping(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $this->parameterNameMapping)) { + $type = $type->changeParameterName('$' . $this->parameterNameMapping[$parameterName]); + } + } + + return $traverse($type); + }); + } + + public function transformAssertTagParameterWithParameterNameMapping(AssertTagParameter $parameter): AssertTagParameter + { + $parameterName = substr($parameter->getParameterName(), 1); + if (array_key_exists($parameterName, $this->parameterNameMapping)) { + $parameter = $parameter->changeParameterName('$' . $this->parameterNameMapping[$parameterName]); + } + + return $parameter; + } + + public static function resolvePhpDocBlockForProperty( + ?string $docComment, + ClassReflection $classReflection, + ?string $trait, + string $propertyName, + ?string $file, + ?bool $explicit, + ): self + { + $docBlocksFromParents = []; + foreach (self::getParentReflections($classReflection) as $parentReflection) { + $oneResult = self::resolvePropertyPhpDocBlockFromClass( + $parentReflection, + $propertyName, + $explicit ?? $docComment !== null, + ); + + if ($oneResult === null) { // Null if it is private or from a wrong trait. + continue; + } + + $docBlocksFromParents[] = $oneResult; + } + + return new self( + $docComment ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $file, + $classReflection, + $trait, + $explicit ?? true, + [], + $docBlocksFromParents, + ); + } + + public static function resolvePhpDocBlockForConstant( + ?string $docComment, + ClassReflection $classReflection, + string $constantName, + ?string $file, + ?bool $explicit, + ): self + { + $docBlocksFromParents = []; + foreach (self::getParentReflections($classReflection) as $parentReflection) { + $oneResult = self::resolveConstantPhpDocBlockFromClass( + $parentReflection, + $constantName, + $explicit ?? $docComment !== null, + ); + + if ($oneResult === null) { // Null if it is private or from a wrong trait. + continue; + } + + $docBlocksFromParents[] = $oneResult; + } + + return new self( + $docComment ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $file, + $classReflection, + null, + $explicit ?? true, + [], + $docBlocksFromParents, + ); + } + + /** + * @param array $originalPositionalParameterNames + * @param array $newPositionalParameterNames + */ + public static function resolvePhpDocBlockForMethod( + ?string $docComment, + ClassReflection $classReflection, + ?string $trait, + string $methodName, + ?string $file, + ?bool $explicit, + array $originalPositionalParameterNames, + array $newPositionalParameterNames, + ): self + { + $docBlocksFromParents = []; + foreach (self::getParentReflections($classReflection) as $parentReflection) { + $oneResult = self::resolveMethodPhpDocBlockFromClass( + $parentReflection, + $methodName, + $explicit ?? $docComment !== null, + $newPositionalParameterNames, + ); + + if ($oneResult === null) { // Null if it is private or from a wrong trait. + continue; + } + + $docBlocksFromParents[] = $oneResult; + } + + foreach ($classReflection->getTraits(true) as $traitReflection) { + if (!$traitReflection->hasNativeMethod($methodName)) { + continue; + } + $traitMethod = $traitReflection->getNativeMethod($methodName); + $abstract = $traitMethod->isAbstract(); + if (is_bool($abstract)) { + if (!$abstract) { + continue; + } + } elseif (!$abstract->yes()) { + continue; + } + + $methodVariant = $traitMethod->getOnlyVariant(); + $positionalMethodParameterNames = []; + foreach ($methodVariant->getParameters() as $methodParameter) { + $positionalMethodParameterNames[] = $methodParameter->getName(); + } + + $docBlocksFromParents[] = new self( + $traitMethod->getDocComment() ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $classReflection->getFileName(), + $classReflection, + $traitReflection->getName(), + $explicit ?? $traitMethod->getDocComment() !== null, + self::remapParameterNames($newPositionalParameterNames, $positionalMethodParameterNames), + [], + ); + } + + return new self( + $docComment ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $file, + $classReflection, + $trait, + $explicit ?? true, + self::remapParameterNames($originalPositionalParameterNames, $newPositionalParameterNames), + $docBlocksFromParents, + ); + } + + /** + * @param array $originalPositionalParameterNames + * @param array $newPositionalParameterNames + * @return array + */ + private static function remapParameterNames( + array $originalPositionalParameterNames, + array $newPositionalParameterNames, + ): array + { + $parameterNameMapping = []; + foreach ($originalPositionalParameterNames as $i => $parameterName) { + if (!array_key_exists($i, $newPositionalParameterNames)) { + continue; + } + $parameterNameMapping[$newPositionalParameterNames[$i]] = $parameterName; + } + + return $parameterNameMapping; + } + + /** + * @return array + */ + private static function getParentReflections(ClassReflection $classReflection): array + { + $result = []; + + $parent = $classReflection->getParentClass(); + if ($parent !== null) { + $result[] = $parent; + } + + foreach ($classReflection->getInterfaces() as $interface) { + $result[] = $interface; + } + + return $result; + } + + private static function resolveConstantPhpDocBlockFromClass( + ClassReflection $classReflection, + string $name, + bool $explicit, + ): ?self + { + if ($classReflection->hasConstant($name)) { + $parentReflection = $classReflection->getConstant($name); + if ($parentReflection->isPrivate()) { + return null; + } + + $classReflection = $parentReflection->getDeclaringClass(); + + return self::resolvePhpDocBlockForConstant( + $parentReflection->getDocComment() ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $classReflection, + $name, + $classReflection->getFileName(), + $explicit, + ); + } + + return null; + } + + private static function resolvePropertyPhpDocBlockFromClass( + ClassReflection $classReflection, + string $name, + bool $explicit, + ): ?self + { + if ($classReflection->hasNativeProperty($name)) { + $parentReflection = $classReflection->getNativeProperty($name); + if ($parentReflection->isPrivate()) { + return null; + } + + $classReflection = $parentReflection->getDeclaringClass(); + $traitReflection = $parentReflection->getDeclaringTrait(); + + $trait = $traitReflection !== null + ? $traitReflection->getName() + : null; + + return self::resolvePhpDocBlockForProperty( + $parentReflection->getDocComment() ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $classReflection, + $trait, + $name, + $classReflection->getFileName(), + $explicit, + ); + } + + return null; + } + + /** + * @param array $positionalParameterNames + */ + private static function resolveMethodPhpDocBlockFromClass( + ClassReflection $classReflection, + string $name, + bool $explicit, + array $positionalParameterNames, + ): ?self + { + if ($classReflection->hasNativeMethod($name)) { + $parentReflection = $classReflection->getNativeMethod($name); + if ($parentReflection->isPrivate()) { + return null; + } + + $classReflection = $parentReflection->getDeclaringClass(); + $traitReflection = null; + if ($parentReflection instanceof PhpMethodReflection || $parentReflection instanceof ResolvedMethodReflection) { + $traitReflection = $parentReflection->getDeclaringTrait(); + } + $methodVariants = $parentReflection->getVariants(); + $positionalMethodParameterNames = []; + $lowercaseMethodName = strtolower($parentReflection->getName()); + if ( + count($methodVariants) === 1 + && $lowercaseMethodName !== '__construct' + && $lowercaseMethodName !== strtolower($parentReflection->getDeclaringClass()->getName()) + ) { + $methodParameters = $methodVariants[0]->getParameters(); + foreach ($methodParameters as $methodParameter) { + $positionalMethodParameterNames[] = $methodParameter->getName(); + } + } else { + $positionalMethodParameterNames = $positionalParameterNames; + } + + $trait = $traitReflection !== null + ? $traitReflection->getName() + : null; + + return self::resolvePhpDocBlockForMethod( + $parentReflection->getDocComment() ?? ResolvedPhpDocBlock::EMPTY_DOC_STRING, + $classReflection, + $trait, + $name, + $classReflection->getFileName(), + $explicit, + $positionalParameterNames, + $positionalMethodParameterNames, + ); + } + + return null; + } + +} diff --git a/src/PhpDoc/PhpDocInheritanceResolver.php b/src/PhpDoc/PhpDocInheritanceResolver.php new file mode 100644 index 00000000..4a2a431d --- /dev/null +++ b/src/PhpDoc/PhpDocInheritanceResolver.php @@ -0,0 +1,157 @@ +docBlockTreeToResolvedDocBlock($phpDocBlock, $declaringTraitName, null, $propertyName, null); + } + + public function resolvePhpDocForConstant( + ?string $docComment, + ClassReflection $classReflection, + ?string $classReflectionFileName, + string $constantName, + ): ResolvedPhpDocBlock + { + $phpDocBlock = PhpDocBlock::resolvePhpDocBlockForConstant( + $docComment, + $classReflection, + $constantName, + $classReflectionFileName, + null, + ); + + return $this->docBlockTreeToResolvedDocBlock($phpDocBlock, null, null, null, $constantName); + } + + /** + * @param array $positionalParameterNames + */ + public function resolvePhpDocForMethod( + ?string $docComment, + ?string $fileName, + ClassReflection $classReflection, + ?string $declaringTraitName, + string $methodName, + array $positionalParameterNames, + ): ResolvedPhpDocBlock + { + $phpDocBlock = PhpDocBlock::resolvePhpDocBlockForMethod( + $docComment, + $classReflection, + $declaringTraitName, + $methodName, + $fileName, + null, + $positionalParameterNames, + $positionalParameterNames, + ); + + return $this->docBlockTreeToResolvedDocBlock($phpDocBlock, $phpDocBlock->getTrait(), $methodName, null, null); + } + + private function docBlockTreeToResolvedDocBlock(PhpDocBlock $phpDocBlock, ?string $traitName, ?string $functionName, ?string $propertyName, ?string $constantName): ResolvedPhpDocBlock + { + $parents = []; + $parentPhpDocBlocks = []; + + foreach ($phpDocBlock->getParents() as $parentPhpDocBlock) { + if ( + $functionName !== null + && strtolower($functionName) === '__construct' + && $parentPhpDocBlock->getClassReflection()->isBuiltin() + ) { + continue; + } + $parents[] = $this->docBlockTreeToResolvedDocBlock( + $parentPhpDocBlock, + $parentPhpDocBlock->getTrait(), + $functionName, + $propertyName, + $constantName, + ); + $parentPhpDocBlocks[] = $parentPhpDocBlock; + } + + $oneResolvedDockBlock = $this->docBlockToResolvedDocBlock($phpDocBlock, $traitName, $functionName, $propertyName, $constantName); + return $oneResolvedDockBlock->merge($parents, $parentPhpDocBlocks); + } + + private function docBlockToResolvedDocBlock(PhpDocBlock $phpDocBlock, ?string $traitName, ?string $functionName, ?string $propertyName, ?string $constantName): ResolvedPhpDocBlock + { + $classReflection = $phpDocBlock->getClassReflection(); + if ($functionName !== null && $classReflection->getNativeReflection()->hasMethod($functionName)) { + $methodReflection = $classReflection->getNativeReflection()->getMethod($functionName); + $stub = $this->stubPhpDocProvider->findMethodPhpDoc($classReflection->getName(), $classReflection->getName(), $functionName, array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $methodReflection->getParameters())); + if ($stub !== null) { + return $stub; + } + } + + if ($propertyName !== null && $classReflection->getNativeReflection()->hasProperty($propertyName)) { + $stub = $this->stubPhpDocProvider->findPropertyPhpDoc($classReflection->getName(), $propertyName); + + if ($stub === null) { + $propertyReflection = $classReflection->getNativeReflection()->getProperty($propertyName); + + $propertyDeclaringClass = $propertyReflection->getBetterReflection()->getDeclaringClass(); + + if ($propertyDeclaringClass->isTrait() && (! $propertyReflection->getDeclaringClass()->isTrait() || $propertyReflection->getDeclaringClass()->getName() !== $propertyDeclaringClass->getName())) { + $stub = $this->stubPhpDocProvider->findPropertyPhpDoc($propertyDeclaringClass->getName(), $propertyName); + } + } + if ($stub !== null) { + return $stub; + } + } + + if ($constantName !== null && $classReflection->getNativeReflection()->hasConstant($constantName)) { + $stub = $this->stubPhpDocProvider->findClassConstantPhpDoc($classReflection->getName(), $constantName); + if ($stub !== null) { + return $stub; + } + } + + return $this->fileTypeMapper->getResolvedPhpDoc( + $phpDocBlock->getFile(), + $classReflection->getName(), + $traitName, + $functionName, + $phpDocBlock->getDocComment(), + ); + } + +} diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php new file mode 100644 index 00000000..0e7e5a79 --- /dev/null +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -0,0 +1,754 @@ + + */ + public function resolveVarTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + $resolvedByTag = []; + foreach (['@var', '@phan-var', '@psalm-var', '@phpstan-var'] as $tagName) { + $tagResolved = []; + foreach ($phpDocNode->getVarTagValues($tagName) as $tagValue) { + $type = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + if ($this->shouldSkipType($tagName, $type)) { + continue; + } + if ($tagValue->variableName !== '') { + $variableName = substr($tagValue->variableName, 1); + $resolved[$variableName] = new VarTag($type); + } else { + $varTag = new VarTag($type); + $tagResolved[] = $varTag; + } + } + + if (count($tagResolved) === 0) { + continue; + } + + $resolvedByTag[] = $tagResolved; + } + + if (count($resolvedByTag) > 0) { + return array_reverse($resolvedByTag)[0]; + } + + return $resolved; + } + + /** + * @return array + */ + public function resolvePropertyTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@property', '@phpstan-property'] as $tagName) { + foreach ($phpDocNode->getPropertyTagValues($tagName) as $tagValue) { + $propertyName = substr($tagValue->propertyName, 1); + $propertyType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + + $resolved[$propertyName] = new PropertyTag( + $propertyType, + $propertyType, + ); + } + } + + foreach (['@property-read', '@phpstan-property-read'] as $tagName) { + foreach ($phpDocNode->getPropertyReadTagValues($tagName) as $tagValue) { + $propertyName = substr($tagValue->propertyName, 1); + $propertyType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + + $writableType = null; + if (array_key_exists($propertyName, $resolved)) { + $writableType = $resolved[$propertyName]->getWritableType(); + } + + $resolved[$propertyName] = new PropertyTag( + $propertyType, + $writableType, + ); + } + } + + foreach (['@property-write', '@phpstan-property-write'] as $tagName) { + foreach ($phpDocNode->getPropertyWriteTagValues($tagName) as $tagValue) { + $propertyName = substr($tagValue->propertyName, 1); + $propertyType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + + $readableType = null; + if (array_key_exists($propertyName, $resolved)) { + $readableType = $resolved[$propertyName]->getReadableType(); + } + + $resolved[$propertyName] = new PropertyTag( + $readableType, + $propertyType, + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveMethodTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + $originalNameScope = $nameScope; + + foreach (['@method', '@phan-method', '@psalm-method', '@phpstan-method'] as $tagName) { + foreach ($phpDocNode->getMethodTagValues($tagName) as $tagValue) { + $nameScope = $originalNameScope; + $templateTags = []; + + if (count($tagValue->templateTypes) > 0 && $nameScope->getClassName() !== null) { + foreach ($tagValue->templateTypes as $templateType) { + $templateTags[$templateType->name] = new TemplateTag( + $templateType->name, + $templateType->bound !== null + ? $this->typeNodeResolver->resolve($templateType->bound, $nameScope) + : new MixedType(), + $templateType->default !== null + ? $this->typeNodeResolver->resolve($templateType->default, $nameScope) + : null, + TemplateTypeVariance::createInvariant(), + ); + } + + $templateTypeScope = TemplateTypeScope::createWithMethod($nameScope->getClassName(), $tagValue->methodName); + $templateTypeMap = new TemplateTypeMap(array_map(static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), $templateTags)); + $nameScope = $nameScope->withTemplateTypeMap($templateTypeMap); + } + + $parameters = []; + foreach ($tagValue->parameters as $parameterNode) { + $parameterName = substr($parameterNode->parameterName, 1); + $type = $parameterNode->type !== null + ? $this->typeNodeResolver->resolve($parameterNode->type, $nameScope) + : new MixedType(); + if ($parameterNode->defaultValue instanceof ConstExprNullNode) { + $type = TypeCombinator::addNull($type); + } + $defaultValue = null; + if ($parameterNode->defaultValue !== null) { + $defaultValue = $this->constExprNodeResolver->resolve($parameterNode->defaultValue, $nameScope); + } + + $parameters[$parameterName] = new MethodTagParameter( + $type, + $parameterNode->isReference + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $parameterNode->isVariadic || $parameterNode->defaultValue !== null, + $parameterNode->isVariadic, + $defaultValue, + ); + } + + $resolved[$tagValue->methodName] = new MethodTag( + $tagValue->returnType !== null + ? $this->typeNodeResolver->resolve($tagValue->returnType, $nameScope) + : new MixedType(), + $tagValue->isStatic, + $parameters, + $templateTags, + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveExtendsTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@extends', '@phan-extends', '@phan-inherits', '@template-extends', '@phpstan-extends'] as $tagName) { + foreach ($phpDocNode->getExtendsTagValues($tagName) as $tagValue) { + $resolved[$nameScope->resolveStringName($tagValue->type->type->name)] = new ExtendsTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveImplementsTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@implements', '@template-implements', '@phpstan-implements'] as $tagName) { + foreach ($phpDocNode->getImplementsTagValues($tagName) as $tagValue) { + $resolved[$nameScope->resolveStringName($tagValue->type->type->name)] = new ImplementsTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveUsesTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@use', '@template-use', '@phpstan-use'] as $tagName) { + foreach ($phpDocNode->getUsesTagValues($tagName) as $tagValue) { + $resolved[$nameScope->resolveStringName($tagValue->type->type->name)] = new UsesTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveTemplateTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + $resolvedPrefix = []; + + $prefixPriority = [ + '' => 0, + 'phan' => 1, + 'psalm' => 2, + 'phpstan' => 3, + ]; + + foreach ($phpDocNode->getTags() as $phpDocTagNode) { + $valueNode = $phpDocTagNode->value; + if (!$valueNode instanceof TemplateTagValueNode) { + continue; + } + + $tagName = $phpDocTagNode->name; + if (in_array($tagName, ['@template', '@phan-template', '@psalm-template', '@phpstan-template'], true)) { + $variance = TemplateTypeVariance::createInvariant(); + } elseif (in_array($tagName, ['@template-covariant', '@psalm-template-covariant', '@phpstan-template-covariant'], true)) { + $variance = TemplateTypeVariance::createCovariant(); + } elseif (in_array($tagName, ['@template-contravariant', '@psalm-template-contravariant', '@phpstan-template-contravariant'], true)) { + $variance = TemplateTypeVariance::createContravariant(); + } else { + continue; + } + + if (str_starts_with($tagName, '@phan-')) { + $prefix = 'phan'; + } elseif (str_starts_with($tagName, '@psalm-')) { + $prefix = 'psalm'; + } elseif (str_starts_with($tagName, '@phpstan-')) { + $prefix = 'phpstan'; + } else { + $prefix = ''; + } + + if (isset($resolved[$valueNode->name])) { + $setPrefix = $resolvedPrefix[$valueNode->name]; + if ($prefixPriority[$prefix] <= $prefixPriority[$setPrefix]) { + continue; + } + } + + $nameScopeWithoutCurrent = $nameScope->unsetTemplateType($valueNode->name); + + $resolved[$valueNode->name] = new TemplateTag( + $valueNode->name, + $valueNode->bound !== null ? $this->typeNodeResolver->resolve($valueNode->bound, $nameScopeWithoutCurrent) : new MixedType(true), + $valueNode->default !== null ? $this->typeNodeResolver->resolve($valueNode->default, $nameScopeWithoutCurrent) : null, + $variance, + ); + $resolvedPrefix[$valueNode->name] = $prefix; + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveParamTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@param', '@phan-param', '@psalm-param', '@phpstan-param'] as $tagName) { + foreach ($phpDocNode->getParamTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $parameterType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + if ($this->shouldSkipType($tagName, $parameterType)) { + continue; + } + + $resolved[$parameterName] = new ParamTag( + $parameterType, + $tagValue->isVariadic, + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveParamOutTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + if (!method_exists($phpDocNode, 'getParamOutTypeTagValues')) { + return []; + } + + $resolved = []; + + foreach (['@param-out', '@psalm-param-out', '@phpstan-param-out'] as $tagName) { + foreach ($phpDocNode->getParamOutTypeTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $parameterType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + if ($this->shouldSkipType($tagName, $parameterType)) { + continue; + } + + $resolved[$parameterName] = new ParamOutTag( + $parameterType, + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveParamImmediatelyInvokedCallable(PhpDocNode $phpDocNode): array + { + $parameters = []; + foreach (['@param-immediately-invoked-callable', '@phpstan-param-immediately-invoked-callable'] as $tagName) { + foreach ($phpDocNode->getParamImmediatelyInvokedCallableTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $parameters[$parameterName] = true; + } + } + foreach (['@param-later-invoked-callable', '@phpstan-param-later-invoked-callable'] as $tagName) { + foreach ($phpDocNode->getParamLaterInvokedCallableTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $parameters[$parameterName] = false; + } + } + + return $parameters; + } + + /** + * @return array + */ + public function resolveParamClosureThisTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $closureThisTypes = []; + foreach (['@param-closure-this', '@phpstan-param-closure-this'] as $tagName) { + foreach ($phpDocNode->getParamClosureThisTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $closureThisTypes[$parameterName] = new ParamClosureThisTag( + TypeCombinator::intersect( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + new ObjectWithoutClassType(), + ), + ); + } + } + + return $closureThisTypes; + } + + public function resolveReturnTag(PhpDocNode $phpDocNode, NameScope $nameScope): ?ReturnTag + { + $resolved = null; + + foreach (['@return', '@phan-return', '@phan-real-return', '@psalm-return', '@phpstan-return'] as $tagName) { + foreach ($phpDocNode->getReturnTagValues($tagName) as $tagValue) { + $type = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + if ($this->shouldSkipType($tagName, $type)) { + continue; + } + $resolved = new ReturnTag($type, true); + } + } + + return $resolved; + } + + public function resolveThrowsTags(PhpDocNode $phpDocNode, NameScope $nameScope): ?ThrowsTag + { + foreach (['@phpstan-throws', '@throws'] as $tagName) { + $types = []; + + foreach ($phpDocNode->getThrowsTagValues($tagName) as $tagValue) { + $type = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + if ($this->shouldSkipType($tagName, $type)) { + continue; + } + + $types[] = $type; + } + + if (count($types) > 0) { + return new ThrowsTag(TypeCombinator::union(...$types)); + } + } + + return null; + } + + /** + * @return array + */ + public function resolveMixinTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + return array_map(fn (MixinTagValueNode $mixinTagValueNode): MixinTag => new MixinTag( + $this->typeNodeResolver->resolve($mixinTagValueNode->type, $nameScope), + ), $phpDocNode->getMixinTagValues()); + } + + /** + * @return array + */ + public function resolveRequireExtendsTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@psalm-require-extends', '@phpstan-require-extends'] as $tagName) { + foreach ($phpDocNode->getRequireExtendsTagValues($tagName) as $tagValue) { + $resolved[] = new RequireExtendsTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveRequireImplementsTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@psalm-require-implements', '@phpstan-require-implements'] as $tagName) { + foreach ($phpDocNode->getRequireImplementsTagValues($tagName) as $tagValue) { + $resolved[] = new RequireImplementsTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@phan-type', '@psalm-type', '@phpstan-type'] as $tagName) { + foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { + $alias = $typeAliasTagValue->alias; + $typeNode = $typeAliasTagValue->type; + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope); + } + } + + return $resolved; + } + + /** + * @return array + */ + public function resolveTypeAliasImportTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@psalm-import-type', '@phpstan-import-type'] as $tagName) { + foreach ($phpDocNode->getTypeAliasImportTagValues($tagName) as $typeAliasImportTagValue) { + $importedAlias = $typeAliasImportTagValue->importedAlias; + $importedFrom = $nameScope->resolveStringName($typeAliasImportTagValue->importedFrom->name); + $importedAs = $typeAliasImportTagValue->importedAs; + $resolved[$importedAs ?? $importedAlias] = new TypeAliasImportTag($importedAlias, $importedFrom, $importedAs); + } + } + + return $resolved; + } + + /** + * @return AssertTag[] + */ + public function resolveAssertTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + foreach (['@phpstan', '@psalm', '@phan'] as $prefix) { + $resolved = array_merge( + $this->resolveAssertTagsFor($phpDocNode, $nameScope, $prefix . '-assert', AssertTag::NULL), + $this->resolveAssertTagsFor($phpDocNode, $nameScope, $prefix . '-assert-if-true', AssertTag::IF_TRUE), + $this->resolveAssertTagsFor($phpDocNode, $nameScope, $prefix . '-assert-if-false', AssertTag::IF_FALSE), + ); + + if (count($resolved) > 0) { + return $resolved; + } + } + + return []; + } + + /** + * @param AssertTag::NULL|AssertTag::IF_TRUE|AssertTag::IF_FALSE $if + * @return AssertTag[] + */ + private function resolveAssertTagsFor(PhpDocNode $phpDocNode, NameScope $nameScope, string $tagName, string $if): array + { + $resolved = []; + + foreach ($phpDocNode->getAssertTagValues($tagName) as $assertTagValue) { + $type = $this->typeNodeResolver->resolve($assertTagValue->type, $nameScope); + $parameter = new AssertTagParameter($assertTagValue->parameter, null, null); + $resolved[] = new AssertTag($if, $type, $parameter, $assertTagValue->isNegated, $assertTagValue->isEquality, true); + } + + foreach ($phpDocNode->getAssertPropertyTagValues($tagName) as $assertTagValue) { + $type = $this->typeNodeResolver->resolve($assertTagValue->type, $nameScope); + $parameter = new AssertTagParameter($assertTagValue->parameter, $assertTagValue->property, null); + $resolved[] = new AssertTag($if, $type, $parameter, $assertTagValue->isNegated, $assertTagValue->isEquality, true); + } + + foreach ($phpDocNode->getAssertMethodTagValues($tagName) as $assertTagValue) { + $type = $this->typeNodeResolver->resolve($assertTagValue->type, $nameScope); + $parameter = new AssertTagParameter($assertTagValue->parameter, null, $assertTagValue->method); + $resolved[] = new AssertTag($if, $type, $parameter, $assertTagValue->isNegated, $assertTagValue->isEquality, true); + } + + return $resolved; + } + + public function resolveSelfOutTypeTag(PhpDocNode $phpDocNode, NameScope $nameScope): ?SelfOutTypeTag + { + if (!method_exists($phpDocNode, 'getSelfOutTypeTagValues')) { + return null; + } + + foreach (['@phpstan-this-out', '@phpstan-self-out', '@psalm-this-out', '@psalm-self-out'] as $tagName) { + foreach ($phpDocNode->getSelfOutTypeTagValues($tagName) as $selfOutTypeTagValue) { + $type = $this->typeNodeResolver->resolve($selfOutTypeTagValue->type, $nameScope); + return new SelfOutTypeTag($type); + } + } + + return null; + } + + public function resolveDeprecatedTag(PhpDocNode $phpDocNode, NameScope $nameScope): ?DeprecatedTag + { + foreach ($phpDocNode->getDeprecatedTagValues() as $deprecatedTagValue) { + $description = (string) $deprecatedTagValue; + return new DeprecatedTag($description === '' ? null : $description); + } + + return null; + } + + public function resolveIsDeprecated(PhpDocNode $phpDocNode): bool + { + $deprecatedTags = $phpDocNode->getTagsByName('@deprecated'); + + return count($deprecatedTags) > 0; + } + + public function resolveIsNotDeprecated(PhpDocNode $phpDocNode): bool + { + $notDeprecatedTags = $phpDocNode->getTagsByName('@not-deprecated'); + + return count($notDeprecatedTags) > 0; + } + + public function resolveIsInternal(PhpDocNode $phpDocNode): bool + { + $internalTags = $phpDocNode->getTagsByName('@internal'); + + return count($internalTags) > 0; + } + + public function resolveIsFinal(PhpDocNode $phpDocNode): bool + { + $finalTags = $phpDocNode->getTagsByName('@final'); + + return count($finalTags) > 0; + } + + public function resolveIsPure(PhpDocNode $phpDocNode): bool + { + foreach ($phpDocNode->getTags() as $phpDocTagNode) { + if (in_array($phpDocTagNode->name, ['@pure', '@phan-pure', '@phan-side-effect-free', '@psalm-pure', '@phpstan-pure'], true)) { + return true; + } + } + + return false; + } + + public function resolveIsImpure(PhpDocNode $phpDocNode): bool + { + foreach ($phpDocNode->getTags() as $phpDocTagNode) { + if (in_array($phpDocTagNode->name, ['@impure', '@phpstan-impure'], true)) { + return true; + } + } + + return false; + } + + public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool + { + foreach (['@readonly', '@phan-read-only', '@psalm-readonly', '@phpstan-readonly', '@phpstan-readonly-allow-private-mutation', '@psalm-readonly-allow-private-mutation'] as $tagName) { + $tags = $phpDocNode->getTagsByName($tagName); + + if (count($tags) > 0) { + return true; + } + } + + return false; + } + + public function resolveIsImmutable(PhpDocNode $phpDocNode): bool + { + foreach (['@immutable', '@phan-immutable', '@psalm-immutable', '@phpstan-immutable'] as $tagName) { + $tags = $phpDocNode->getTagsByName($tagName); + + if (count($tags) > 0) { + return true; + } + } + + return false; + } + + public function resolveHasConsistentConstructor(PhpDocNode $phpDocNode): bool + { + foreach (['@consistent-constructor', '@phpstan-consistent-constructor', '@psalm-consistent-constructor'] as $tagName) { + $tags = $phpDocNode->getTagsByName($tagName); + + if (count($tags) > 0) { + return true; + } + } + + return false; + } + + public function resolveAcceptsNamedArguments(PhpDocNode $phpDocNode): bool + { + return count($phpDocNode->getTagsByName('@no-named-arguments')) === 0; + } + + private function shouldSkipType(string $tagName, Type $type): bool + { + if (!str_starts_with($tagName, '@psalm-')) { + return false; + } + + return $this->unresolvableTypeHelper->containsUnresolvableType($type); + } + + public function resolveAllowPrivateMutation(PhpDocNode $phpDocNode): bool + { + foreach (['@phpstan-readonly-allow-private-mutation', '@phpstan-allow-private-mutation', '@psalm-readonly-allow-private-mutation', '@psalm-allow-private-mutation'] as $tagName) { + $tags = $phpDocNode->getTagsByName($tagName); + + if (count($tags) > 0) { + return true; + } + } + + return false; + } + +} diff --git a/src/PhpDoc/PhpDocStringResolver.php b/src/PhpDoc/PhpDocStringResolver.php new file mode 100644 index 00000000..d2543c39 --- /dev/null +++ b/src/PhpDoc/PhpDocStringResolver.php @@ -0,0 +1,27 @@ +phpDocLexer->tokenize($phpDocString)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); // @phpstan-ignore missingType.checkedException + + return $phpDocNode; + } + +} diff --git a/src/PhpDoc/ReflectionClassStubFilesExtension.php b/src/PhpDoc/ReflectionClassStubFilesExtension.php new file mode 100644 index 00000000..2537fd96 --- /dev/null +++ b/src/PhpDoc/ReflectionClassStubFilesExtension.php @@ -0,0 +1,28 @@ +phpVersion->supportsLazyObjects()) { + return [ + __DIR__ . '/../../stubs/ReflectionClass.stub', + ]; + } + + return [ + __DIR__ . '/../../stubs/ReflectionClassWithLazyObjects.stub', + ]; + } + +} diff --git a/src/PhpDoc/ReflectionEnumStubFilesExtension.php b/src/PhpDoc/ReflectionEnumStubFilesExtension.php new file mode 100644 index 00000000..1ddb1de3 --- /dev/null +++ b/src/PhpDoc/ReflectionEnumStubFilesExtension.php @@ -0,0 +1,28 @@ +phpVersion->supportsEnums()) { + return []; + } + + if (!$this->phpVersion->supportsLazyObjects()) { + return [__DIR__ . '/../../stubs/ReflectionEnum.stub']; + } + + return [__DIR__ . '/../../stubs/ReflectionEnumWithLazyObjects.stub']; + } + +} diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php new file mode 100644 index 00000000..b8ae66ff --- /dev/null +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -0,0 +1,1246 @@ + */ + private array $templateTags; + + private PhpDocNodeResolver $phpDocNodeResolver; + + private ReflectionProvider $reflectionProvider; + + /** @var array<(string|int), VarTag>|false */ + private array|false $varTags = false; + + /** @var array|false */ + private array|false $methodTags = false; + + /** @var array|false */ + private array|false $propertyTags = false; + + /** @var array|false */ + private array|false $extendsTags = false; + + /** @var array|false */ + private array|false $implementsTags = false; + + /** @var array|false */ + private array|false $usesTags = false; + + /** @var array|false */ + private array|false $paramTags = false; + + /** @var array|false */ + private array|false $paramOutTags = false; + + /** @var array|false */ + private array|false $paramsImmediatelyInvokedCallable = false; + + /** @var array|false */ + private array|false $paramClosureThisTags = false; + + private ReturnTag|false|null $returnTag = false; + + private ThrowsTag|false|null $throwsTag = false; + + /** @var array|false */ + private array|false $mixinTags = false; + + /** @var array|false */ + private array|false $requireExtendsTags = false; + + /** @var array|false */ + private array|false $requireImplementsTags = false; + + /** @var array|false */ + private array|false $typeAliasTags = false; + + /** @var array|false */ + private array|false $typeAliasImportTags = false; + + /** @var array|false */ + private array|false $assertTags = false; + + private SelfOutTypeTag|false|null $selfOutTypeTag = false; + + private DeprecatedTag|false|null $deprecatedTag = false; + + private ?bool $isDeprecated = null; + + private ?bool $isNotDeprecated = null; + + private ?bool $isInternal = null; + + private ?bool $isFinal = null; + + /** @var bool|'notLoaded'|null */ + private bool|string|null $isPure = 'notLoaded'; + + private ?bool $isReadOnly = null; + + private ?bool $isImmutable = null; + + private ?bool $isAllowedPrivateMutation = null; + + private ?bool $hasConsistentConstructor = null; + + private ?bool $acceptsNamedArguments = null; + + private function __construct() + { + } + + /** + * @param TemplateTag[] $templateTags + */ + public static function create( + PhpDocNode $phpDocNode, + string $phpDocString, + ?string $filename, + NameScope $nameScope, + TemplateTypeMap $templateTypeMap, + array $templateTags, + PhpDocNodeResolver $phpDocNodeResolver, + ReflectionProvider $reflectionProvider, + ): self + { + // new property also needs to be added to withNameScope(), createEmpty() and merge() + $self = new self(); + $self->phpDocNode = $phpDocNode; + $self->phpDocNodes = [$phpDocNode]; + $self->phpDocString = $phpDocString; + $self->filename = $filename; + $self->nameScope = $nameScope; + $self->templateTypeMap = $templateTypeMap; + $self->templateTags = $templateTags; + $self->phpDocNodeResolver = $phpDocNodeResolver; + $self->reflectionProvider = $reflectionProvider; + + return $self; + } + + public function withNameScope(NameScope $nameScope): self + { + $self = new self(); + $self->phpDocNode = $this->phpDocNode; + $self->phpDocNodes = $this->phpDocNodes; + $self->phpDocString = $this->phpDocString; + $self->filename = $this->filename; + $self->nameScope = $nameScope; + $self->templateTypeMap = $this->templateTypeMap; + $self->templateTags = $this->templateTags; + $self->phpDocNodeResolver = $this->phpDocNodeResolver; + $self->reflectionProvider = $this->reflectionProvider; + + return $self; + } + + public static function createEmpty(): self + { + // new property also needs to be added to merge() + $self = new self(); + $self->phpDocString = self::EMPTY_DOC_STRING; + $self->phpDocNodes = []; + $self->filename = null; + $self->templateTypeMap = TemplateTypeMap::createEmpty(); + $self->templateTags = []; + $self->varTags = []; + $self->methodTags = []; + $self->propertyTags = []; + $self->extendsTags = []; + $self->implementsTags = []; + $self->usesTags = []; + $self->paramTags = []; + $self->paramOutTags = []; + $self->paramsImmediatelyInvokedCallable = []; + $self->paramClosureThisTags = []; + $self->returnTag = null; + $self->throwsTag = null; + $self->mixinTags = []; + $self->requireExtendsTags = []; + $self->requireImplementsTags = []; + $self->typeAliasTags = []; + $self->typeAliasImportTags = []; + $self->assertTags = []; + $self->selfOutTypeTag = null; + $self->deprecatedTag = null; + $self->isDeprecated = false; + $self->isNotDeprecated = false; + $self->isInternal = false; + $self->isFinal = false; + $self->isPure = null; + $self->isReadOnly = false; + $self->isImmutable = false; + $self->isAllowedPrivateMutation = false; + $self->hasConsistentConstructor = false; + $self->acceptsNamedArguments = true; + + return $self; + } + + /** + * @param array $parents + * @param array $parentPhpDocBlocks + */ + public function merge(array $parents, array $parentPhpDocBlocks): self + { + $className = $this->nameScope !== null ? $this->nameScope->getClassName() : null; + $classReflection = $className !== null && $this->reflectionProvider->hasClass($className) + ? $this->reflectionProvider->getClass($className) + : null; + + // new property also needs to be added to createEmpty() + $result = new self(); + // we will resolve everything on $this here so these properties don't have to be populated + // skip $result->phpDocNode + $phpDocNodes = $this->phpDocNodes; + $acceptsNamedArguments = $this->acceptsNamedArguments(); + foreach ($parents as $parent) { + foreach ($parent->phpDocNodes as $phpDocNode) { + $phpDocNodes[] = $phpDocNode; + $acceptsNamedArguments = $acceptsNamedArguments && $parent->acceptsNamedArguments(); + } + } + $result->phpDocNodes = $phpDocNodes; + $result->phpDocString = $this->phpDocString; + $result->filename = $this->filename; + // skip $result->nameScope + $result->templateTypeMap = $this->templateTypeMap; + $result->templateTags = $this->templateTags; + // skip $result->phpDocNodeResolver + $result->varTags = self::mergeVarTags($this->getVarTags(), $parents, $parentPhpDocBlocks); + $result->methodTags = $this->getMethodTags(); + $result->propertyTags = $this->getPropertyTags(); + $result->extendsTags = $this->getExtendsTags(); + $result->implementsTags = $this->getImplementsTags(); + $result->usesTags = $this->getUsesTags(); + $result->paramTags = self::mergeParamTags($this->getParamTags(), $parents, $parentPhpDocBlocks); + $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parents, $parentPhpDocBlocks); + $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parents, $parentPhpDocBlocks); + $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parents, $parentPhpDocBlocks); + $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $classReflection, $parents, $parentPhpDocBlocks); + $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parents); + $result->mixinTags = $this->getMixinTags(); + $result->requireExtendsTags = $this->getRequireExtendsTags(); + $result->requireImplementsTags = $this->getRequireImplementsTags(); + $result->typeAliasTags = $this->getTypeAliasTags(); + $result->typeAliasImportTags = $this->getTypeAliasImportTags(); + $result->assertTags = self::mergeAssertTags($this->getAssertTags(), $parents, $parentPhpDocBlocks); + $result->selfOutTypeTag = self::mergeSelfOutTypeTags($this->getSelfOutTag(), $parents); + $result->deprecatedTag = self::mergeDeprecatedTags($this->getDeprecatedTag(), $this->isNotDeprecated(), $parents); + $result->isDeprecated = $result->deprecatedTag !== null; + $result->isNotDeprecated = $this->isNotDeprecated(); + $result->isInternal = $this->isInternal(); + $result->isFinal = $this->isFinal(); + $result->isPure = self::mergePureTags($this->isPure(), $parents); + $result->isReadOnly = $this->isReadOnly(); + $result->isImmutable = $this->isImmutable(); + $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); + $result->hasConsistentConstructor = $this->hasConsistentConstructor(); + $result->acceptsNamedArguments = $acceptsNamedArguments; + + return $result; + } + + /** + * @param array $parameterNameMapping + */ + public function changeParameterNamesByMapping(array $parameterNameMapping): self + { + if (count($this->phpDocNodes) === 0) { + return $this; + } + + $mapParameterCb = static function (Type $type, callable $traverse) use ($parameterNameMapping): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $parameterNameMapping)) { + $type = $type->changeParameterName('$' . $parameterNameMapping[$parameterName]); + } + } + + return $traverse($type); + }; + + $newParamTags = []; + foreach ($this->getParamTags() as $key => $paramTag) { + if (!array_key_exists($key, $parameterNameMapping)) { + continue; + } + $transformedType = TypeTraverser::map($paramTag->getType(), $mapParameterCb); + $newParamTags[$parameterNameMapping[$key]] = $paramTag->withType($transformedType); + } + + $newParamOutTags = []; + foreach ($this->getParamOutTags() as $key => $paramOutTag) { + if (!array_key_exists($key, $parameterNameMapping)) { + continue; + } + + $transformedType = TypeTraverser::map($paramOutTag->getType(), $mapParameterCb); + $newParamOutTags[$parameterNameMapping[$key]] = $paramOutTag->withType($transformedType); + } + + $newParamsImmediatelyInvokedCallable = []; + foreach ($this->getParamsImmediatelyInvokedCallable() as $key => $immediatelyInvokedCallable) { + if (!array_key_exists($key, $parameterNameMapping)) { + continue; + } + + $newParamsImmediatelyInvokedCallable[$parameterNameMapping[$key]] = $immediatelyInvokedCallable; + } + + $paramClosureThisTags = $this->getParamClosureThisTags(); + $newParamClosureThisTags = []; + foreach ($paramClosureThisTags as $key => $paramClosureThisTag) { + if (!array_key_exists($key, $parameterNameMapping)) { + continue; + } + + $transformedType = TypeTraverser::map($paramClosureThisTag->getType(), $mapParameterCb); + $newParamClosureThisTags[$parameterNameMapping[$key]] = $paramClosureThisTag->withType($transformedType); + } + + $returnTag = $this->getReturnTag(); + if ($returnTag !== null) { + $transformedType = TypeTraverser::map($returnTag->getType(), $mapParameterCb); + $returnTag = $returnTag->withType($transformedType); + } + + $assertTags = $this->getAssertTags(); + if (count($assertTags) > 0) { + $assertTags = array_map(static function (AssertTag $tag) use ($parameterNameMapping): AssertTag { + $parameterName = substr($tag->getParameter()->getParameterName(), 1); + if (array_key_exists($parameterName, $parameterNameMapping)) { + $tag = $tag->withParameter($tag->getParameter()->changeParameterName('$' . $parameterNameMapping[$parameterName])); + } + return $tag; + }, $assertTags); + } + + $self = new self(); + $self->phpDocNode = $this->phpDocNode; + $self->phpDocNodes = $this->phpDocNodes; + $self->phpDocString = $this->phpDocString; + $self->filename = $this->filename; + $self->nameScope = $this->nameScope; + $self->templateTypeMap = $this->templateTypeMap; + $self->templateTags = $this->templateTags; + $self->phpDocNodeResolver = $this->phpDocNodeResolver; + $self->reflectionProvider = $this->reflectionProvider; + $self->varTags = $this->varTags; + $self->methodTags = $this->methodTags; + $self->propertyTags = $this->propertyTags; + $self->extendsTags = $this->extendsTags; + $self->implementsTags = $this->implementsTags; + $self->usesTags = $this->usesTags; + $self->paramTags = $newParamTags; + $self->paramOutTags = $newParamOutTags; + $self->paramsImmediatelyInvokedCallable = $newParamsImmediatelyInvokedCallable; + $self->paramClosureThisTags = $newParamClosureThisTags; + $self->returnTag = $returnTag; + $self->throwsTag = $this->throwsTag; + $self->mixinTags = $this->mixinTags; + $self->requireImplementsTags = $this->requireImplementsTags; + $self->requireExtendsTags = $this->requireExtendsTags; + $self->typeAliasTags = $this->typeAliasTags; + $self->typeAliasImportTags = $this->typeAliasImportTags; + $self->assertTags = $assertTags; + $self->selfOutTypeTag = $this->selfOutTypeTag; + $self->deprecatedTag = $this->deprecatedTag; + $self->isDeprecated = $this->isDeprecated; + $self->isNotDeprecated = $this->isNotDeprecated; + $self->isInternal = $this->isInternal; + $self->isFinal = $this->isFinal; + $self->isPure = $this->isPure; + + return $self; + } + + public function hasPhpDocString(): bool + { + return $this->phpDocString !== self::EMPTY_DOC_STRING; + } + + public function getPhpDocString(): string + { + return $this->phpDocString; + } + + /** + * @return PhpDocNode[] + */ + public function getPhpDocNodes(): array + { + return $this->phpDocNodes; + } + + public function getFilename(): ?string + { + return $this->filename; + } + + private function getNameScope(): NameScope + { + return $this->nameScope; + } + + public function getNullableNameScope(): ?NameScope + { + return $this->nameScope; + } + + /** + * @return array<(string|int), VarTag> + */ + public function getVarTags(): array + { + if ($this->varTags === false) { + $this->varTags = $this->phpDocNodeResolver->resolveVarTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->varTags; + } + + /** + * @return array + */ + public function getMethodTags(): array + { + if ($this->methodTags === false) { + $this->methodTags = $this->phpDocNodeResolver->resolveMethodTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->methodTags; + } + + /** + * @return array + */ + public function getPropertyTags(): array + { + if ($this->propertyTags === false) { + $this->propertyTags = $this->phpDocNodeResolver->resolvePropertyTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->propertyTags; + } + + /** + * @return array + */ + public function getTemplateTags(): array + { + return $this->templateTags; + } + + /** + * @return array + */ + public function getExtendsTags(): array + { + if ($this->extendsTags === false) { + $this->extendsTags = $this->phpDocNodeResolver->resolveExtendsTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->extendsTags; + } + + /** + * @return array + */ + public function getImplementsTags(): array + { + if ($this->implementsTags === false) { + $this->implementsTags = $this->phpDocNodeResolver->resolveImplementsTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->implementsTags; + } + + /** + * @return array + */ + public function getUsesTags(): array + { + if ($this->usesTags === false) { + $this->usesTags = $this->phpDocNodeResolver->resolveUsesTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->usesTags; + } + + /** + * @return array + */ + public function getParamTags(): array + { + if ($this->paramTags === false) { + $this->paramTags = $this->phpDocNodeResolver->resolveParamTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->paramTags; + } + + /** + * @return array + */ + public function getParamOutTags(): array + { + if ($this->paramOutTags === false) { + $this->paramOutTags = $this->phpDocNodeResolver->resolveParamOutTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->paramOutTags; + } + + /** + * @return array + */ + public function getParamsImmediatelyInvokedCallable(): array + { + if ($this->paramsImmediatelyInvokedCallable === false) { + $this->paramsImmediatelyInvokedCallable = $this->phpDocNodeResolver->resolveParamImmediatelyInvokedCallable($this->phpDocNode); + } + + return $this->paramsImmediatelyInvokedCallable; + } + + /** + * @return array + */ + public function getParamClosureThisTags(): array + { + if ($this->paramClosureThisTags === false) { + $this->paramClosureThisTags = $this->phpDocNodeResolver->resolveParamClosureThisTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->paramClosureThisTags; + } + + public function getReturnTag(): ?ReturnTag + { + if (is_bool($this->returnTag)) { + $this->returnTag = $this->phpDocNodeResolver->resolveReturnTag( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->returnTag; + } + + public function getThrowsTag(): ?ThrowsTag + { + if (is_bool($this->throwsTag)) { + $this->throwsTag = $this->phpDocNodeResolver->resolveThrowsTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->throwsTag; + } + + /** + * @return array + */ + public function getMixinTags(): array + { + if ($this->mixinTags === false) { + $this->mixinTags = $this->phpDocNodeResolver->resolveMixinTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->mixinTags; + } + + /** + * @return array + */ + public function getRequireExtendsTags(): array + { + if ($this->requireExtendsTags === false) { + $this->requireExtendsTags = $this->phpDocNodeResolver->resolveRequireExtendsTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->requireExtendsTags; + } + + /** + * @return array + */ + public function getRequireImplementsTags(): array + { + if ($this->requireImplementsTags === false) { + $this->requireImplementsTags = $this->phpDocNodeResolver->resolveRequireImplementsTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->requireImplementsTags; + } + + /** + * @return array + */ + public function getTypeAliasTags(): array + { + if ($this->typeAliasTags === false) { + $this->typeAliasTags = $this->phpDocNodeResolver->resolveTypeAliasTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->typeAliasTags; + } + + /** + * @return array + */ + public function getTypeAliasImportTags(): array + { + if ($this->typeAliasImportTags === false) { + $this->typeAliasImportTags = $this->phpDocNodeResolver->resolveTypeAliasImportTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->typeAliasImportTags; + } + + /** + * @return array + */ + public function getAssertTags(): array + { + if ($this->assertTags === false) { + $this->assertTags = $this->phpDocNodeResolver->resolveAssertTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->assertTags; + } + + public function getSelfOutTag(): ?SelfOutTypeTag + { + if ($this->selfOutTypeTag === false) { + $this->selfOutTypeTag = $this->phpDocNodeResolver->resolveSelfOutTypeTag( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->selfOutTypeTag; + } + + public function getDeprecatedTag(): ?DeprecatedTag + { + if (is_bool($this->deprecatedTag)) { + $this->deprecatedTag = $this->phpDocNodeResolver->resolveDeprecatedTag( + $this->phpDocNode, + $this->getNameScope(), + ); + } + return $this->deprecatedTag; + } + + public function isDeprecated(): bool + { + if ($this->isDeprecated === null) { + $this->isDeprecated = $this->phpDocNodeResolver->resolveIsDeprecated( + $this->phpDocNode, + ); + } + return $this->isDeprecated; + } + + /** + * @internal + */ + public function isNotDeprecated(): bool + { + if ($this->isNotDeprecated === null) { + $this->isNotDeprecated = $this->phpDocNodeResolver->resolveIsNotDeprecated( + $this->phpDocNode, + ); + } + return $this->isNotDeprecated; + } + + public function isInternal(): bool + { + if ($this->isInternal === null) { + $this->isInternal = $this->phpDocNodeResolver->resolveIsInternal( + $this->phpDocNode, + ); + } + return $this->isInternal; + } + + public function isFinal(): bool + { + if ($this->isFinal === null) { + $this->isFinal = $this->phpDocNodeResolver->resolveIsFinal( + $this->phpDocNode, + ); + } + return $this->isFinal; + } + + public function hasConsistentConstructor(): bool + { + if ($this->hasConsistentConstructor === null) { + $this->hasConsistentConstructor = $this->phpDocNodeResolver->resolveHasConsistentConstructor( + $this->phpDocNode, + ); + } + return $this->hasConsistentConstructor; + } + + public function acceptsNamedArguments(): bool + { + if ($this->acceptsNamedArguments === null) { + $this->acceptsNamedArguments = $this->phpDocNodeResolver->resolveAcceptsNamedArguments( + $this->phpDocNode, + ); + } + return $this->acceptsNamedArguments; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function isPure(): ?bool + { + if ($this->isPure === 'notLoaded') { + $pure = $this->phpDocNodeResolver->resolveIsPure( + $this->phpDocNode, + ); + if ($pure) { + $this->isPure = true; + return $this->isPure; + } + + $impure = $this->phpDocNodeResolver->resolveIsImpure( + $this->phpDocNode, + ); + if ($impure) { + $this->isPure = false; + return $this->isPure; + } + + $this->isPure = null; + } + + return $this->isPure; + } + + public function isReadOnly(): bool + { + if ($this->isReadOnly === null) { + $this->isReadOnly = $this->phpDocNodeResolver->resolveIsReadOnly( + $this->phpDocNode, + ); + } + return $this->isReadOnly; + } + + public function isImmutable(): bool + { + if ($this->isImmutable === null) { + $this->isImmutable = $this->phpDocNodeResolver->resolveIsImmutable( + $this->phpDocNode, + ); + } + return $this->isImmutable; + } + + public function isAllowedPrivateMutation(): bool + { + if ($this->isAllowedPrivateMutation === null) { + $this->isAllowedPrivateMutation = $this->phpDocNodeResolver->resolveAllowPrivateMutation( + $this->phpDocNode, + ); + } + + return $this->isAllowedPrivateMutation; + } + + /** + * @param array $varTags + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeVarTags(array $varTags, array $parents, array $parentPhpDocBlocks): array + { + // Only allow one var tag per comment. Check the parent if child does not have this tag. + if (count($varTags) > 0) { + return $varTags; + } + + foreach ($parents as $i => $parent) { + $result = self::mergeOneParentVarTags($parent, $parentPhpDocBlocks[$i]); + if ($result === null) { + continue; + } + + return $result; + } + + return []; + } + + /** + * @return array|null + */ + private static function mergeOneParentVarTags(self $parent, PhpDocBlock $phpDocBlock): ?array + { + foreach ($parent->getVarTags() as $key => $parentVarTag) { + return [$key => self::resolveTemplateTypeInTag($parentVarTag, $phpDocBlock, TemplateTypeVariance::createInvariant())]; + } + + return null; + } + + /** + * @param array $paramTags + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeParamTags(array $paramTags, array $parents, array $parentPhpDocBlocks): array + { + foreach ($parents as $i => $parent) { + $paramTags = self::mergeOneParentParamTags($paramTags, $parent, $parentPhpDocBlocks[$i]); + } + + return $paramTags; + } + + /** + * @param array $paramTags + * @return array + */ + private static function mergeOneParentParamTags(array $paramTags, self $parent, PhpDocBlock $phpDocBlock): array + { + $parentParamTags = $phpDocBlock->transformArrayKeysWithParameterNameMapping($parent->getParamTags()); + + foreach ($parentParamTags as $name => $parentParamTag) { + if (array_key_exists($name, $paramTags)) { + continue; + } + + $paramTags[$name] = self::resolveTemplateTypeInTag( + $parentParamTag->withType($phpDocBlock->transformConditionalReturnTypeWithParameterNameMapping($parentParamTag->getType())), + $phpDocBlock, + TemplateTypeVariance::createContravariant(), + ); + } + + return $paramTags; + } + + /** + * @param array $parents + * @param array $parentPhpDocBlocks + * @return ReturnTag|Null + */ + private static function mergeReturnTags(?ReturnTag $returnTag, ?ClassReflection $classReflection, array $parents, array $parentPhpDocBlocks): ?ReturnTag + { + if ($returnTag !== null) { + return $returnTag; + } + + foreach ($parents as $i => $parent) { + $result = self::mergeOneParentReturnTag($returnTag, $classReflection, $parent, $parentPhpDocBlocks[$i]); + if ($result === null) { + continue; + } + + return $result; + } + + return null; + } + + private static function mergeOneParentReturnTag(?ReturnTag $returnTag, ?ClassReflection $classReflection, self $parent, PhpDocBlock $phpDocBlock): ?ReturnTag + { + $parentReturnTag = $parent->getReturnTag(); + if ($parentReturnTag === null) { + return $returnTag; + } + + $parentType = $parentReturnTag->getType(); + + if ($classReflection !== null) { + $parentType = TypeTraverser::map( + $parentType, + static function (Type $type, callable $traverse) use ($classReflection): Type { + if ($type instanceof StaticType) { + return $type->changeBaseClass($classReflection); + } + + return $traverse($type); + }, + ); + + $parentReturnTag = $parentReturnTag->withType($parentType); + } + + // Each parent would overwrite the previous one except if it returns a less specific type. + // Do not care for incompatible types as there is a separate rule for that. + if ($returnTag !== null && $parentType->isSuperTypeOf($returnTag->getType())->yes()) { + return null; + } + + return self::resolveTemplateTypeInTag( + $parentReturnTag->withType( + $phpDocBlock->transformConditionalReturnTypeWithParameterNameMapping($parentReturnTag->getType()), + )->toImplicit(), + $phpDocBlock, + TemplateTypeVariance::createCovariant(), + ); + } + + /** + * @param array $assertTags + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeAssertTags(array $assertTags, array $parents, array $parentPhpDocBlocks): array + { + if (count($assertTags) > 0) { + return $assertTags; + } + foreach ($parents as $i => $parent) { + $result = $parent->getAssertTags(); + if (count($result) === 0) { + continue; + } + + $phpDocBlock = $parentPhpDocBlocks[$i]; + + return array_map( + static fn (AssertTag $assertTag) => self::resolveTemplateTypeInTag( + $assertTag->withParameter( + $phpDocBlock->transformAssertTagParameterWithParameterNameMapping($assertTag->getParameter()), + )->toImplicit(), + $phpDocBlock, + TemplateTypeVariance::createCovariant(), + ), + $result, + ); + } + + return $assertTags; + } + + /** + * @param array $parents + */ + private static function mergeSelfOutTypeTags(?SelfOutTypeTag $selfOutTypeTag, array $parents): ?SelfOutTypeTag + { + if ($selfOutTypeTag !== null) { + return $selfOutTypeTag; + } + foreach ($parents as $parent) { + $result = $parent->getSelfOutTag(); + if ($result === null) { + continue; + } + return $result; + } + + return null; + } + + /** + * @param array $parents + */ + private static function mergeDeprecatedTags(?DeprecatedTag $deprecatedTag, bool $hasNotDeprecatedTag, array $parents): ?DeprecatedTag + { + if ($deprecatedTag !== null) { + return $deprecatedTag; + } + + if ($hasNotDeprecatedTag) { + return null; + } + + foreach ($parents as $parent) { + $result = $parent->getDeprecatedTag(); + if ($result === null && !$parent->isNotDeprecated()) { + continue; + } + return $result; + } + + return null; + } + + /** + * @param array $parents + */ + private static function mergeThrowsTags(?ThrowsTag $throwsTag, array $parents): ?ThrowsTag + { + if ($throwsTag !== null) { + return $throwsTag; + } + foreach ($parents as $parent) { + $result = $parent->getThrowsTag(); + if ($result === null) { + continue; + } + + return $result; + } + + return null; + } + + /** + * @param array $paramOutTags + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeParamOutTags(array $paramOutTags, array $parents, array $parentPhpDocBlocks): array + { + foreach ($parents as $i => $parent) { + $paramOutTags = self::mergeOneParentParamOutTags($paramOutTags, $parent, $parentPhpDocBlocks[$i]); + } + + return $paramOutTags; + } + + /** + * @param array $paramOutTags + * @return array + */ + private static function mergeOneParentParamOutTags(array $paramOutTags, self $parent, PhpDocBlock $phpDocBlock): array + { + $parentParamOutTags = $phpDocBlock->transformArrayKeysWithParameterNameMapping($parent->getParamOutTags()); + + foreach ($parentParamOutTags as $name => $parentParamTag) { + if (array_key_exists($name, $paramOutTags)) { + continue; + } + + $paramOutTags[$name] = self::resolveTemplateTypeInTag( + $parentParamTag->withType($phpDocBlock->transformConditionalReturnTypeWithParameterNameMapping($parentParamTag->getType())), + $phpDocBlock, + TemplateTypeVariance::createCovariant(), + ); + } + + return $paramOutTags; + } + + /** + * @param array $paramsImmediatelyInvokedCallable + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeParamsImmediatelyInvokedCallable(array $paramsImmediatelyInvokedCallable, array $parents, array $parentPhpDocBlocks): array + { + foreach ($parents as $i => $parent) { + $paramsImmediatelyInvokedCallable = self::mergeOneParentParamImmediatelyInvokedCallable($paramsImmediatelyInvokedCallable, $parent, $parentPhpDocBlocks[$i]); + } + + return $paramsImmediatelyInvokedCallable; + } + + /** + * @param array $paramsImmediatelyInvokedCallable + * @return array + */ + private static function mergeOneParentParamImmediatelyInvokedCallable(array $paramsImmediatelyInvokedCallable, self $parent, PhpDocBlock $phpDocBlock): array + { + $parentImmediatelyInvokedCallable = $phpDocBlock->transformArrayKeysWithParameterNameMapping($parent->getParamsImmediatelyInvokedCallable()); + + foreach ($parentImmediatelyInvokedCallable as $name => $parentIsImmediatelyInvokedCallable) { + if (array_key_exists($name, $paramsImmediatelyInvokedCallable)) { + continue; + } + + $paramsImmediatelyInvokedCallable[$name] = $parentIsImmediatelyInvokedCallable; + } + + return $paramsImmediatelyInvokedCallable; + } + + /** + * @param array $paramsClosureThisTags + * @param array $parents + * @param array $parentPhpDocBlocks + * @return array + */ + private static function mergeParamClosureThisTags(array $paramsClosureThisTags, array $parents, array $parentPhpDocBlocks): array + { + foreach ($parents as $i => $parent) { + $paramsClosureThisTags = self::mergeOneParentParamClosureThisTag($paramsClosureThisTags, $parent, $parentPhpDocBlocks[$i]); + } + + return $paramsClosureThisTags; + } + + /** + * @param array $paramsClosureThisTags + * @return array + */ + private static function mergeOneParentParamClosureThisTag(array $paramsClosureThisTags, self $parent, PhpDocBlock $phpDocBlock): array + { + $parentClosureThisTags = $phpDocBlock->transformArrayKeysWithParameterNameMapping($parent->getParamClosureThisTags()); + + foreach ($parentClosureThisTags as $name => $parentParamClosureThisTag) { + if (array_key_exists($name, $paramsClosureThisTags)) { + continue; + } + + $paramsClosureThisTags[$name] = self::resolveTemplateTypeInTag( + $parentParamClosureThisTag->withType( + $phpDocBlock->transformConditionalReturnTypeWithParameterNameMapping($parentParamClosureThisTag->getType()), + ), + $phpDocBlock, + TemplateTypeVariance::createContravariant(), + ); + } + + return $paramsClosureThisTags; + } + + /** + * @param array $parents + */ + private static function mergePureTags(?bool $isPure, array $parents): ?bool + { + if ($isPure !== null) { + return $isPure; + } + + foreach ($parents as $parent) { + $parentIsPure = $parent->isPure(); + if ($parentIsPure === null) { + continue; + } + + return $parentIsPure; + } + + return null; + } + + /** + * @template T of TypedTag + * @param T $tag + * @return T + */ + private static function resolveTemplateTypeInTag( + TypedTag $tag, + PhpDocBlock $phpDocBlock, + TemplateTypeVariance $positionVariance, + ): TypedTag + { + $type = TemplateTypeHelper::resolveTemplateTypes( + $tag->getType(), + $phpDocBlock->getClassReflection()->getActiveTemplateTypeMap(), + $phpDocBlock->getClassReflection()->getCallSiteVarianceMap(), + $positionVariance, + ); + return $tag->withType($type); + } + +} diff --git a/src/PhpDoc/SocketSelectStubFilesExtension.php b/src/PhpDoc/SocketSelectStubFilesExtension.php new file mode 100644 index 00000000..e23e4322 --- /dev/null +++ b/src/PhpDoc/SocketSelectStubFilesExtension.php @@ -0,0 +1,24 @@ +phpVersion->getVersionId() >= 80000) { + return [__DIR__ . '/../../stubs/socket_select_php8.stub']; + } + + return [__DIR__ . '/../../stubs/socket_select.stub']; + } + +} diff --git a/src/PhpDoc/StubFilesExtension.php b/src/PhpDoc/StubFilesExtension.php new file mode 100644 index 00000000..aacf908a --- /dev/null +++ b/src/PhpDoc/StubFilesExtension.php @@ -0,0 +1,30 @@ + */ + private array $classMap = []; + + /** @var array> */ + private array $propertyMap = []; + + /** @var array> */ + private array $constantMap = []; + + /** @var array> */ + private array $methodMap = []; + + /** @var array */ + private array $functionMap = []; + + private bool $initialized = false; + + private bool $initializing = false; + + /** @var array */ + private array $knownClassesDocComments = []; + + /** @var array */ + private array $knownFunctionsDocComments = []; + + /** @var array> */ + private array $knownPropertiesDocComments = []; + + /** @var array> */ + private array $knownConstantsDocComments = []; + + /** @var array> */ + private array $knownMethodsDocComments = []; + + /** @var array>> */ + private array $knownMethodsParameterNames = []; + + /** @var array> */ + private array $knownFunctionParameterNames = []; + + public function __construct( + private Parser $parser, + private FileTypeMapper $fileTypeMapper, + private StubFilesProvider $stubFilesProvider, + ) + { + } + + public function findClassPhpDoc(string $className): ?ResolvedPhpDocBlock + { + if (!$this->isKnownClass($className)) { + return null; + } + + if (array_key_exists($className, $this->classMap)) { + return $this->classMap[$className]; + } + + if (array_key_exists($className, $this->knownClassesDocComments)) { + [$file, $docComment] = $this->knownClassesDocComments[$className]; + $this->classMap[$className] = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $className, + null, + null, + $docComment, + ); + + return $this->classMap[$className]; + } + + return null; + } + + public function findPropertyPhpDoc(string $className, string $propertyName): ?ResolvedPhpDocBlock + { + if (!$this->isKnownClass($className)) { + return null; + } + + if (array_key_exists($propertyName, $this->propertyMap[$className])) { + return $this->propertyMap[$className][$propertyName]; + } + + if (array_key_exists($propertyName, $this->knownPropertiesDocComments[$className])) { + [$file, $docComment] = $this->knownPropertiesDocComments[$className][$propertyName]; + $this->propertyMap[$className][$propertyName] = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $className, + null, + null, + $docComment, + ); + + return $this->propertyMap[$className][$propertyName]; + } + + return null; + } + + public function findClassConstantPhpDoc(string $className, string $constantName): ?ResolvedPhpDocBlock + { + if (!$this->isKnownClass($className)) { + return null; + } + + if (array_key_exists($constantName, $this->constantMap[$className])) { + return $this->constantMap[$className][$constantName]; + } + + if (array_key_exists($constantName, $this->knownConstantsDocComments[$className])) { + [$file, $docComment] = $this->knownConstantsDocComments[$className][$constantName]; + $this->constantMap[$className][$constantName] = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $className, + null, + null, + $docComment, + ); + + return $this->constantMap[$className][$constantName]; + } + + return null; + } + + /** + * @param array $positionalParameterNames + */ + public function findMethodPhpDoc( + string $className, + string $implementingClassName, + string $methodName, + array $positionalParameterNames, + ): ?ResolvedPhpDocBlock + { + if (!$this->isKnownClass($className)) { + return null; + } + + if (array_key_exists($methodName, $this->methodMap[$className])) { + return $this->methodMap[$className][$methodName]; + } + + if (array_key_exists($methodName, $this->knownMethodsDocComments[$className])) { + [$file, $docComment] = $this->knownMethodsDocComments[$className][$methodName]; + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + $className, + null, + $methodName, + $docComment, + ); + + if (!isset($this->knownMethodsParameterNames[$className][$methodName])) { + throw new ShouldNotHappenException(); + } + + if ($className !== $implementingClassName && $resolvedPhpDoc->getNullableNameScope() !== null) { + $resolvedPhpDoc = $resolvedPhpDoc->withNameScope( + $resolvedPhpDoc->getNullableNameScope()->withClassName($implementingClassName), + ); + } + + $methodParameterNames = $this->knownMethodsParameterNames[$className][$methodName]; + $parameterNameMapping = []; + foreach ($positionalParameterNames as $i => $parameterName) { + if (!array_key_exists($i, $methodParameterNames)) { + continue; + } + $parameterNameMapping[$methodParameterNames[$i]] = $parameterName; + } + + return $resolvedPhpDoc->changeParameterNamesByMapping($parameterNameMapping); + } + + return null; + } + + /** + * @param array $positionalParameterNames + * @throws ShouldNotHappenException + */ + public function findFunctionPhpDoc(string $functionName, array $positionalParameterNames): ?ResolvedPhpDocBlock + { + if (!$this->isKnownFunction($functionName)) { + return null; + } + + if (array_key_exists($functionName, $this->functionMap)) { + return $this->functionMap[$functionName]; + } + + if (array_key_exists($functionName, $this->knownFunctionsDocComments)) { + [$file, $docComment] = $this->knownFunctionsDocComments[$functionName]; + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $file, + null, + null, + $functionName, + $docComment, + ); + + if (!isset($this->knownFunctionParameterNames[$functionName])) { + throw new ShouldNotHappenException(); + } + + $functionParameterNames = $this->knownFunctionParameterNames[$functionName]; + $parameterNameMapping = []; + foreach ($positionalParameterNames as $i => $parameterName) { + if (!array_key_exists($i, $functionParameterNames)) { + continue; + } + $parameterNameMapping[$functionParameterNames[$i]] = $parameterName; + } + + $this->functionMap[$functionName] = $resolvedPhpDoc->changeParameterNamesByMapping($parameterNameMapping); + + return $this->functionMap[$functionName]; + } + + return null; + } + + public function isKnownClass(string $className): bool + { + $this->initializeKnownElements(); + + if (array_key_exists($className, $this->classMap)) { + return true; + } + + return array_key_exists($className, $this->knownClassesDocComments); + } + + private function isKnownFunction(string $functionName): bool + { + $this->initializeKnownElements(); + + if (array_key_exists($functionName, $this->functionMap)) { + return true; + } + + return array_key_exists($functionName, $this->knownFunctionsDocComments); + } + + private function initializeKnownElements(): void + { + if ($this->initializing) { + throw new ShouldNotHappenException(); + } + if ($this->initialized) { + return; + } + + $this->initializing = true; + + try { + foreach ($this->stubFilesProvider->getStubFiles() as $stubFile) { + $nodes = $this->parser->parseFile($stubFile); + foreach ($nodes as $node) { + $this->initializeKnownElementNode($stubFile, $node); + } + } + } finally { + $this->initializing = false; + $this->initialized = true; + } + } + + private function initializeKnownElementNode(string $stubFile, Node $node): void + { + if ($node instanceof Node\Stmt\Namespace_) { + foreach ($node->stmts as $stmt) { + $this->initializeKnownElementNode($stubFile, $stmt); + } + return; + } + + if ($node instanceof Node\Stmt\Function_) { + $functionName = (string) $node->namespacedName; + $docComment = $node->getDocComment(); + if ($docComment === null) { + $this->functionMap[$functionName] = null; + return; + } + $this->knownFunctionParameterNames[$functionName] = array_map(static function (Node\Param $param): string { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + return $param->var->name; + }, $node->getParams()); + + $this->knownFunctionsDocComments[$functionName] = [$stubFile, $docComment->getText()]; + return; + } + + if (!$node instanceof Class_ && !$node instanceof Interface_ && !$node instanceof Trait_ && !$node instanceof Node\Stmt\Enum_) { + return; + } + + if (!isset($node->namespacedName)) { + return; + } + + $className = (string) $node->namespacedName; + $docComment = $node->getDocComment(); + if ($docComment === null) { + $this->classMap[$className] = null; + } else { + $this->knownClassesDocComments[$className] = [$stubFile, $docComment->getText()]; + } + + $this->methodMap[$className] = []; + $this->propertyMap[$className] = []; + $this->constantMap[$className] = []; + $this->knownPropertiesDocComments[$className] = []; + $this->knownConstantsDocComments[$className] = []; + $this->knownMethodsDocComments[$className] = []; + + foreach ($node->stmts as $stmt) { + $docComment = $stmt->getDocComment(); + if ($stmt instanceof Node\Stmt\Property) { + foreach ($stmt->props as $property) { + if ($docComment === null) { + $this->propertyMap[$className][$property->name->toString()] = null; + continue; + } + $this->knownPropertiesDocComments[$className][$property->name->toString()] = [$stubFile, $docComment->getText()]; + } + } elseif ($stmt instanceof Node\Stmt\ClassConst) { + foreach ($stmt->consts as $const) { + if ($docComment === null) { + $this->constantMap[$className][$const->name->toString()] = null; + continue; + } + $this->knownConstantsDocComments[$className][$const->name->toString()] = [$stubFile, $docComment->getText()]; + } + } elseif ($stmt instanceof Node\Stmt\ClassMethod) { + if ($docComment === null) { + $this->methodMap[$className][$stmt->name->toString()] = null; + continue; + } + + $methodName = $stmt->name->toString(); + $this->knownMethodsDocComments[$className][$methodName] = [$stubFile, $docComment->getText()]; + $this->knownMethodsParameterNames[$className][$methodName] = array_map(static function (Node\Param $param): string { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + return $param->var->name; + }, $stmt->getParams()); + } + } + } + +} diff --git a/src/PhpDoc/StubSourceLocatorFactory.php b/src/PhpDoc/StubSourceLocatorFactory.php new file mode 100644 index 00000000..2dc2005d --- /dev/null +++ b/src/PhpDoc/StubSourceLocatorFactory.php @@ -0,0 +1,55 @@ +php8Parser); + foreach ($this->stubFilesProvider->getStubFiles() as $stubFile) { + $locators[] = $this->optimizedSingleFileSourceLocatorRepository->getOrCreate($stubFile); + } + + $locators[] = $this->optimizedPsrAutoloaderLocatorFactory->create( + Psr4Mapping::fromArrayMappings([ + 'PHPStan\\' => [dirname(__DIR__) . '/'], + ]), + ); + $locators[] = $this->optimizedPsrAutoloaderLocatorFactory->create( + Psr4Mapping::fromArrayMappings([ + 'PhpParser\\' => [dirname(__DIR__, 2) . '/vendor/nikic/php-parser/lib/PhpParser/'], + ]), + ); + + $locators[] = new PhpInternalSourceLocator($astPhp8Locator, $this->phpStormStubsSourceStubber); + + return new MemoizingSourceLocator(new AggregateSourceLocator($locators)); + } + +} diff --git a/src/PhpDoc/StubValidator.php b/src/PhpDoc/StubValidator.php new file mode 100644 index 00000000..4b8114c1 --- /dev/null +++ b/src/PhpDoc/StubValidator.php @@ -0,0 +1,293 @@ + + */ + public function validate(array $stubFiles, bool $debug): array + { + if (count($stubFiles) === 0) { + return []; + } + + $originalReflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + $originalPhpVersion = PhpVersionStaticAccessor::getInstance(); + $container = $this->derivativeContainerFactory->create([ + __DIR__ . '/../../conf/config.stubValidator.neon', + ]); + + $ruleRegistry = $this->getRuleRegistry($container); + $collectorRegistry = $this->getCollectorRegistry($container); + + $fileAnalyser = $container->getByType(FileAnalyser::class); + + $nodeScopeResolver = $container->getByType(NodeScopeResolver::class); + $nodeScopeResolver->setAnalysedFiles($stubFiles); + + $pathRoutingParser = $container->getService('pathRoutingParser'); + $pathRoutingParser->setAnalysedFiles($stubFiles); + + $analysedFiles = array_fill_keys($stubFiles, true); + + $errors = []; + foreach ($stubFiles as $stubFile) { + try { + $tmpErrors = $fileAnalyser->analyseFile( + $stubFile, + $analysedFiles, + $ruleRegistry, + $collectorRegistry, + static function (): void { + }, + )->getErrors(); + foreach ($tmpErrors as $tmpError) { + $errors[] = $tmpError->withoutTip()->doNotIgnore(); + } + } catch (Throwable $e) { + if ($debug) { + throw $e; + } + + $internalErrorMessage = sprintf('Internal error: %s', $e->getMessage()); + $errors[] = (new Error($internalErrorMessage, $stubFile, null, $e)) + ->withIdentifier('phpstan.internal') + ->withMetadata([ + InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), + InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), + ]); + } + } + + ReflectionProviderStaticAccessor::registerInstance($originalReflectionProvider); + PhpVersionStaticAccessor::registerInstance($originalPhpVersion); + ObjectType::resetCaches(); + + return $errors; + } + + private function getRuleRegistry(Container $container): RuleRegistry + { + $fileTypeMapper = $container->getByType(FileTypeMapper::class); + $genericObjectTypeCheck = $container->getByType(GenericObjectTypeCheck::class); + $genericAncestorsCheck = $container->getByType(GenericAncestorsCheck::class); + $templateTypeCheck = $container->getByType(TemplateTypeCheck::class); + $varianceCheck = $container->getByType(VarianceCheck::class); + $reflectionProvider = $container->getByType(ReflectionProvider::class); + $classNameCheck = $container->getByType(ClassNameCheck::class); + $functionDefinitionCheck = $container->getByType(FunctionDefinitionCheck::class); + $missingTypehintCheck = $container->getByType(MissingTypehintCheck::class); + $unresolvableTypeHelper = $container->getByType(UnresolvableTypeHelper::class); + $crossCheckInterfacesHelper = $container->getByType(CrossCheckInterfacesHelper::class); + $phpVersion = $container->getByType(PhpVersion::class); + $localTypeAliasesCheck = $container->getByType(LocalTypeAliasesCheck::class); + $phpClassReflectionExtension = $container->getByType(PhpClassReflectionExtension::class); + $genericCallableRuleHelper = $container->getByType(GenericCallableRuleHelper::class); + $methodTagTemplateTypeCheck = $container->getByType(MethodTagTemplateTypeCheck::class); + $mixinCheck = $container->getByType(MixinCheck::class); + $methodTagCheck = new MethodTagCheck($reflectionProvider, $classNameCheck, $genericObjectTypeCheck, $missingTypehintCheck, $unresolvableTypeHelper, true, true); + $propertyTagCheck = new PropertyTagCheck($reflectionProvider, $classNameCheck, $genericObjectTypeCheck, $missingTypehintCheck, $unresolvableTypeHelper, true, true); + $reflector = $container->getService('stubReflector'); + $relativePathHelper = $container->getService('simpleRelativePathHelper'); + $assertRuleHelper = $container->getByType(AssertRuleHelper::class); + $conditionalReturnTypeRuleHelper = $container->getByType(ConditionalReturnTypeRuleHelper::class); + + $rules = [ + // level 0 + new ExistingClassesInClassImplementsRule($classNameCheck, $reflectionProvider), + new ExistingClassesInInterfaceExtendsRule($classNameCheck, $reflectionProvider), + new ExistingClassInClassExtendsRule($classNameCheck, $reflectionProvider), + new ExistingClassInTraitUseRule($classNameCheck, $reflectionProvider), + new ExistingClassesInTypehintsRule($functionDefinitionCheck), + new \PHPStan\Rules\Functions\ExistingClassesInTypehintsRule($functionDefinitionCheck), + new ExistingClassesInPropertiesRule($reflectionProvider, $classNameCheck, $unresolvableTypeHelper, $phpVersion, true, false), + new OverridingMethodRule( + $phpVersion, + new MethodSignatureRule($phpClassReflectionExtension, true, true), + true, + new MethodParameterComparisonHelper($phpVersion), + new MethodVisibilityComparisonHelper(), + $phpClassReflectionExtension, + $container->getParameter('checkMissingOverrideMethodAttribute'), + ), + new DuplicateDeclarationRule(), + new LocalTypeAliasesRule($localTypeAliasesCheck), + new LocalTypeTraitAliasesRule($localTypeAliasesCheck, $reflectionProvider), + new LocalTypeTraitUseAliasesRule($localTypeAliasesCheck), + + // level 2 + new ClassAncestorsRule($genericAncestorsCheck, $crossCheckInterfacesHelper), + new ClassTemplateTypeRule($templateTypeCheck), + new FunctionTemplateTypeRule($fileTypeMapper, $templateTypeCheck), + new FunctionSignatureVarianceRule($varianceCheck), + new InterfaceAncestorsRule($genericAncestorsCheck, $crossCheckInterfacesHelper), + new InterfaceTemplateTypeRule($templateTypeCheck), + new MethodTemplateTypeRule($fileTypeMapper, $templateTypeCheck), + new MethodTagTemplateTypeRule($methodTagTemplateTypeCheck), + new MethodSignatureVarianceRule($varianceCheck), + new TraitTemplateTypeRule($fileTypeMapper, $templateTypeCheck), + new IncompatiblePhpDocTypeRule($fileTypeMapper, new IncompatiblePhpDocTypeCheck($genericObjectTypeCheck, $unresolvableTypeHelper, $genericCallableRuleHelper)), + new IncompatiblePropertyPhpDocTypeRule($genericObjectTypeCheck, $unresolvableTypeHelper, $genericCallableRuleHelper), + new InvalidPhpDocTagValueRule( + $container->getByType(Lexer::class), + $container->getByType(PhpDocParser::class), + ), + new IncompatibleParamImmediatelyInvokedCallableRule($fileTypeMapper), + new IncompatibleSelfOutTypeRule($unresolvableTypeHelper, $genericObjectTypeCheck), + new IncompatibleClassConstantPhpDocTypeRule($genericObjectTypeCheck, $unresolvableTypeHelper), + new InvalidPHPStanDocTagRule( + $container->getByType(Lexer::class), + $container->getByType(PhpDocParser::class), + ), + new InvalidThrowsPhpDocValueRule($fileTypeMapper), + new MixinTraitRule($mixinCheck, $reflectionProvider), + new MixinRule($mixinCheck), + new MixinTraitUseRule($mixinCheck), + new MethodTagRule($methodTagCheck), + new MethodTagTraitRule($methodTagCheck, $reflectionProvider), + new MethodTagTraitUseRule($methodTagCheck), + new MethodTagTemplateTypeTraitRule($methodTagTemplateTypeCheck, $reflectionProvider), + new PropertyTagRule($propertyTagCheck), + new PropertyTagTraitRule($propertyTagCheck, $reflectionProvider), + new PropertyTagTraitUseRule($propertyTagCheck), + new EnumAncestorsRule($genericAncestorsCheck, $crossCheckInterfacesHelper), + new EnumTemplateTypeRule(), + new PropertyVarianceRule($varianceCheck), + new UsedTraitsRule($fileTypeMapper, $genericAncestorsCheck), + new FunctionAssertRule($assertRuleHelper), + new MethodAssertRule($assertRuleHelper), + new FunctionConditionalReturnTypeRule($conditionalReturnTypeRuleHelper), + new MethodConditionalReturnTypeRule($conditionalReturnTypeRuleHelper), + + // level 6 + new MissingFunctionParameterTypehintRule($missingTypehintCheck), + new MissingFunctionReturnTypehintRule($missingTypehintCheck), + new MissingMethodParameterTypehintRule($missingTypehintCheck), + new MissingMethodReturnTypehintRule($missingTypehintCheck), + new MissingPropertyTypehintRule($missingTypehintCheck), + new MissingMethodSelfOutTypeRule($missingTypehintCheck), + + // duplicate stubs + new DuplicateClassDeclarationRule($reflector, $relativePathHelper), + new DuplicateFunctionDeclarationRule($reflector, $relativePathHelper), + ]; + + return new DirectRuleRegistry($rules); + } + + private function getCollectorRegistry(Container $container): CollectorRegistry + { + return new CollectorRegistry([]); + } + +} diff --git a/src/PhpDoc/Tag/AssertTag.php b/src/PhpDoc/Tag/AssertTag.php new file mode 100644 index 00000000..ba434f8a --- /dev/null +++ b/src/PhpDoc/Tag/AssertTag.php @@ -0,0 +1,97 @@ +if; + } + + public function getType(): Type + { + return $this->type; + } + + public function getOriginalType(): Type + { + return $this->originalType ??= $this->type; + } + + public function getParameter(): AssertTagParameter + { + return $this->parameter; + } + + public function isNegated(): bool + { + return $this->negated; + } + + public function isEquality(): bool + { + return $this->equality; + } + + /** + * @return static + */ + public function withType(Type $type): TypedTag + { + $tag = new self($this->if, $type, $this->parameter, $this->negated, $this->equality, $this->isExplicit); + $tag->originalType = $this->getOriginalType(); + return $tag; + } + + public function withParameter(AssertTagParameter $parameter): self + { + $tag = new self($this->if, $this->type, $parameter, $this->negated, $this->equality, $this->isExplicit); + $tag->originalType = $this->getOriginalType(); + return $tag; + } + + public function negate(): self + { + if ($this->isEquality()) { + throw new ShouldNotHappenException(); + } + + $tag = new self($this->if, $this->type, $this->parameter, !$this->negated, $this->equality, $this->isExplicit); + $tag->originalType = $this->getOriginalType(); + return $tag; + } + + public function isExplicit(): bool + { + return $this->isExplicit; + } + + public function toImplicit(): self + { + return new self($this->if, $this->type, $this->parameter, $this->negated, $this->equality, false); + } + +} diff --git a/src/PhpDoc/Tag/AssertTagParameter.php b/src/PhpDoc/Tag/AssertTagParameter.php new file mode 100644 index 00000000..dd752b33 --- /dev/null +++ b/src/PhpDoc/Tag/AssertTagParameter.php @@ -0,0 +1,60 @@ +parameterName; + } + + public function changeParameterName(string $parameterName): self + { + return new self( + $parameterName, + $this->property, + $this->method, + ); + } + + public function describe(): string + { + if ($this->property !== null) { + return sprintf('%s->%s', $this->parameterName, $this->property); + } + + if ($this->method !== null) { + return sprintf('%s->%s()', $this->parameterName, $this->method); + } + + return $this->parameterName; + } + + public function getExpr(Expr $parameter): Expr + { + if ($this->property !== null) { + return new Expr\PropertyFetch($parameter, $this->property); + } + + if ($this->method !== null) { + return new Expr\MethodCall($parameter, $this->method); + } + + return $parameter; + } + +} diff --git a/src/PhpDoc/Tag/DeprecatedTag.php b/src/PhpDoc/Tag/DeprecatedTag.php new file mode 100644 index 00000000..47801c36 --- /dev/null +++ b/src/PhpDoc/Tag/DeprecatedTag.php @@ -0,0 +1,21 @@ +message; + } + +} diff --git a/src/PhpDoc/Tag/ExtendsTag.php b/src/PhpDoc/Tag/ExtendsTag.php new file mode 100644 index 00000000..69887b99 --- /dev/null +++ b/src/PhpDoc/Tag/ExtendsTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/ImplementsTag.php b/src/PhpDoc/Tag/ImplementsTag.php new file mode 100644 index 00000000..353b7146 --- /dev/null +++ b/src/PhpDoc/Tag/ImplementsTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/MethodTag.php b/src/PhpDoc/Tag/MethodTag.php new file mode 100644 index 00000000..a1a6eb10 --- /dev/null +++ b/src/PhpDoc/Tag/MethodTag.php @@ -0,0 +1,53 @@ + $parameters + * @param array $templateTags + */ + public function __construct( + private Type $returnType, + private bool $isStatic, + private array $parameters, + private array $templateTags, + ) + { + } + + public function getReturnType(): Type + { + return $this->returnType; + } + + public function isStatic(): bool + { + return $this->isStatic; + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @return array + */ + public function getTemplateTags(): array + { + return $this->templateTags; + } + +} diff --git a/src/PhpDoc/Tag/MethodTagParameter.php b/src/PhpDoc/Tag/MethodTagParameter.php new file mode 100644 index 00000000..f5e54ee1 --- /dev/null +++ b/src/PhpDoc/Tag/MethodTagParameter.php @@ -0,0 +1,50 @@ +type; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isOptional(): bool + { + return $this->isOptional; + } + + public function isVariadic(): bool + { + return $this->isVariadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + +} diff --git a/src/PhpDoc/Tag/MixinTag.php b/src/PhpDoc/Tag/MixinTag.php new file mode 100644 index 00000000..3a0a9c1c --- /dev/null +++ b/src/PhpDoc/Tag/MixinTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/ParamClosureThisTag.php b/src/PhpDoc/Tag/ParamClosureThisTag.php new file mode 100644 index 00000000..53da6a35 --- /dev/null +++ b/src/PhpDoc/Tag/ParamClosureThisTag.php @@ -0,0 +1,30 @@ +type; + } + + public function withType(Type $type): self + { + return new self($type); + } + +} diff --git a/src/PhpDoc/Tag/ParamOutTag.php b/src/PhpDoc/Tag/ParamOutTag.php new file mode 100644 index 00000000..286d1a5b --- /dev/null +++ b/src/PhpDoc/Tag/ParamOutTag.php @@ -0,0 +1,28 @@ +type; + } + + public function withType(Type $type): self + { + return new self($type); + } + +} diff --git a/src/PhpDoc/Tag/ParamTag.php b/src/PhpDoc/Tag/ParamTag.php new file mode 100644 index 00000000..a3517418 --- /dev/null +++ b/src/PhpDoc/Tag/ParamTag.php @@ -0,0 +1,36 @@ +type; + } + + public function isVariadic(): bool + { + return $this->isVariadic; + } + + public function withType(Type $type): self + { + return new self($type, $this->isVariadic); + } + +} diff --git a/src/PhpDoc/Tag/PropertyTag.php b/src/PhpDoc/Tag/PropertyTag.php new file mode 100644 index 00000000..bc7b9d53 --- /dev/null +++ b/src/PhpDoc/Tag/PropertyTag.php @@ -0,0 +1,47 @@ +readableType; + } + + public function getWritableType(): ?Type + { + return $this->writableType; + } + + /** + * @phpstan-assert-if-true !null $this->getReadableType() + */ + public function isReadable(): bool + { + return $this->readableType !== null; + } + + /** + * @phpstan-assert-if-true !null $this->getWritableType() + */ + public function isWritable(): bool + { + return $this->writableType !== null; + } + +} diff --git a/src/PhpDoc/Tag/RequireExtendsTag.php b/src/PhpDoc/Tag/RequireExtendsTag.php new file mode 100644 index 00000000..ebbc29f8 --- /dev/null +++ b/src/PhpDoc/Tag/RequireExtendsTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/RequireImplementsTag.php b/src/PhpDoc/Tag/RequireImplementsTag.php new file mode 100644 index 00000000..9b5a5663 --- /dev/null +++ b/src/PhpDoc/Tag/RequireImplementsTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/ReturnTag.php b/src/PhpDoc/Tag/ReturnTag.php new file mode 100644 index 00000000..6344e90e --- /dev/null +++ b/src/PhpDoc/Tag/ReturnTag.php @@ -0,0 +1,38 @@ +type; + } + + public function isExplicit(): bool + { + return $this->isExplicit; + } + + public function withType(Type $type): self + { + return new self($type, $this->isExplicit); + } + + public function toImplicit(): self + { + return new self($this->type, false); + } + +} diff --git a/src/PhpDoc/Tag/SelfOutTypeTag.php b/src/PhpDoc/Tag/SelfOutTypeTag.php new file mode 100644 index 00000000..ed9b14be --- /dev/null +++ b/src/PhpDoc/Tag/SelfOutTypeTag.php @@ -0,0 +1,28 @@ +type; + } + + public function withType(Type $type): self + { + return new self($type); + } + +} diff --git a/src/PhpDoc/Tag/TemplateTag.php b/src/PhpDoc/Tag/TemplateTag.php new file mode 100644 index 00000000..aabede4c --- /dev/null +++ b/src/PhpDoc/Tag/TemplateTag.php @@ -0,0 +1,45 @@ +name; + } + + public function getBound(): Type + { + return $this->bound; + } + + public function getDefault(): ?Type + { + return $this->default; + } + + public function getVariance(): TemplateTypeVariance + { + return $this->variance; + } + +} diff --git a/src/PhpDoc/Tag/ThrowsTag.php b/src/PhpDoc/Tag/ThrowsTag.php new file mode 100644 index 00000000..bf593627 --- /dev/null +++ b/src/PhpDoc/Tag/ThrowsTag.php @@ -0,0 +1,23 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/TypeAliasImportTag.php b/src/PhpDoc/Tag/TypeAliasImportTag.php new file mode 100644 index 00000000..da291cda --- /dev/null +++ b/src/PhpDoc/Tag/TypeAliasImportTag.php @@ -0,0 +1,29 @@ +importedAlias; + } + + public function getImportedFrom(): string + { + return $this->importedFrom; + } + + public function getImportedAs(): ?string + { + return $this->importedAs; + } + +} diff --git a/src/PhpDoc/Tag/TypeAliasTag.php b/src/PhpDoc/Tag/TypeAliasTag.php new file mode 100644 index 00000000..4c9ec277 --- /dev/null +++ b/src/PhpDoc/Tag/TypeAliasTag.php @@ -0,0 +1,37 @@ +aliasName; + } + + public function getTypeAlias(): TypeAlias + { + return new TypeAlias( + $this->typeNode, + $this->nameScope, + ); + } + +} diff --git a/src/PhpDoc/Tag/TypedTag.php b/src/PhpDoc/Tag/TypedTag.php new file mode 100644 index 00000000..1ed9e997 --- /dev/null +++ b/src/PhpDoc/Tag/TypedTag.php @@ -0,0 +1,19 @@ +type; + } + +} diff --git a/src/PhpDoc/Tag/VarTag.php b/src/PhpDoc/Tag/VarTag.php new file mode 100644 index 00000000..191896ec --- /dev/null +++ b/src/PhpDoc/Tag/VarTag.php @@ -0,0 +1,28 @@ +type; + } + + public function withType(Type $type): self + { + return new self($type); + } + +} diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php new file mode 100644 index 00000000..d1a33886 --- /dev/null +++ b/src/PhpDoc/TypeNodeResolver.php @@ -0,0 +1,1284 @@ + */ + private array $genericTypeResolvingStack = []; + + public function __construct( + private TypeNodeResolverExtensionRegistryProvider $extensionRegistryProvider, + private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, + private TypeAliasResolverProvider $typeAliasResolverProvider, + private ConstantResolver $constantResolver, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) + { + } + + /** @api */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): Type + { + foreach ($this->extensionRegistryProvider->getRegistry()->getExtensions() as $extension) { + $type = $extension->resolve($typeNode, $nameScope); + if ($type !== null) { + return $type; + } + } + + if ($typeNode instanceof IdentifierTypeNode) { + return $this->resolveIdentifierTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof ThisTypeNode) { + return $this->resolveThisTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof NullableTypeNode) { + return $this->resolveNullableTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof UnionTypeNode) { + return $this->resolveUnionTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof IntersectionTypeNode) { + return $this->resolveIntersectionTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof ConditionalTypeNode) { + return $this->resolveConditionalTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof ConditionalTypeForParameterNode) { + return $this->resolveConditionalTypeForParameterNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof ArrayTypeNode) { + return $this->resolveArrayTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof GenericTypeNode) { + return $this->resolveGenericTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof CallableTypeNode) { + return $this->resolveCallableTypeNode($typeNode, $nameScope); + + } elseif ($typeNode instanceof ArrayShapeNode) { + return $this->resolveArrayShapeNode($typeNode, $nameScope); + } elseif ($typeNode instanceof ObjectShapeNode) { + return $this->resolveObjectShapeNode($typeNode, $nameScope); + } elseif ($typeNode instanceof ConstTypeNode) { + return $this->resolveConstTypeNode($typeNode, $nameScope); + } elseif ($typeNode instanceof OffsetAccessTypeNode) { + return $this->resolveOffsetAccessNode($typeNode, $nameScope); + } elseif ($typeNode instanceof InvalidTypeNode) { + return new MixedType(true); + } + + return new ErrorType(); + } + + private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameScope $nameScope): Type + { + switch (strtolower($typeNode->name)) { + case 'int': + case 'integer': + return new IntegerType(); + + case 'positive-int': + return IntegerRangeType::fromInterval(1, null); + + case 'negative-int': + return IntegerRangeType::fromInterval(null, -1); + + case 'non-positive-int': + return IntegerRangeType::fromInterval(null, 0); + + case 'non-negative-int': + return IntegerRangeType::fromInterval(0, null); + + case 'non-zero-int': + return new UnionType([ + IntegerRangeType::fromInterval(null, -1), + IntegerRangeType::fromInterval(1, null), + ]); + + case 'string': + return new StringType(); + + case 'lowercase-string': + return new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]); + + case 'uppercase-string': + return new IntersectionType([new StringType(), new AccessoryUppercaseStringType()]); + + case 'literal-string': + return new IntersectionType([new StringType(), new AccessoryLiteralStringType()]); + + case 'class-string': + case 'interface-string': + case 'trait-string': + return new ClassStringType(); + + case 'enum-string': + return new GenericClassStringType(new ObjectType('UnitEnum')); + + case 'callable-string': + return new IntersectionType([new StringType(), new CallableType()]); + + case 'array-key': + return new BenevolentUnionType([new IntegerType(), new StringType()]); + + case 'scalar': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new UnionType([new IntegerType(), new FloatType(), new StringType(), new BooleanType()]); + + case 'empty-scalar': + return TypeCombinator::intersect( + new UnionType([new IntegerType(), new FloatType(), new StringType(), new BooleanType()]), + StaticTypeFactory::falsey(), + ); + + case 'non-empty-scalar': + return TypeCombinator::remove( + new UnionType([new IntegerType(), new FloatType(), new StringType(), new BooleanType()]), + StaticTypeFactory::falsey(), + ); + + case 'number': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new UnionType([new IntegerType(), new FloatType()]); + + case 'numeric': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new UnionType([ + new IntegerType(), + new FloatType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + + case 'numeric-string': + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + + case 'non-empty-string': + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]); + + case 'non-empty-lowercase-string': + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryLowercaseStringType(), + ]); + + case 'non-empty-uppercase-string': + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryUppercaseStringType(), + ]); + + case 'truthy-string': + case 'non-falsy-string': + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + + case 'non-empty-literal-string': + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryLiteralStringType(), + ]); + + case 'bool': + return new BooleanType(); + + case 'boolean': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new BooleanType(); + + case 'true': + return new ConstantBooleanType(true); + + case 'false': + return new ConstantBooleanType(false); + + case 'null': + return new NullType(); + + case 'float': + return new FloatType(); + + case 'double': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new FloatType(); + + case 'array': + case 'associative-array': + return new ArrayType(new MixedType(), new MixedType()); + + case 'non-empty-array': + return TypeCombinator::intersect( + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + ); + + case 'iterable': + return new IterableType(new MixedType(), new MixedType()); + + case 'callable': + return new CallableType(); + + case 'pure-callable': + return new CallableType(null, null, true, null, null, [], TrinaryLogic::createYes()); + + case 'pure-closure': + return ClosureType::createPure(); + + case 'resource': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new ResourceType(); + + case 'open-resource': + case 'closed-resource': + return new ResourceType(); + + case 'mixed': + return new MixedType(true); + + case 'non-empty-mixed': + return new MixedType(true, StaticTypeFactory::falsey()); + + case 'void': + return new VoidType(); + + case 'object': + return new ObjectWithoutClassType(); + + case 'callable-object': + return new IntersectionType([new ObjectWithoutClassType(), new CallableType()]); + + case 'callable-array': + return new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]); + + case 'never': + case 'noreturn': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + + if ($type !== null) { + return $type; + } + + return new NonAcceptingNeverType(); + + case 'never-return': + case 'never-returns': + case 'no-return': + return new NonAcceptingNeverType(); + + case 'list': + return TypeCombinator::intersect(new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new MixedType()), new AccessoryArrayListType()); + case 'non-empty-list': + return TypeCombinator::intersect( + new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new MixedType()), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + + case 'empty': + $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); + if ($type !== null) { + return $type; + } + + return StaticTypeFactory::falsey(); + case '__stringandstringable': + return new StringAlwaysAcceptingObjectWithToStringType(); + } + + if ($nameScope->getClassName() !== null) { + switch (strtolower($typeNode->name)) { + case 'self': + return new ObjectType($nameScope->getClassName()); + + case 'static': + if ($this->getReflectionProvider()->hasClass($nameScope->getClassName())) { + $classReflection = $this->getReflectionProvider()->getClass($nameScope->getClassName()); + + return new StaticType($classReflection); + } + + return new ErrorType(); + case 'parent': + if ($this->getReflectionProvider()->hasClass($nameScope->getClassName())) { + $classReflection = $this->getReflectionProvider()->getClass($nameScope->getClassName()); + if ($classReflection->getParentClass() !== null) { + return new ObjectType($classReflection->getParentClass()->getName()); + } + } + + return new NonexistentParentClassType(); + } + } + + if (!$nameScope->shouldBypassTypeAliases()) { + $typeAlias = $this->getTypeAliasResolver()->resolveTypeAlias($typeNode->name, $nameScope); + if ($typeAlias !== null) { + return $typeAlias; + } + } + + $templateType = $nameScope->resolveTemplateTypeName($typeNode->name); + if ($templateType !== null) { + return $templateType; + } + + $stringName = $nameScope->resolveStringName($typeNode->name); + if (str_contains($stringName, '-') && !str_starts_with($stringName, 'OCI-')) { + return new ErrorType(); + } + + if ($this->mightBeConstant($typeNode->name) && !$this->getReflectionProvider()->hasClass($stringName)) { + $constType = $this->tryResolveConstant($typeNode->name, $nameScope); + if ($constType !== null) { + return $constType; + } + } + + return new ObjectType($stringName); + } + + private function mightBeConstant(string $name): bool + { + return preg_match('((?:^|\\\\)[A-Z_][A-Z0-9_]*$)', $name) > 0; + } + + private function tryResolveConstant(string $name, NameScope $nameScope): ?Type + { + foreach ($nameScope->resolveConstantNames($name) as $constName) { + $nameNode = new Name\FullyQualified(explode('\\', $constName)); + $constType = $this->constantResolver->resolveConstant($nameNode, null); + if ($constType !== null) { + return $constType; + } + } + + return null; + } + + private function tryResolvePseudoTypeClassType(IdentifierTypeNode $typeNode, NameScope $nameScope): ?Type + { + if ($nameScope->hasUseAlias($typeNode->name)) { + return new ObjectType($nameScope->resolveStringName($typeNode->name)); + } + + if ($nameScope->getNamespace() === null) { + return null; + } + + $className = $nameScope->resolveStringName($typeNode->name); + + if ($this->getReflectionProvider()->hasClass($className)) { + return new ObjectType($className); + } + + return null; + } + + private function resolveThisTypeNode(ThisTypeNode $typeNode, NameScope $nameScope): Type + { + $className = $nameScope->getClassName(); + if ($className !== null) { + if ($this->getReflectionProvider()->hasClass($className)) { + return new ThisType($this->getReflectionProvider()->getClass($className)); + } + } + + return new ErrorType(); + } + + private function resolveNullableTypeNode(NullableTypeNode $typeNode, NameScope $nameScope): Type + { + return TypeCombinator::union($this->resolve($typeNode->type, $nameScope), new NullType()); + } + + private function resolveUnionTypeNode(UnionTypeNode $typeNode, NameScope $nameScope): Type + { + $iterableTypeNodes = []; + $otherTypeNodes = []; + + foreach ($typeNode->types as $innerTypeNode) { + if ($innerTypeNode instanceof ArrayTypeNode) { + $iterableTypeNodes[] = $innerTypeNode->type; + } else { + $otherTypeNodes[] = $innerTypeNode; + } + } + + $otherTypeTypes = $this->resolveMultiple($otherTypeNodes, $nameScope); + if (count($iterableTypeNodes) > 0) { + $arrayTypeTypes = $this->resolveMultiple($iterableTypeNodes, $nameScope); + $arrayTypeType = TypeCombinator::union(...$arrayTypeTypes); + $addArray = true; + + foreach ($otherTypeTypes as &$type) { + if (!$type->isIterable()->yes() || !$type->getIterableValueType()->isSuperTypeOf($arrayTypeType)->yes()) { + continue; + } + + if ($type instanceof ObjectType && !$type instanceof GenericObjectType) { + $type = new IntersectionType([$type, new IterableType(new MixedType(), $arrayTypeType)]); + } elseif ($type instanceof ArrayType) { + $type = new ArrayType(new MixedType(), $arrayTypeType); + } elseif ($type instanceof ConstantArrayType) { + $type = new ArrayType(new MixedType(), $arrayTypeType); + } elseif ($type instanceof IterableType) { + $type = new IterableType(new MixedType(), $arrayTypeType); + } else { + continue; + } + + $addArray = false; + } + + if ($addArray) { + $otherTypeTypes[] = new ArrayType(new MixedType(), $arrayTypeType); + } + } + + return TypeCombinator::union(...$otherTypeTypes); + } + + private function resolveIntersectionTypeNode(IntersectionTypeNode $typeNode, NameScope $nameScope): Type + { + $types = $this->resolveMultiple($typeNode->types, $nameScope); + return TypeCombinator::intersect(...$types); + } + + private function resolveConditionalTypeNode(ConditionalTypeNode $typeNode, NameScope $nameScope): Type + { + return new ConditionalType( + $this->resolve($typeNode->subjectType, $nameScope), + $this->resolve($typeNode->targetType, $nameScope), + $this->resolve($typeNode->if, $nameScope), + $this->resolve($typeNode->else, $nameScope), + $typeNode->negated, + ); + } + + private function resolveConditionalTypeForParameterNode(ConditionalTypeForParameterNode $typeNode, NameScope $nameScope): Type + { + return new ConditionalTypeForParameter( + $typeNode->parameterName, + $this->resolve($typeNode->targetType, $nameScope), + $this->resolve($typeNode->if, $nameScope), + $this->resolve($typeNode->else, $nameScope), + $typeNode->negated, + ); + } + + private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type + { + $itemType = $this->resolve($typeNode->type, $nameScope); + return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $itemType); + } + + private function resolveGenericTypeNode(GenericTypeNode $typeNode, NameScope $nameScope): Type + { + $mainTypeName = strtolower($typeNode->type->name); + $genericTypes = $this->resolveMultiple($typeNode->genericTypes, $nameScope); + $variances = array_map( + static function (string $variance): TemplateTypeVariance { + switch ($variance) { + case GenericTypeNode::VARIANCE_INVARIANT: + return TemplateTypeVariance::createInvariant(); + case GenericTypeNode::VARIANCE_COVARIANT: + return TemplateTypeVariance::createCovariant(); + case GenericTypeNode::VARIANCE_CONTRAVARIANT: + return TemplateTypeVariance::createContravariant(); + case GenericTypeNode::VARIANCE_BIVARIANT: + return TemplateTypeVariance::createBivariant(); + } + }, + $typeNode->variances, + ); + + if (in_array($mainTypeName, ['array', 'non-empty-array'], true)) { + if (count($genericTypes) === 1) { // array + $arrayType = new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $genericTypes[0]); + } elseif (count($genericTypes) === 2) { // array + $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ + new IntegerType(), + new StringType(), + ]))->toArrayKey(); + $finiteTypes = $keyType->getFiniteTypes(); + if ( + count($finiteTypes) === 1 + && ($finiteTypes[0] instanceof ConstantStringType || $finiteTypes[0] instanceof ConstantIntegerType) + ) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $arrayBuilder->setOffsetValueType($finiteTypes[0], $genericTypes[1], true); + $arrayType = $arrayBuilder->getArray(); + } else { + $arrayType = new ArrayType($keyType, $genericTypes[1]); + } + } else { + return new ErrorType(); + } + + if ($mainTypeName === 'non-empty-array') { + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + return $arrayType; + } elseif (in_array($mainTypeName, ['list', 'non-empty-list'], true)) { + if (count($genericTypes) === 1) { // list + $listType = TypeCombinator::intersect(new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $genericTypes[0]), new AccessoryArrayListType()); + if ($mainTypeName === 'non-empty-list') { + return TypeCombinator::intersect($listType, new NonEmptyArrayType()); + } + + return $listType; + } + + return new ErrorType(); + } elseif ($mainTypeName === 'iterable') { + if (count($genericTypes) === 1) { // iterable + return new IterableType(new MixedType(true), $genericTypes[0]); + + } + + if (count($genericTypes) === 2) { // iterable + return new IterableType($genericTypes[0], $genericTypes[1]); + } + } elseif (in_array($mainTypeName, ['class-string', 'interface-string'], true)) { + if (count($genericTypes) === 1) { + $genericType = $genericTypes[0]; + if ($genericType->isObject()->yes() || $genericType instanceof MixedType) { + return new GenericClassStringType($genericType); + } + } + + return new ErrorType(); + } elseif ($mainTypeName === 'enum-string') { + if (count($genericTypes) === 1) { + $genericType = $genericTypes[0]; + return new GenericClassStringType(TypeCombinator::intersect($genericType, new ObjectType('UnitEnum'))); + } + + return new ErrorType(); + } elseif ($mainTypeName === 'int') { + if (count($genericTypes) === 2) { // int, int<1, 3> + + if ($genericTypes[0] instanceof ConstantIntegerType) { + $min = $genericTypes[0]->getValue(); + } elseif ($typeNode->genericTypes[0] instanceof IdentifierTypeNode && $typeNode->genericTypes[0]->name === 'min') { + $min = null; + } else { + return new ErrorType(); + } + + if ($genericTypes[1] instanceof ConstantIntegerType) { + $max = $genericTypes[1]->getValue(); + } elseif ($typeNode->genericTypes[1] instanceof IdentifierTypeNode && $typeNode->genericTypes[1]->name === 'max') { + $max = null; + } else { + return new ErrorType(); + } + + return IntegerRangeType::fromInterval($min, $max); + } + } elseif ($mainTypeName === 'key-of') { + if (count($genericTypes) === 1) { // key-of + $type = new KeyOfType($genericTypes[0]); + return $type->isResolvable() ? $type->resolve() : $type; + } + + return new ErrorType(); + } elseif ($mainTypeName === 'value-of') { + if (count($genericTypes) === 1) { // value-of + $type = new ValueOfType($genericTypes[0]); + + return $type->isResolvable() ? $type->resolve() : $type; + } + + return new ErrorType(); + } elseif ($mainTypeName === 'int-mask-of') { + if (count($genericTypes) === 1) { // int-mask-of + $maskType = $this->expandIntMaskToType($genericTypes[0]); + if ($maskType !== null) { + return $maskType; + } + } + + return new ErrorType(); + } elseif ($mainTypeName === 'int-mask') { + if (count($genericTypes) > 0) { // int-mask<1, 2, 4> + $maskType = $this->expandIntMaskToType(TypeCombinator::union(...$genericTypes)); + if ($maskType !== null) { + return $maskType; + } + } + + return new ErrorType(); + } elseif ($mainTypeName === '__benevolent') { + if (count($genericTypes) === 1) { + return TypeUtils::toBenevolentUnion($genericTypes[0]); + } + return new ErrorType(); + } elseif ($mainTypeName === 'template-type') { + if (count($genericTypes) === 3) { + $result = []; + /** @var class-string $ancestorClassName */ + foreach ($genericTypes[1]->getObjectClassNames() as $ancestorClassName) { + foreach ($genericTypes[2]->getConstantStrings() as $templateTypeName) { + $result[] = new GetTemplateTypeType($genericTypes[0], $ancestorClassName, $templateTypeName->getValue()); + } + } + + return TypeCombinator::union(...$result); + } + + return new ErrorType(); + } elseif ($mainTypeName === 'new') { + if (count($genericTypes) === 1) { + $type = new NewObjectType($genericTypes[0]); + return $type->isResolvable() ? $type->resolve() : $type; + } + + return new ErrorType(); + } elseif ($mainTypeName === 'static') { + if ($nameScope->getClassName() !== null && $this->getReflectionProvider()->hasClass($nameScope->getClassName())) { + $classReflection = $this->getReflectionProvider()->getClass($nameScope->getClassName()); + + return new GenericStaticType($classReflection, $genericTypes, null, $variances); + } + + return new ErrorType(); + } + + $mainType = $this->resolveIdentifierTypeNode($typeNode->type, $nameScope); + $mainTypeObjectClassNames = $mainType->getObjectClassNames(); + if (count($mainTypeObjectClassNames) > 1) { + if ($mainType instanceof TemplateType) { + return new ErrorType(); + } + throw new ShouldNotHappenException(); + } + $mainTypeClassName = $mainTypeObjectClassNames[0] ?? null; + + if ($mainTypeClassName !== null) { + if (!$this->getReflectionProvider()->hasClass($mainTypeClassName)) { + return new GenericObjectType($mainTypeClassName, $genericTypes, null, null, $variances); + } + + $classReflection = $this->getReflectionProvider()->getClass($mainTypeClassName); + if ($classReflection->isGeneric()) { + $templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes()); + for ($i = count($genericTypes), $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) { + $templateType = $templateTypes[$i]; + if (!$templateType instanceof TemplateType || $templateType->getDefault() === null) { + continue; + } + $genericTypes[] = $templateType->getDefault(); + } + + if (in_array($mainTypeClassName, [ + Traversable::class, + IteratorAggregate::class, + Iterator::class, + ], true)) { + if (count($genericTypes) === 1) { + return new GenericObjectType($mainTypeClassName, [ + new MixedType(true), + $genericTypes[0], + ], null, null, [ + TemplateTypeVariance::createInvariant(), + $variances[0], + ]); + } + + if (count($genericTypes) === 2) { + return new GenericObjectType($mainTypeClassName, [ + $genericTypes[0], + $genericTypes[1], + ], null, null, [ + $variances[0], + $variances[1], + ]); + } + } + if ($mainTypeClassName === Generator::class) { + if (count($genericTypes) === 1) { + $mixed = new MixedType(true); + return new GenericObjectType($mainTypeClassName, [ + $mixed, + $genericTypes[0], + $mixed, + $mixed, + ], null, null, [ + TemplateTypeVariance::createInvariant(), + $variances[0], + TemplateTypeVariance::createInvariant(), + TemplateTypeVariance::createInvariant(), + ]); + } + + if (count($genericTypes) === 2) { + $mixed = new MixedType(true); + return new GenericObjectType($mainTypeClassName, [ + $genericTypes[0], + $genericTypes[1], + $mixed, + $mixed, + ], null, null, [ + $variances[0], + $variances[1], + TemplateTypeVariance::createInvariant(), + TemplateTypeVariance::createInvariant(), + ]); + } + } + + if (!$mainType->isIterable()->yes()) { + return new GenericObjectType($mainTypeClassName, $genericTypes, null, null, $variances); + } + + if ( + count($genericTypes) !== 1 + || $classReflection->getTemplateTypeMap()->count() === 1 + ) { + return new GenericObjectType($mainTypeClassName, $genericTypes, null, null, $variances); + } + } + } + + if ($mainType->isIterable()->yes()) { + if ($mainTypeClassName !== null) { + if (isset($this->genericTypeResolvingStack[$mainTypeClassName])) { + return new ErrorType(); + } + + $this->genericTypeResolvingStack[$mainTypeClassName] = true; + } + + try { + if (count($genericTypes) === 1) { // Foo + return TypeCombinator::intersect( + $mainType, + new IterableType(new MixedType(true), $genericTypes[0]), + ); + } + + if (count($genericTypes) === 2) { // Foo + return TypeCombinator::intersect( + $mainType, + new IterableType($genericTypes[0], $genericTypes[1]), + ); + } + } finally { + if ($mainTypeClassName !== null) { + unset($this->genericTypeResolvingStack[$mainTypeClassName]); + } + } + } + + if ($mainTypeClassName !== null) { + return new GenericObjectType($mainTypeClassName, $genericTypes, null, null, $variances); + } + + return new ErrorType(); + } + + private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $nameScope): Type + { + $templateTags = []; + + if (count($typeNode->templateTypes) > 0) { + foreach ($typeNode->templateTypes as $templateType) { + $templateTags[$templateType->name] = new TemplateTag( + $templateType->name, + $templateType->bound !== null + ? $this->resolve($templateType->bound, $nameScope) + : new MixedType(), + $templateType->default !== null + ? $this->resolve($templateType->default, $nameScope) + : null, + TemplateTypeVariance::createInvariant(), + ); + } + $templateTypeScope = TemplateTypeScope::createWithAnonymousFunction(); + + $templateTypeMap = new TemplateTypeMap(array_map( + static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), + $templateTags, + )); + + $nameScope = $nameScope->withTemplateTypeMap($templateTypeMap); + } else { + $templateTypeMap = TemplateTypeMap::createEmpty(); + } + + $mainType = $this->resolve($typeNode->identifier, $nameScope); + + $isVariadic = false; + $parameters = array_values(array_map( + function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadic): NativeParameterReflection { + $isVariadic = $isVariadic || $parameterNode->isVariadic; + $parameterName = $parameterNode->parameterName; + if (str_starts_with($parameterName, '$')) { + $parameterName = substr($parameterName, 1); + } + + return new NativeParameterReflection( + $parameterName, + $parameterNode->isOptional || $parameterNode->isVariadic, + $this->resolve($parameterNode->type, $nameScope), + $parameterNode->isReference ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), + $parameterNode->isVariadic, + null, + ); + }, + $typeNode->parameters, + )); + + $returnType = $this->resolve($typeNode->returnType, $nameScope); + + if ($mainType instanceof CallableType) { + $pure = $mainType->isPure(); + if ($pure->yes() && $returnType->isVoid()->yes()) { + return new ErrorType(); + } + + return new CallableType($parameters, $returnType, $isVariadic, $templateTypeMap, null, $templateTags, $pure); + + } elseif ( + $mainType instanceof ObjectType + && $mainType->getClassName() === Closure::class + ) { + return new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, null, null, $templateTags, [], [ + new SimpleImpurePoint( + 'functionCall', + 'call to a Closure', + false, + ), + ]); + } elseif ($mainType instanceof ClosureType) { + $closure = new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, null, null, $templateTags, [], $mainType->getImpurePoints(), $mainType->getInvalidateExpressions(), $mainType->getUsedVariables(), $mainType->acceptsNamedArguments()); + if ($closure->isPure()->yes() && $returnType->isVoid()->yes()) { + return new ErrorType(); + } + + return $closure; + } + + return new ErrorType(); + } + + private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $nameScope): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + if (count($typeNode->items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + $builder->degradeToGeneralArray(true); + } + + foreach ($typeNode->items as $itemNode) { + $offsetType = null; + if ($itemNode->keyName instanceof ConstExprIntegerNode) { + $offsetType = new ConstantIntegerType((int) $itemNode->keyName->value); + } elseif ($itemNode->keyName instanceof IdentifierTypeNode) { + $offsetType = new ConstantStringType($itemNode->keyName->name); + } elseif ($itemNode->keyName instanceof ConstExprStringNode) { + $offsetType = new ConstantStringType($itemNode->keyName->value); + } elseif ($itemNode->keyName !== null) { + throw new ShouldNotHappenException('Unsupported key node type: ' . get_class($itemNode->keyName)); + } + $builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional); + } + + $arrayType = $builder->getArray(); + if (in_array($typeNode->kind, [ + ArrayShapeNode::KIND_LIST, + ArrayShapeNode::KIND_NON_EMPTY_LIST, + ], true)) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + if (in_array($typeNode->kind, [ + ArrayShapeNode::KIND_NON_EMPTY_ARRAY, + ArrayShapeNode::KIND_NON_EMPTY_LIST, + ], true)) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + return $arrayType; + } + + private function resolveObjectShapeNode(ObjectShapeNode $typeNode, NameScope $nameScope): Type + { + $properties = []; + $optionalProperties = []; + foreach ($typeNode->items as $itemNode) { + if ($itemNode->keyName instanceof IdentifierTypeNode) { + $propertyName = $itemNode->keyName->name; + } elseif ($itemNode->keyName instanceof ConstExprStringNode) { + $propertyName = $itemNode->keyName->value; + } + + if ($itemNode->optional) { + $optionalProperties[] = $propertyName; + } + + $properties[$propertyName] = $this->resolve($itemNode->valueType, $nameScope); + } + + return new ObjectShapeType($properties, $optionalProperties); + } + + private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameScope): Type + { + $constExpr = $typeNode->constExpr; + if ($constExpr instanceof ConstExprArrayNode) { + throw new ShouldNotHappenException(); // we prefer array shapes + } + + if ( + $constExpr instanceof ConstExprFalseNode + || $constExpr instanceof ConstExprTrueNode + || $constExpr instanceof ConstExprNullNode + ) { + throw new ShouldNotHappenException(); // we prefer IdentifierTypeNode + } + + if ($constExpr instanceof ConstFetchNode) { + if ($constExpr->className === '') { + throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode + } + + if ($nameScope->getClassName() !== null) { + switch (strtolower($constExpr->className)) { + case 'static': + case 'self': + $className = $nameScope->getClassName(); + break; + + case 'parent': + if ($this->getReflectionProvider()->hasClass($nameScope->getClassName())) { + $classReflection = $this->getReflectionProvider()->getClass($nameScope->getClassName()); + if ($classReflection->getParentClass() === null) { + return new ErrorType(); + + } + + $className = $classReflection->getParentClass()->getName(); + } + break; + } + } + + if (!isset($className)) { + $className = $nameScope->resolveStringName($constExpr->className); + } + + if (!$this->getReflectionProvider()->hasClass($className)) { + return new ErrorType(); + } + + $classReflection = $this->getReflectionProvider()->getClass($className); + + $constantName = $constExpr->name; + if (Strings::contains($constantName, '*')) { + // convert * into .*? and escape everything else so the constants can be matched against the pattern + $pattern = '{^' . str_replace('\\*', '.*?', preg_quote($constantName)) . '$}D'; + $constantTypes = []; + foreach ($classReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { + $classConstantName = $reflectionConstant->getName(); + if (Strings::match($classConstantName, $pattern) === null) { + continue; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($classConstantName)) { + $constantTypes[] = new EnumCaseObjectType($classReflection->getName(), $classConstantName); + continue; + } + + $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); + if (!$this->getReflectionProvider()->hasClass($declaringClassName)) { + continue; + } + + $constantTypes[] = $this->initializerExprTypeResolver->getType( + $reflectionConstant->getValueExpression(), + InitializerExprContext::fromClassReflection( + $this->getReflectionProvider()->getClass($declaringClassName), + ), + ); + } + + if (count($constantTypes) === 0) { + return new ErrorType(); + } + + return TypeCombinator::union(...$constantTypes); + } + + if (!$classReflection->hasConstant($constantName)) { + return new ErrorType(); + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + return new EnumCaseObjectType($classReflection->getName(), $constantName); + } + + $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($constantName); + if ($reflectionConstant === false) { + return new ErrorType(); + } + $declaringClass = $reflectionConstant->getDeclaringClass(); + + return $this->initializerExprTypeResolver->getType($reflectionConstant->getValueExpression(), InitializerExprContext::fromClass($declaringClass->getName(), $declaringClass->getFileName() ?: null)); + } + + if ($constExpr instanceof ConstExprFloatNode) { + return new ConstantFloatType((float) $constExpr->value); + } + + if ($constExpr instanceof ConstExprIntegerNode) { + return new ConstantIntegerType((int) $constExpr->value); + } + + if ($constExpr instanceof ConstExprStringNode) { + return new ConstantStringType($constExpr->value); + } + + return new ErrorType(); + } + + private function resolveOffsetAccessNode(OffsetAccessTypeNode $typeNode, NameScope $nameScope): Type + { + $type = $this->resolve($typeNode->type, $nameScope); + $offset = $this->resolve($typeNode->offset, $nameScope); + + if ($type->isOffsetAccessible()->no() || $type->hasOffsetValueType($offset)->no()) { + return new ErrorType(); + } + + return new OffsetAccessType($type, $offset); + } + + private function expandIntMaskToType(Type $type): ?Type + { + $ints = array_map(static fn (ConstantIntegerType $type) => $type->getValue(), TypeUtils::getConstantIntegers($type)); + if (count($ints) === 0) { + return null; + } + + $values = []; + + foreach ($ints as $int) { + if ($int !== 0 && !array_key_exists($int, $values)) { + foreach ($values as $value) { + $computedValue = $value | $int; + $values[$computedValue] = $computedValue; + } + } + + $values[$int] = $int; + } + + $values[0] = 0; + + $min = min($values); + $max = max($values); + + if ($max - $min === count($values) - 1) { + return IntegerRangeType::fromInterval($min, $max); + } + + return TypeCombinator::union(...array_map(static fn ($value) => new ConstantIntegerType($value), $values)); + } + + /** + * @api + * @param TypeNode[] $typeNodes + * @return list + */ + public function resolveMultiple(array $typeNodes, NameScope $nameScope): array + { + $types = []; + foreach ($typeNodes as $typeNode) { + $types[] = $this->resolve($typeNode, $nameScope); + } + + return $types; + } + + private function getReflectionProvider(): ReflectionProvider + { + return $this->reflectionProviderProvider->getReflectionProvider(); + } + + private function getTypeAliasResolver(): TypeAliasResolver + { + return $this->typeAliasResolverProvider->getTypeAliasResolver(); + } + +} diff --git a/src/PhpDoc/TypeNodeResolverAwareExtension.php b/src/PhpDoc/TypeNodeResolverAwareExtension.php new file mode 100644 index 00000000..d2d675f7 --- /dev/null +++ b/src/PhpDoc/TypeNodeResolverAwareExtension.php @@ -0,0 +1,12 @@ +setTypeNodeResolver($typeNodeResolver); + } + } + + /** + * @return TypeNodeResolverExtension[] + */ + public function getExtensions(): array + { + return $this->extensions; + } + +} diff --git a/src/PhpDoc/TypeNodeResolverExtensionRegistry.php b/src/PhpDoc/TypeNodeResolverExtensionRegistry.php new file mode 100644 index 00000000..2f17e5e0 --- /dev/null +++ b/src/PhpDoc/TypeNodeResolverExtensionRegistry.php @@ -0,0 +1,14 @@ +typeLexer->tokenize($typeString)); + $typeNode = $this->typeParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); // @phpstan-ignore missingType.checkedException + + return $this->typeNodeResolver->resolve($typeNode, $nameScope ?? new NameScope(null, [])); + } + +} diff --git a/src/Process/CpuCoreCounter.php b/src/Process/CpuCoreCounter.php new file mode 100644 index 00000000..c2f65ad2 --- /dev/null +++ b/src/Process/CpuCoreCounter.php @@ -0,0 +1,29 @@ +count !== null) { + return $this->count; + } + + try { + $this->count = (new FidryCpuCoreCounter())->getCount(); + } catch (NumberOfCpuCoreNotFound) { + $this->count = 1; + } + + return $this->count; + } + +} diff --git a/src/Process/ProcessCanceledException.php b/src/Process/ProcessCanceledException.php new file mode 100644 index 00000000..faa01267 --- /dev/null +++ b/src/Process/ProcessCanceledException.php @@ -0,0 +1,11 @@ +getOption('memory-limit') === null) { + $processCommandArray[] = '-d'; + $processCommandArray[] = 'memory_limit=' . ini_get('memory_limit'); + } + + foreach ([$mainScript, $commandName] as $arg) { + $processCommandArray[] = escapeshellarg($arg); + } + + if ($projectConfigFile !== null) { + $processCommandArray[] = '--configuration'; + $processCommandArray[] = escapeshellarg($projectConfigFile); + } + + $options = [ + AnalyseCommand::OPTION_LEVEL, + 'autoload-file', + 'memory-limit', + 'xdebug', + 'verbose', + ]; + foreach ($options as $optionName) { + /** @var bool|string|null $optionValue */ + $optionValue = $input->getOption($optionName); + if (is_bool($optionValue)) { + if ($optionValue === true) { + $processCommandArray[] = sprintf('--%s', $optionName); + } + continue; + } + if ($optionValue === null) { + continue; + } + + $processCommandArray[] = sprintf('--%s=%s', $optionName, escapeshellarg($optionValue)); + } + + $processCommandArray = array_merge($processCommandArray, $additionalItems); + + $processCommandArray[] = '--'; + + /** @var string[] $paths */ + $paths = $input->getArgument('paths'); + foreach ($paths as $path) { + $processCommandArray[] = escapeshellarg($path); + } + + return implode(' ', $processCommandArray); + } + +} diff --git a/src/Process/ProcessPromise.php b/src/Process/ProcessPromise.php new file mode 100644 index 00000000..6bebd9a2 --- /dev/null +++ b/src/Process/ProcessPromise.php @@ -0,0 +1,99 @@ + */ + private Deferred $deferred; + + private ?Process $process = null; + + private bool $canceled = false; + + public function __construct(private LoopInterface $loop, private string $name, private string $command) + { + $this->deferred = new Deferred(); + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return PromiseInterface + */ + public function run(): PromiseInterface + { + $tmpStdOutResource = tmpfile(); + if ($tmpStdOutResource === false) { + throw new ShouldNotHappenException('Failed creating temp file for stdout.'); + } + $tmpStdErrResource = tmpfile(); + if ($tmpStdErrResource === false) { + throw new ShouldNotHappenException('Failed creating temp file for stderr.'); + } + + $this->process = new Process($this->command, null, null, [ + 1 => $tmpStdOutResource, + 2 => $tmpStdErrResource, + ]); + $this->process->start($this->loop); + + $this->process->on('exit', function ($exitCode) use ($tmpStdOutResource, $tmpStdErrResource): void { + if ($this->canceled) { + fclose($tmpStdOutResource); + fclose($tmpStdErrResource); + return; + } + rewind($tmpStdOutResource); + $stdOut = stream_get_contents($tmpStdOutResource); + fclose($tmpStdOutResource); + + rewind($tmpStdErrResource); + $stdErr = stream_get_contents($tmpStdErrResource); + fclose($tmpStdErrResource); + + if ($exitCode === null) { + $this->deferred->reject(new ProcessCrashedException($stdOut . $stdErr)); + return; + } + + if ($exitCode === 0) { + if ($stdOut === false) { + $stdOut = ''; + } + $this->deferred->resolve($stdOut); + return; + } + + $this->deferred->reject(new ProcessCrashedException($stdOut . $stdErr)); + }); + + return $this->deferred->promise(); + } + + public function cancel(): void + { + if ($this->process === null) { + throw new ShouldNotHappenException('Cancelling process before running'); + } + $this->canceled = true; + $this->process->terminate(); + $this->deferred->reject(new ProcessCanceledException()); + } + +} diff --git a/src/Reflection/AdditionalConstructorsExtension.php b/src/Reflection/AdditionalConstructorsExtension.php new file mode 100644 index 00000000..02fcc2e5 --- /dev/null +++ b/src/Reflection/AdditionalConstructorsExtension.php @@ -0,0 +1,30 @@ + + */ + public function getAllowedSubTypes(ClassReflection $classReflection): array; + +} diff --git a/src/Reflection/Annotations/AnnotationMethodReflection.php b/src/Reflection/Annotations/AnnotationMethodReflection.php new file mode 100644 index 00000000..4869f60e --- /dev/null +++ b/src/Reflection/Annotations/AnnotationMethodReflection.php @@ -0,0 +1,185 @@ +|null */ + private ?array $variants = null; + + /** + * @param list $parameters + */ + public function __construct( + private string $name, + private ClassReflection $declaringClass, + private Type $returnType, + private array $parameters, + private bool $isStatic, + private bool $isVariadic, + private ?Type $throwType, + private TemplateTypeMap $templateTypeMap, + ) + { + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function isStatic(): bool + { + return $this->isStatic; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getName(): string + { + return $this->name; + } + + public function getVariants(): array + { + if ($this->variants === null) { + $this->variants = [ + new ExtendedFunctionVariant( + $this->templateTypeMap, + null, + $this->parameters, + $this->isVariadic, + $this->returnType, + $this->returnType, + new MixedType(), + ), + ]; + } + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return $this->throwType; + } + + public function hasSideEffects(): TrinaryLogic + { + if ($this->returnType->isVoid()->yes()) { + return TrinaryLogic::createYes(); + } + + if ((new ThisType($this->declaringClass))->isSuperTypeOf($this->returnType)->yes()) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->declaringClass->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isPure(): TrinaryLogic + { + if ($this->hasSideEffects()->yes()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Annotations/AnnotationPropertyReflection.php b/src/Reflection/Annotations/AnnotationPropertyReflection.php new file mode 100644 index 00000000..effcc4cf --- /dev/null +++ b/src/Reflection/Annotations/AnnotationPropertyReflection.php @@ -0,0 +1,152 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function hasPhpDocType(): bool + { + return true; + } + + public function getPhpDocType(): Type + { + return $this->readableType; + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->readableType; + } + + public function getWritableType(): Type + { + return $this->writableType; + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->readableType->equals($this->writableType); + } + + public function isReadable(): bool + { + return $this->readable; + } + + public function isWritable(): bool + { + return $this->writable; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php new file mode 100644 index 00000000..d450c8e8 --- /dev/null +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -0,0 +1,84 @@ +name; + } + + public function isOptional(): bool + { + return $this->isOptional; + } + + public function getType(): Type + { + return $this->type; + } + + public function getPhpDocType(): Type + { + return $this->type; + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getOutType(): ?Type + { + return null; + } + + public function isImmediatelyInvokedCallable(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClosureThisType(): ?Type + { + return null; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->isVariadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Annotations/AnnotationsMethodsClassReflectionExtension.php b/src/Reflection/Annotations/AnnotationsMethodsClassReflectionExtension.php new file mode 100644 index 00000000..6bb768a2 --- /dev/null +++ b/src/Reflection/Annotations/AnnotationsMethodsClassReflectionExtension.php @@ -0,0 +1,138 @@ +methods[$classReflection->getCacheKey()][$methodName])) { + $method = $this->findClassReflectionWithMethod($classReflection, $classReflection, $methodName); + if ($method === null) { + return false; + } + $this->methods[$classReflection->getCacheKey()][$methodName] = $method; + } + + return isset($this->methods[$classReflection->getCacheKey()][$methodName]); + } + + public function getMethod(ClassReflection $classReflection, string $methodName): ExtendedMethodReflection + { + return $this->methods[$classReflection->getCacheKey()][$methodName]; + } + + private function findClassReflectionWithMethod( + ClassReflection $classReflection, + ClassReflection $declaringClass, + string $methodName, + ): ?ExtendedMethodReflection + { + $methodTags = $classReflection->getMethodTags(); + if (isset($methodTags[$methodName])) { + $parameters = []; + foreach ($methodTags[$methodName]->getParameters() as $parameterName => $parameterTag) { + $parameters[] = new AnnotationsMethodParameterReflection( + $parameterName, + $parameterTag->getType(), + $parameterTag->passedByReference(), + $parameterTag->isOptional(), + $parameterTag->isVariadic(), + $parameterTag->getDefaultValue(), + ); + } + + $templateTypeScope = TemplateTypeScope::createWithClass($classReflection->getName()); + + $templateTypeMap = new TemplateTypeMap(array_map( + static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), + $methodTags[$methodName]->getTemplateTags(), + )); + + $isStatic = $methodTags[$methodName]->isStatic(); + $nativeCallMethodName = $isStatic ? '__callStatic' : '__call'; + + return new AnnotationMethodReflection( + $methodName, + $declaringClass, + TemplateTypeHelper::resolveTemplateTypes( + $methodTags[$methodName]->getReturnType(), + $classReflection->getActiveTemplateTypeMap(), + $classReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createCovariant(), + ), + $parameters, + $isStatic, + $this->detectMethodVariadic($parameters), + $classReflection->hasNativeMethod($nativeCallMethodName) + ? $classReflection->getNativeMethod($nativeCallMethodName)->getThrowType() + : null, + $templateTypeMap, + ); + } + + foreach ($classReflection->getTraits() as $traitClass) { + $methodWithDeclaringClass = $this->findClassReflectionWithMethod($traitClass, $classReflection, $methodName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + $parentClass = $classReflection->getParentClass(); + while ($parentClass !== null) { + $methodWithDeclaringClass = $this->findClassReflectionWithMethod($parentClass, $parentClass, $methodName); + if ($methodWithDeclaringClass !== null) { + return $methodWithDeclaringClass; + } + + $parentClass = $parentClass->getParentClass(); + } + + foreach ($classReflection->getInterfaces() as $interfaceClass) { + $methodWithDeclaringClass = $this->findClassReflectionWithMethod($interfaceClass, $interfaceClass, $methodName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + return null; + } + + /** + * @param AnnotationsMethodParameterReflection[] $parameters + */ + private function detectMethodVariadic(array $parameters): bool + { + if ($parameters === []) { + return false; + } + + $possibleVariadicParameterIndex = count($parameters) - 1; + $possibleVariadicParameter = $parameters[$possibleVariadicParameterIndex]; + + return $possibleVariadicParameter->isVariadic(); + } + +} diff --git a/src/Reflection/Annotations/AnnotationsPropertiesClassReflectionExtension.php b/src/Reflection/Annotations/AnnotationsPropertiesClassReflectionExtension.php new file mode 100644 index 00000000..974e2459 --- /dev/null +++ b/src/Reflection/Annotations/AnnotationsPropertiesClassReflectionExtension.php @@ -0,0 +1,105 @@ +properties[$classReflection->getCacheKey()][$propertyName])) { + $property = $this->findClassReflectionWithProperty($classReflection, $classReflection, $propertyName); + if ($property === null) { + return false; + } + $this->properties[$classReflection->getCacheKey()][$propertyName] = $property; + } + + return isset($this->properties[$classReflection->getCacheKey()][$propertyName]); + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): ExtendedPropertyReflection + { + return $this->properties[$classReflection->getCacheKey()][$propertyName]; + } + + private function findClassReflectionWithProperty( + ClassReflection $classReflection, + ClassReflection $declaringClass, + string $propertyName, + ): ?ExtendedPropertyReflection + { + $propertyTags = $classReflection->getPropertyTags(); + if (isset($propertyTags[$propertyName])) { + $propertyTag = $propertyTags[$propertyName]; + + $isReadable = $propertyTags[$propertyName]->isReadable(); + $isWritable = $propertyTags[$propertyName]->isWritable(); + if ($classReflection->hasNativeProperty($propertyName)) { + $nativeProperty = $classReflection->getNativeProperty($propertyName); + $isReadable = $isReadable || $nativeProperty->isReadable(); + $isWritable = $isWritable || $nativeProperty->isWritable(); + } + + return new AnnotationPropertyReflection( + $declaringClass, + TemplateTypeHelper::resolveTemplateTypes( + $propertyTag->getReadableType() ?? new NeverType(), + $classReflection->getActiveTemplateTypeMap(), + $classReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createCovariant(), + ), + TemplateTypeHelper::resolveTemplateTypes( + $propertyTag->getWritableType() ?? new NeverType(), + $classReflection->getActiveTemplateTypeMap(), + $classReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createContravariant(), + ), + $isReadable, + $isWritable, + ); + } + + foreach ($classReflection->getTraits() as $traitClass) { + $methodWithDeclaringClass = $this->findClassReflectionWithProperty($traitClass, $classReflection, $propertyName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + $parentClass = $classReflection->getParentClass(); + while ($parentClass !== null) { + $methodWithDeclaringClass = $this->findClassReflectionWithProperty($parentClass, $parentClass, $propertyName); + if ($methodWithDeclaringClass !== null) { + return $methodWithDeclaringClass; + } + + $parentClass = $parentClass->getParentClass(); + } + + foreach ($classReflection->getInterfaces() as $interfaceClass) { + $methodWithDeclaringClass = $this->findClassReflectionWithProperty($interfaceClass, $interfaceClass, $propertyName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + return null; + } + +} diff --git a/src/Reflection/Assertions.php b/src/Reflection/Assertions.php new file mode 100644 index 00000000..9a8d6e5c --- /dev/null +++ b/src/Reflection/Assertions.php @@ -0,0 +1,112 @@ +asserts; + } + + /** + * @return AssertTag[] + */ + public function getAsserts(): array + { + return array_filter($this->asserts, static fn (AssertTag $assert) => $assert->getIf() === AssertTag::NULL); + } + + /** + * @return AssertTag[] + */ + public function getAssertsIfTrue(): array + { + return array_merge( + array_filter($this->asserts, static fn (AssertTag $assert) => $assert->getIf() === AssertTag::IF_TRUE), + array_map( + static fn (AssertTag $assert) => $assert->negate(), + array_filter($this->asserts, static fn (AssertTag $assert) => $assert->getIf() === AssertTag::IF_FALSE && !$assert->isEquality()), + ), + ); + } + + /** + * @return AssertTag[] + */ + public function getAssertsIfFalse(): array + { + return array_merge( + array_filter($this->asserts, static fn (AssertTag $assert) => $assert->getIf() === AssertTag::IF_FALSE), + array_map( + static fn (AssertTag $assert) => $assert->negate(), + array_filter($this->asserts, static fn (AssertTag $assert) => $assert->getIf() === AssertTag::IF_TRUE && !$assert->isEquality()), + ), + ); + } + + /** + * @param callable(Type): Type $callable + */ + public function mapTypes(callable $callable): self + { + $assertTagsCallback = static fn (AssertTag $tag): AssertTag => $tag->withType($callable($tag->getType())); + + return new self(array_map($assertTagsCallback, $this->asserts)); + } + + public function intersectWith(Assertions $other): self + { + return new self(array_merge($this->getAll(), $other->getAll())); + } + + public static function createEmpty(): self + { + $empty = self::$empty; + + if ($empty !== null) { + return $empty; + } + + $empty = new self([]); + self::$empty = $empty; + + return $empty; + } + + public static function createFromResolvedPhpDocBlock(ResolvedPhpDocBlock $phpDocBlock): self + { + $tags = $phpDocBlock->getAssertTags(); + if (count($tags) === 0) { + return self::createEmpty(); + } + + return new self($tags); + } + +} diff --git a/src/Reflection/AttributeReflection.php b/src/Reflection/AttributeReflection.php new file mode 100644 index 00000000..0e6609b0 --- /dev/null +++ b/src/Reflection/AttributeReflection.php @@ -0,0 +1,34 @@ + $argumentTypes + */ + public function __construct(private string $name, private array $argumentTypes) + { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return array + */ + public function getArgumentTypes(): array + { + return $this->argumentTypes; + } + +} diff --git a/src/Reflection/AttributeReflectionFactory.php b/src/Reflection/AttributeReflectionFactory.php new file mode 100644 index 00000000..31d1e441 --- /dev/null +++ b/src/Reflection/AttributeReflectionFactory.php @@ -0,0 +1,135 @@ + $reflections + * @return list + */ + public function fromNativeReflection(array $reflections, InitializerExprContext $context): array + { + $attributes = []; + foreach ($reflections as $reflection) { + $attribute = $this->fromNameAndArgumentExpressions($reflection->getName(), $reflection->getArgumentsExpressions(), $context); + if ($attribute === null) { + continue; + } + + $attributes[] = $attribute; + } + + return $attributes; + } + + /** + * @param AttributeGroup[] $attrGroups + * @return list + */ + public function fromAttrGroups(array $attrGroups, InitializerExprContext $context): array + { + $attributes = []; + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $arguments = []; + foreach ($attr->args as $i => $arg) { + if ($arg->name === null) { + $argName = $i; + } else { + $argName = $arg->name->toString(); + } + + $arguments[$argName] = $arg->value; + } + $attributeReflection = $this->fromNameAndArgumentExpressions($attr->name->toString(), $arguments, $context); + if ($attributeReflection === null) { + continue; + } + + $attributes[] = $attributeReflection; + } + } + + return $attributes; + } + + /** + * @param array $arguments + */ + private function fromNameAndArgumentExpressions(string $name, array $arguments, InitializerExprContext $context): ?AttributeReflection + { + if (count($arguments) === 0) { + return new AttributeReflection($name, []); + } + + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + if (!$reflectionProvider->hasClass($name)) { + return null; + } + + $classReflection = $reflectionProvider->getClass($name); + if (!$classReflection->hasConstructor()) { + return null; + } + + if (!$classReflection->isAttributeClass()) { + return null; + } + + $constructor = $classReflection->getConstructor(); + $parameters = $constructor->getOnlyVariant()->getParameters(); + $namedArgTypes = []; + foreach ($arguments as $i => $argExpr) { + if (is_int($i)) { + if (isset($parameters[$i])) { + $namedArgTypes[$parameters[$i]->getName()] = $this->initializerExprTypeResolver->getType($argExpr, $context); + continue; + } + if (count($parameters) > 0) { + $lastParameter = $parameters[count($parameters) - 1]; + if ($lastParameter->isVariadic()) { + $parameterName = $lastParameter->getName(); + if (array_key_exists($parameterName, $namedArgTypes)) { + $namedArgTypes[$parameterName] = TypeCombinator::union($namedArgTypes[$parameterName], $this->initializerExprTypeResolver->getType($argExpr, $context)); + continue; + } + $namedArgTypes[$parameterName] = $this->initializerExprTypeResolver->getType($argExpr, $context); + } + } + continue; + } + + foreach ($parameters as $parameter) { + if ($parameter->getName() !== $i) { + continue; + } + + $namedArgTypes[$i] = $this->initializerExprTypeResolver->getType($argExpr, $context); + break; + } + } + + return new AttributeReflection($classReflection->getName(), $namedArgTypes); + } + +} diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php new file mode 100644 index 00000000..14d16514 --- /dev/null +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -0,0 +1,484 @@ + */ + private array $cachedConstants = []; + + /** + * @param list $universalObjectCratesClasses + */ + public function __construct( + private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ClassReflectionExtensionRegistryProvider $classReflectionExtensionRegistryProvider, + private Reflector $reflector, + private FileTypeMapper $fileTypeMapper, + private PhpDocInheritanceResolver $phpDocInheritanceResolver, + private PhpVersion $phpVersion, + private NativeFunctionReflectionProvider $nativeFunctionReflectionProvider, + private StubPhpDocProvider $stubPhpDocProvider, + private FunctionReflectionFactory $functionReflectionFactory, + private RelativePathHelper $relativePathHelper, + private AnonymousClassNameHelper $anonymousClassNameHelper, + private FileHelper $fileHelper, + private PhpStormStubsSourceStubber $phpstormStubsSourceStubber, + private SignatureMapProvider $signatureMapProvider, + private AttributeReflectionFactory $attributeReflectionFactory, + private array $universalObjectCratesClasses, + ) + { + } + + public function hasClass(string $className): bool + { + if (isset(self::$anonymousClasses[$className])) { + return true; + } + + if (!ClassNameHelper::isValidClassName($className)) { + return false; + } + + try { + $this->reflector->reflectClass($className); + return true; + } catch (IdentifierNotFound) { + return false; + } catch (InvalidIdentifierName) { + return false; + } + } + + public function getClass(string $className): ClassReflection + { + if (isset(self::$anonymousClasses[$className])) { + return self::$anonymousClasses[$className]; + } + + try { + $reflectionClass = $this->reflector->reflectClass($className); + } catch (IdentifierNotFound | InvalidIdentifierName) { + throw new ClassNotFoundException($className); + } + + $reflectionClassName = strtolower($reflectionClass->getName()); + + if (array_key_exists($reflectionClassName, $this->classReflections)) { + return $this->classReflections[$reflectionClassName]; + } + + $enumAdapter = base64_decode('UEhQU3RhblxCZXR0ZXJSZWZsZWN0aW9uXFJlZmxlY3Rpb25cQWRhcHRlclxSZWZsZWN0aW9uRW51bQ==', true); + + $classReflection = new ClassReflection( + $this->reflectionProviderProvider->getReflectionProvider(), + $this->initializerExprTypeResolver, + $this->fileTypeMapper, + $this->stubPhpDocProvider, + $this->phpDocInheritanceResolver, + $this->phpVersion, + $this->signatureMapProvider, + $this->attributeReflectionFactory, + $this->classReflectionExtensionRegistryProvider->getRegistry()->getPropertiesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getMethodsClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getAllowedSubTypesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsPropertyClassReflectionExtension(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsMethodsClassReflectionExtension(), + $reflectionClass->getName(), + $reflectionClass instanceof ReflectionEnum && PHP_VERSION_ID >= 80000 ? new $enumAdapter($reflectionClass) : new ReflectionClass($reflectionClass), + null, + null, + $this->stubPhpDocProvider->findClassPhpDoc($reflectionClass->getName()), + $this->universalObjectCratesClasses, + ); + + $this->classReflections[$reflectionClassName] = $classReflection; + + return $classReflection; + } + + public function getClassName(string $className): string + { + if (!$this->hasClass($className)) { + throw new ClassNotFoundException($className); + } + + if (isset(self::$anonymousClasses[$className])) { + return self::$anonymousClasses[$className]->getDisplayName(); + } + + $reflectionClass = $this->reflector->reflectClass($className); + + return $reflectionClass->getName(); + } + + public function getAnonymousClassReflection(Node\Stmt\Class_ $classNode, Scope $scope): ClassReflection + { + if (isset($classNode->namespacedName)) { + throw new ShouldNotHappenException(); + } + + if (!$scope->isInTrait()) { + $scopeFile = $scope->getFile(); + } else { + $scopeFile = $scope->getTraitReflection()->getFileName(); + if ($scopeFile === null) { + $scopeFile = $scope->getFile(); + } + } + + $filename = $this->fileHelper->normalizePath($this->relativePathHelper->getRelativePath($scopeFile), '/'); + $className = $this->anonymousClassNameHelper->getAnonymousClassName( + $classNode, + $scopeFile, + ); + $classNode->name = new Node\Identifier($className); + $classNode->namespacedName = null; + + if (isset(self::$anonymousClasses[$className])) { + return self::$anonymousClasses[$className]; + } + + $reflectionClass = \PHPStan\BetterReflection\Reflection\ReflectionClass::createFromNode( + $this->reflector, + $classNode, + new LocatedSource(FileReader::read($scopeFile), $className, $scopeFile), + null, + ); + + $displayParentName = $reflectionClass->getParentClassName(); + if ($displayParentName === null) { + // https://3v4l.org/6FBuP + $classInterfaceNames = $reflectionClass->getInterfaceNames(); + if ($classInterfaceNames !== []) { + $displayParentName = $classInterfaceNames[array_key_first($classInterfaceNames)]; + } else { + $displayParentName = 'class'; + } + } + + /** @var int|null $classLineIndex */ + $classLineIndex = $classNode->getAttribute(AnonymousClassVisitor::ATTRIBUTE_LINE_INDEX); + if ($classLineIndex === null) { + $displayName = sprintf('%s@anonymous/%s:%s', $displayParentName, $filename, $classNode->getStartLine()); + } else { + $displayName = sprintf('%s@anonymous/%s:%s:%d', $displayParentName, $filename, $classNode->getStartLine(), $classLineIndex); + } + + self::$anonymousClasses[$className] = new ClassReflection( + $this->reflectionProviderProvider->getReflectionProvider(), + $this->initializerExprTypeResolver, + $this->fileTypeMapper, + $this->stubPhpDocProvider, + $this->phpDocInheritanceResolver, + $this->phpVersion, + $this->signatureMapProvider, + $this->attributeReflectionFactory, + $this->classReflectionExtensionRegistryProvider->getRegistry()->getPropertiesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getMethodsClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getAllowedSubTypesClassReflectionExtensions(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsPropertyClassReflectionExtension(), + $this->classReflectionExtensionRegistryProvider->getRegistry()->getRequireExtendsMethodsClassReflectionExtension(), + $displayName, + new ReflectionClass($reflectionClass), + $scopeFile, + null, + $this->stubPhpDocProvider->findClassPhpDoc($className), + $this->universalObjectCratesClasses, + ); + $this->classReflections[$className] = self::$anonymousClasses[$className]; + + return self::$anonymousClasses[$className]; + } + + public function getUniversalObjectCratesClasses(): array + { + return $this->universalObjectCratesClasses; + } + + public function hasFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool + { + return $this->resolveFunctionName($nameNode, $namespaceAnswerer) !== null; + } + + public function getFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): FunctionReflection + { + $functionName = $this->resolveFunctionName($nameNode, $namespaceAnswerer); + if ($functionName === null) { + throw new FunctionNotFoundException((string) $nameNode); + } + + $lowerCasedFunctionName = strtolower($functionName); + if (isset($this->functionReflections[$lowerCasedFunctionName])) { + return $this->functionReflections[$lowerCasedFunctionName]; + } + + if (in_array($lowerCasedFunctionName, ['exit', 'die'], true)) { + return $this->functionReflections[$lowerCasedFunctionName] = new ExitFunctionReflection($lowerCasedFunctionName); + } + + $nativeFunctionReflection = $this->nativeFunctionReflectionProvider->findFunctionReflection($lowerCasedFunctionName); + if ($nativeFunctionReflection !== null) { + $this->functionReflections[$lowerCasedFunctionName] = $nativeFunctionReflection; + return $nativeFunctionReflection; + } + + $this->functionReflections[$lowerCasedFunctionName] = $this->getCustomFunction($functionName); + + return $this->functionReflections[$lowerCasedFunctionName]; + } + + private function getCustomFunction(string $functionName): PhpFunctionReflection + { + $reflectionFunction = new ReflectionFunction($this->reflector->reflectFunction($functionName)); + $templateTypeMap = TemplateTypeMap::createEmpty(); + $phpDocParameterTypes = []; + $phpDocReturnTag = null; + $phpDocThrowsTag = null; + $deprecatedTag = null; + $isDeprecated = false; + $isInternal = false; + $isPure = null; + $asserts = Assertions::createEmpty(); + $acceptsNamedArguments = true; + $phpDocComment = null; + $phpDocParameterOutTags = []; + $phpDocParameterImmediatelyInvokedCallable = []; + $phpDocParameterClosureThisTypeTags = []; + + $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName(), array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflectionFunction->getParameters())); + if ($resolvedPhpDoc === null && $reflectionFunction->getFileName() !== false && $reflectionFunction->getDocComment() !== false) { + $docComment = $reflectionFunction->getDocComment(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc($reflectionFunction->getFileName(), null, null, $reflectionFunction->getName(), $docComment); + } + + if ($resolvedPhpDoc !== null) { + $templateTypeMap = $resolvedPhpDoc->getTemplateTypeMap(); + $phpDocParameterTypes = array_map(static fn ($tag) => $tag->getType(), $resolvedPhpDoc->getParamTags()); + $phpDocReturnTag = $resolvedPhpDoc->getReturnTag(); + $phpDocThrowsTag = $resolvedPhpDoc->getThrowsTag(); + $deprecatedTag = $resolvedPhpDoc->getDeprecatedTag(); + $isDeprecated = $resolvedPhpDoc->isDeprecated(); + $isInternal = $resolvedPhpDoc->isInternal(); + $isPure = $resolvedPhpDoc->isPure(); + $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); + if ($resolvedPhpDoc->hasPhpDocString()) { + $phpDocComment = $resolvedPhpDoc->getPhpDocString(); + } + $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); + $phpDocParameterOutTags = $resolvedPhpDoc->getParamOutTags(); + $phpDocParameterImmediatelyInvokedCallable = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); + $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); + } + + return $this->functionReflectionFactory->create( + $reflectionFunction, + $templateTypeMap, + $phpDocParameterTypes, + $phpDocReturnTag !== null ? $phpDocReturnTag->getType() : null, + $phpDocThrowsTag !== null ? $phpDocThrowsTag->getType() : null, + $deprecatedTag !== null ? $deprecatedTag->getMessage() : null, + $isDeprecated, + $isInternal, + $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null, + $isPure, + $asserts, + $acceptsNamedArguments, + $phpDocComment, + array_map(static fn (ParamOutTag $paramOutTag): Type => $paramOutTag->getType(), $phpDocParameterOutTags), + $phpDocParameterImmediatelyInvokedCallable, + array_map(static fn (ParamClosureThisTag $tag): Type => $tag->getType(), $phpDocParameterClosureThisTypeTags), + $this->attributeReflectionFactory->fromNativeReflection($reflectionFunction->getAttributes(), InitializerExprContext::fromFunction($reflectionFunction->getName(), $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null)), + ); + } + + public function resolveFunctionName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string + { + $name = $nameNode->toLowerString(); + if (in_array($name, ['exit', 'die'], true)) { + return $name; + } + + return $this->resolveName($nameNode, function (string $name): bool { + try { + $this->reflector->reflectFunction($name); + return true; + } catch (IdentifierNotFound) { + // pass + } catch (InvalidIdentifierName) { + // pass + } + + if ($this->nativeFunctionReflectionProvider->findFunctionReflection($name) !== null) { + return $this->phpstormStubsSourceStubber->isPresentFunction($name) !== false; + } + return false; + }, $namespaceAnswerer); + } + + public function hasConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool + { + return $this->resolveConstantName($nameNode, $namespaceAnswerer) !== null; + } + + public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ConstantReflection + { + $constantName = $this->resolveConstantName($nameNode, $namespaceAnswerer); + if ($constantName === null) { + throw new ConstantNotFoundException((string) $nameNode); + } + + if (array_key_exists($constantName, $this->cachedConstants)) { + return $this->cachedConstants[$constantName]; + } + + $constantReflection = $this->reflector->reflectConstant($constantName); + $fileName = $constantReflection->getFileName(); + $constantValueType = $this->initializerExprTypeResolver->getType($constantReflection->getValueExpression(), InitializerExprContext::fromGlobalConstant($constantReflection)); + $docComment = $constantReflection->getDocComment(); + + $isDeprecated = TrinaryLogic::createNo(); + $deprecatedDescription = null; + if ($docComment !== null) { + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc($fileName, null, null, null, $docComment); + $isDeprecated = TrinaryLogic::createFromBoolean($resolvedPhpDoc->isDeprecated()); + + if ($resolvedPhpDoc->isDeprecated() && $resolvedPhpDoc->getDeprecatedTag() !== null) { + $deprecatedMessage = $resolvedPhpDoc->getDeprecatedTag()->getMessage(); + + $matches = Strings::match($deprecatedMessage ?? '', '#^(\d+)\.(\d+)(?:\.(\d+))?$#'); + if ($matches !== null) { + $major = $matches[1]; + $minor = $matches[2]; + $patch = $matches[3] ?? 0; + $versionId = sprintf('%d%02d%02d', $major, $minor, $patch); + + $isDeprecated = TrinaryLogic::createFromBoolean($this->phpVersion->getVersionId() >= $versionId); + } else { + // filter raw version number messages like in + // https://github.com/JetBrains/phpstorm-stubs/blob/9608c953230b08f07b703ecfe459cc58d5421437/filter/filter.php#L478 + $deprecatedDescription = $deprecatedMessage; + } + } + } + + return $this->cachedConstants[$constantName] = new RuntimeConstantReflection( + $constantName, + $constantValueType, + $fileName, + $isDeprecated, + $deprecatedDescription, + ); + } + + public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string + { + return $this->resolveName($nameNode, function (string $name): bool { + try { + $this->reflector->reflectConstant($name); + return true; + } catch (IdentifierNotFound) { + // pass + } catch (UnableToCompileNode) { + // pass + } + return false; + }, $namespaceAnswerer); + } + + /** + * @param Closure(string $name): bool $existsCallback + */ + private function resolveName( + Node\Name $nameNode, + Closure $existsCallback, + ?NamespaceAnswerer $namespaceAnswerer, + ): ?string + { + $name = (string) $nameNode; + if ($namespaceAnswerer !== null && $namespaceAnswerer->getNamespace() !== null && !$nameNode->isFullyQualified()) { + $namespacedName = sprintf('%s\\%s', $namespaceAnswerer->getNamespace(), $name); + if ($existsCallback($namespacedName)) { + return $namespacedName; + } + } + + if ($existsCallback($name)) { + return $name; + } + + return null; + } + +} diff --git a/src/Reflection/BetterReflection/BetterReflectionProviderFactory.php b/src/Reflection/BetterReflection/BetterReflectionProviderFactory.php new file mode 100644 index 00000000..1ff614e8 --- /dev/null +++ b/src/Reflection/BetterReflection/BetterReflectionProviderFactory.php @@ -0,0 +1,15 @@ +parser); + $locators[] = new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($this->fileNodesFetcher, false), + new ReflectionClassSourceLocator( + $astLocator, + $this->reflectionSourceStubber, + ), + ); + + $analysedDirectories = []; + $analysedFiles = []; + + foreach (array_merge($this->analysedPaths, $this->analysedPathsFromConfig) as $analysedPath) { + if (is_file($analysedPath)) { + $analysedFiles[] = $analysedPath; + continue; + } + + if (!is_dir($analysedPath)) { + continue; + } + + $analysedDirectories[] = $analysedPath; + } + + $fileLocators = []; + $analysedFiles = array_unique(array_merge($analysedFiles, $this->scanFiles)); + foreach ($analysedFiles as $analysedFile) { + $fileLocators[] = $this->optimizedSingleFileSourceLocatorRepository->getOrCreate($analysedFile); + } + + $directories = array_unique(array_merge($analysedDirectories, $this->scanDirectories)); + foreach ($directories as $directory) { + $fileLocators[] = $this->optimizedDirectorySourceLocatorRepository->getOrCreate($directory); + } + + $astPhp8Locator = new Locator($this->php8Parser); + + foreach ($this->composerAutoloaderProjectPaths as $composerAutoloaderProjectPath) { + $locator = $this->composerJsonAndInstalledJsonSourceLocatorMaker->create($composerAutoloaderProjectPath); + if ($locator === null) { + continue; + } + $fileLocators[] = $locator; + } + + if (extension_loaded('phar')) { + $pharProtocolPath = Phar::running(); + if ($pharProtocolPath !== '') { + $mappings = [ + 'PHPStan\\BetterReflection\\' => [$pharProtocolPath . '/vendor/ondrejmirtes/better-reflection/src/'], + ]; + if ($this->playgroundMode) { + $mappings['PHPStan\\'] = [$pharProtocolPath . '/src/']; + } else { + $mappings['PHPStan\\Testing\\'] = [$pharProtocolPath . '/src/Testing/']; + } + $fileLocators[] = $this->optimizedPsrAutoloaderLocatorFactory->create( + Psr4Mapping::fromArrayMappings($mappings), + ); + } + } + + $locators[] = new RewriteClassAliasSourceLocator(new AggregateSourceLocator($fileLocators)); + $locators[] = new SkipClassAliasSourceLocator(new PhpInternalSourceLocator($astPhp8Locator, $this->phpstormStubsSourceStubber)); + + $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); + $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + + return new MemoizingSourceLocator(new AggregateSourceLocator($locators)); + } + +} diff --git a/src/Reflection/BetterReflection/Reflector/MemoizingReflector.php b/src/Reflection/BetterReflection/Reflector/MemoizingReflector.php new file mode 100644 index 00000000..2e34bb2f --- /dev/null +++ b/src/Reflection/BetterReflection/Reflector/MemoizingReflector.php @@ -0,0 +1,112 @@ + */ + private array $classReflections = []; + + /** @var array */ + private array $constantReflections = []; + + /** @var array */ + private array $functionReflections = []; + + public function __construct(private Reflector $reflector) + { + } + + public function reflectClass(string $className): ReflectionClass + { + $lowerClassName = strtolower($className); + if (array_key_exists($lowerClassName, $this->classReflections) && $this->classReflections[$lowerClassName] !== null) { + return $this->classReflections[$lowerClassName]; + } + if (array_key_exists($className, $this->classReflections)) { + $classReflection = $this->classReflections[$className]; + if ($classReflection === null) { + throw IdentifierNotFound::fromIdentifier(new Identifier($className, new IdentifierType(IdentifierType::IDENTIFIER_CLASS))); + } + + return $classReflection; + } + + try { + return $this->classReflections[$lowerClassName] = $this->reflector->reflectClass($className); + } catch (IdentifierNotFound $e) { + $this->classReflections[$className] = null; + + throw $e; + } + } + + public function reflectConstant(string $constantName): ReflectionConstant + { + if (array_key_exists($constantName, $this->constantReflections)) { + $constantReflection = $this->constantReflections[$constantName]; + if ($constantReflection === null) { + throw IdentifierNotFound::fromIdentifier(new Identifier($constantName, new IdentifierType(IdentifierType::IDENTIFIER_CONSTANT))); + } + + return $constantReflection; + } + + try { + return $this->constantReflections[$constantName] = $this->reflector->reflectConstant($constantName); + } catch (IdentifierNotFound $e) { + $this->constantReflections[$constantName] = null; + + throw $e; + } + } + + public function reflectFunction(string $functionName): ReflectionFunction + { + $lowerFunctionName = strtolower($functionName); + if (array_key_exists($lowerFunctionName, $this->functionReflections)) { + $functionReflection = $this->functionReflections[$lowerFunctionName]; + if ($functionReflection === null) { + throw IdentifierNotFound::fromIdentifier(new Identifier($functionName, new IdentifierType(IdentifierType::IDENTIFIER_FUNCTION))); + } + + return $functionReflection; + } + + try { + return $this->functionReflections[$lowerFunctionName] = $this->reflector->reflectFunction($functionName); + } catch (IdentifierNotFound $e) { + $this->functionReflections[$lowerFunctionName] = null; + + throw $e; + } + } + + public function reflectAllClasses(): iterable + { + return $this->reflector->reflectAllClasses(); + } + + public function reflectAllFunctions(): iterable + { + return $this->reflector->reflectAllFunctions(); + } + + public function reflectAllConstants(): iterable + { + return $this->reflector->reflectAllConstants(); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php new file mode 100644 index 00000000..6b1fcb31 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -0,0 +1,59 @@ +isClass()) { + return null; + } + + $className = $identifier->getName(); + if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) { + return null; + } + + $autoloadFunctions = autoloadFunctions(); + foreach ($autoloadFunctions as $autoloadFunction) { + $autoloadFunction($className); + $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); + if ($reflection !== null) { + return $reflection; + } + + $reflection = $this->reflectionClassSourceLocator->locateIdentifier($reflector, $identifier); + if ($reflection !== null) { + return $reflection; + } + } + + return null; + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return []; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php new file mode 100644 index 00000000..eb401317 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -0,0 +1,383 @@ +, functions: array, constants: array} */ + private array $presentSymbols = [ + 'classes' => [], + 'functions' => [], + 'constants' => [], + ]; + + /** @var array */ + private array $scannedFiles = []; + + /** @var array */ + private array $startLineByClass = []; + + public function __construct(private FileNodesFetcher $fileNodesFetcher, private bool $executeAutoloadersInFileReadTrap) + { + } + + public function locateIdentifier(Reflector $reflector, Identifier $identifier): ?Reflection + { + if ($identifier->isFunction()) { + $functionName = $identifier->getName(); + $loweredFunctionName = strtolower($functionName); + if (array_key_exists($loweredFunctionName, $this->presentSymbols['functions'])) { + return $this->findReflection($reflector, $this->presentSymbols['functions'][$loweredFunctionName], $identifier, null); + } + if (!function_exists($functionName)) { + return null; + } + + $reflection = new ReflectionFunction($functionName); + $reflectionFileName = $reflection->getFileName(); + + if (!is_string($reflectionFileName)) { + return null; + } + if (!is_file($reflectionFileName)) { + return null; + } + + return $this->findReflection($reflector, $reflectionFileName, $identifier, null); + } + + if ($identifier->isConstant()) { + $constantName = ConstantNameHelper::normalize($identifier->getName()); + if (array_key_exists($constantName, $this->presentSymbols['constants'])) { + return $this->findReflection($reflector, $this->presentSymbols['constants'][$constantName], $identifier, null); + } + + if (!defined($constantName)) { + return null; + } + + $constantValue = @constant($constantName); + return ReflectionConstant::createFromNode( + $reflector, + new FuncCall(new Name('define'), [ + new Arg(new String_($constantName)), + new Arg(new TypeExpr(ConstantTypeHelper::getTypeFromValue($constantValue))), + ], [ + 'startLine' => 1, + 'endLine' => 1, + 'startFilePos' => 1, + 'endFilePos' => 4, + ]), + new LocatedSource('isClass()) { + return null; + } + + $loweredClassName = strtolower($identifier->getName()); + if (array_key_exists($loweredClassName, $this->presentSymbols['classes'])) { + $startLine = null; + if (array_key_exists($loweredClassName, $this->startLineByClass)) { + $startLine = $this->startLineByClass[$loweredClassName]; + } else { + $reflection = $this->getReflectionClass($identifier->getName()); + if ( + $reflection !== null + && $reflection->getStartLine() !== false + && is_string($reflection->getFileName()) + && is_file($reflection->getFileName()) + && $reflection->getFileName() === $this->presentSymbols['classes'][$loweredClassName] + ) { + $startLine = $reflection->getStartLine(); + } + } + return $this->findReflection($reflector, $this->presentSymbols['classes'][$loweredClassName], $identifier, $startLine); + } + $locateResult = $this->locateClassByName($identifier->getName()); + if ($locateResult === null) { + return null; + } + [$potentiallyLocatedFiles, $className, $startLine] = $locateResult; + if ($startLine !== null) { + $this->startLineByClass[strtolower($className)] = $startLine; + } + + $newIdentifier = new Identifier($className, $identifier->getType()); + + foreach ($potentiallyLocatedFiles as $potentiallyLocatedFile) { + $reflection = $this->findReflection($reflector, $potentiallyLocatedFile, $newIdentifier, $startLine); + if ($reflection === null) { + continue; + } + + return $reflection; + } + + return null; + } + + private function findReflection(Reflector $reflector, string $file, Identifier $identifier, ?int $startLine): ?Reflection + { + $result = $this->fileNodesFetcher->fetchNodes($file); + if (!array_key_exists($file, $this->scannedFiles)) { + foreach (array_keys($result->getClassNodes()) as $className) { + if (array_key_exists($className, $this->presentSymbols['classes'])) { + continue; + } + $this->presentSymbols['classes'][$className] = $file; + } + foreach (array_keys($result->getFunctionNodes()) as $functionName) { + if (array_key_exists($functionName, $this->presentSymbols['functions'])) { + continue; + } + $this->presentSymbols['functions'][$functionName] = $file; + } + foreach (array_keys($result->getConstantNodes()) as $constantName) { + if (array_key_exists($constantName, $this->presentSymbols['constants'])) { + continue; + } + $this->presentSymbols['constants'][$constantName] = $file; + } + $this->scannedFiles[$file] = true; + } + + $nodeToReflection = new NodeToReflection(); + if ($identifier->isClass()) { + $identifierName = strtolower($identifier->getName()); + if (!array_key_exists($identifierName, $result->getClassNodes())) { + return null; + } + + $classNodesCount = count($result->getClassNodes()[$identifierName]); + foreach ($result->getClassNodes()[$identifierName] as $classNode) { + if ($classNodesCount > 1 && $startLine !== null) { + if (count($classNode->getNode()->attrGroups) > 0 && PHP_VERSION_ID < 80000) { + $startLine--; + } + if ($startLine !== $classNode->getNode()->getStartLine()) { + continue; + } + } + + return $nodeToReflection->__invoke( + $reflector, + $classNode->getNode(), + $classNode->getLocatedSource(), + $classNode->getNamespace(), + ); + } + + return null; + } + if ($identifier->isFunction()) { + $identifierName = strtolower($identifier->getName()); + if (!array_key_exists($identifierName, $result->getFunctionNodes())) { + return null; + } + + foreach ($result->getFunctionNodes()[$identifierName] as $functionNode) { + return $nodeToReflection->__invoke( + $reflector, + $functionNode->getNode(), + $functionNode->getLocatedSource(), + $functionNode->getNamespace(), + ); + } + } + + if ($identifier->isConstant()) { + $identifierName = ConstantNameHelper::normalize($identifier->getName()); + $constantNodes = $result->getConstantNodes(); + + if (!array_key_exists($identifierName, $constantNodes)) { + return null; + } + + foreach ($constantNodes[$identifierName] as $fetchedConstantNode) { + $constantNode = $fetchedConstantNode->getNode(); + + $positionInNode = null; + if ($constantNode instanceof Const_) { + foreach ($constantNode->consts as $constPosition => $const) { + if ($const->namespacedName === null) { + throw new ShouldNotHappenException(); + } + + if (ConstantNameHelper::normalize($const->namespacedName->toString()) === $identifierName) { + /** @var int $positionInNode */ + $positionInNode = $constPosition; + break; + } + } + + if ($positionInNode === null) { + throw new ShouldNotHappenException(); + } + } + + return $nodeToReflection->__invoke( + $reflector, + $constantNode, + $fetchedConstantNode->getLocatedSource(), + $fetchedConstantNode->getNamespace(), + $positionInNode, + ); + } + } + + return null; + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return []; + } + + /** + * @return ReflectionClass|null + */ + private function getReflectionClass(string $className): ?ReflectionClass + { + if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) { + return new ReflectionClass($className); + } + + return null; + } + + /** + * Attempt to locate a class by name. + * + * If class already exists, simply use internal reflection API to get the + * filename and store it. + * + * If class does not exist, we make an assumption that whatever autoloaders + * that are registered will be loading a file. We then override the file:// + * protocol stream wrapper to "capture" the filename we expect the class to + * be in, and then restore it. Note that class_exists will cause an error + * that it cannot find the file, so we squelch the errors by overriding the + * error handler temporarily. + * + * @return array{string[], string, int|null}|null + */ + private function locateClassByName(string $className): ?array + { + $reflection = $this->getReflectionClass($className); + if ($reflection !== null) { + $filename = $reflection->getFileName(); + if (!is_string($filename)) { + return null; + } + if (!is_file($filename)) { + return null; + } + + return [[$filename], $reflection->getName(), $reflection->getStartLine() !== false ? $reflection->getStartLine() : null]; + } + + if (!$this->executeAutoloadersInFileReadTrap) { + return null; + } + + $this->silenceErrors(); + + try { + $result = FileReadTrapStreamWrapper::withStreamWrapperOverride( + static function () use ($className): ?array { + $functions = spl_autoload_functions(); + if ($functions === false) { + return null; + } + + foreach ($functions as $preExistingAutoloader) { + $preExistingAutoloader($className); + + /** + * This static variable is populated by the side-effect of the stream wrapper + * trying to read the file path when `include()` is used by an autoloader. + * + * This will not be `null` when the autoloader tried to read a file. + */ + if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) { + return [FileReadTrapStreamWrapper::$autoloadLocatedFiles, $className, null]; + } + } + + return null; + }, + ); + if ($result === null) { + return null; + } + + if (!function_exists('opcache_invalidate')) { + return $result; + } + + foreach ($result[0] as $file) { + opcache_invalidate($file, true); + } + + return $result; + } finally { + restore_error_handler(); + } + } + + private function silenceErrors(): void + { + set_error_handler(static fn (): bool => true); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/CachingVisitor.php b/src/Reflection/BetterReflection/SourceLocator/CachingVisitor.php new file mode 100644 index 00000000..b5f00635 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/CachingVisitor.php @@ -0,0 +1,157 @@ +>> */ + private array $classNodes; + + /** @var array>> */ + private array $functionNodes; + + /** @var array>> */ + private array $constantNodes; + + private ?Node\Stmt\Namespace_ $currentNamespaceNode = null; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Namespace_) { + $this->currentNamespaceNode = $node; + + return null; + } + + if ($node instanceof Node\Stmt\ClassLike) { + if ($node->name !== null) { + $fullClassName = $node->name->toString(); + if ($this->currentNamespaceNode !== null && $this->currentNamespaceNode->name !== null) { + $fullClassName = $this->currentNamespaceNode->name . '\\' . $fullClassName; + } + $this->classNodes[strtolower($fullClassName)][] = new FetchedNode( + $node, + $this->currentNamespaceNode, + new LocatedSource($this->contents, $fullClassName, $this->fileName), + ); + } + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Node\Stmt\Function_) { + if ($node->namespacedName !== null) { + $functionName = $node->namespacedName->toString(); + $this->functionNodes[strtolower($functionName)][] = new FetchedNode( + $node, + $this->currentNamespaceNode, + new LocatedSource($this->contents, $functionName, $this->fileName), + ); + } + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Node\Stmt\Const_) { + foreach ($node->consts as $const) { + if ($const->namespacedName === null) { + continue; + } + + $this->constantNodes[ConstantNameHelper::normalize($const->namespacedName->toString())][] = new FetchedNode( + $node, + $this->currentNamespaceNode, + new LocatedSource($this->contents, null, $this->fileName), + ); + } + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Node\Expr\FuncCall) { + try { + ConstantNodeChecker::assertValidDefineFunctionCall($node); + } catch (InvalidConstantNode) { + return null; + } + + /** @var Node\Scalar\String_ $nameNode */ + $nameNode = $node->getArgs()[0]->value; + $constantName = $nameNode->value; + + $constantNode = new FetchedNode( + $node, + $this->currentNamespaceNode, + new LocatedSource($this->contents, $constantName, $this->fileName), + ); + $this->constantNodes[ConstantNameHelper::normalize($constantName)][] = $constantNode; + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + return null; + } + + /** + * @return null + */ + public function leaveNode(Node $node) + { + if (!$node instanceof Namespace_) { + return null; + } + + $this->currentNamespaceNode = null; + return null; + } + + /** + * @return array>> + */ + public function getClassNodes(): array + { + return $this->classNodes; + } + + /** + * @return array>> + */ + public function getFunctionNodes(): array + { + return $this->functionNodes; + } + + /** + * @return array>> + */ + public function getConstantNodes(): array + { + return $this->constantNodes; + } + + public function reset(string $fileName, string $contents): void + { + $this->classNodes = []; + $this->functionNodes = []; + $this->constantNodes = []; + $this->fileName = $fileName; + $this->contents = $contents; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/ComposerJsonAndInstalledJsonSourceLocatorMaker.php b/src/Reflection/BetterReflection/SourceLocator/ComposerJsonAndInstalledJsonSourceLocatorMaker.php new file mode 100644 index 00000000..12f73657 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/ComposerJsonAndInstalledJsonSourceLocatorMaker.php @@ -0,0 +1,259 @@ +prefixPaths($this->packageToClassMapPaths($composer), $projectInstallationPath . '/'), + $dev ? $this->prefixPaths($this->packageToClassMapPaths($composer, 'autoload-dev'), $projectInstallationPath . '/') : [], + ...array_map(fn (array $package): array => $this->prefixPaths( + $this->packageToClassMapPaths($package), + $this->packagePrefixPath($installedJsonDirectoryPath, $package, $vendorDirectory), + ), $installed), + ); + $filePaths = array_merge( + $this->prefixPaths($this->packageToFilePaths($composer), $projectInstallationPath . '/'), + $dev ? $this->prefixPaths($this->packageToFilePaths($composer, 'autoload-dev'), $projectInstallationPath . '/') : [], + ...array_map(fn (array $package): array => $this->prefixPaths( + $this->packageToFilePaths($package), + $this->packagePrefixPath($installedJsonDirectoryPath, $package, $vendorDirectory), + ), $installed), + ); + + $locators = []; + $locators[] = $this->optimizedPsrAutoloaderLocatorFactory->create( + Psr4Mapping::fromArrayMappings(array_merge_recursive( + $this->prefixWithInstallationPath($this->packageToPsr4AutoloadNamespaces($composer), $projectInstallationPath), + $dev ? $this->prefixWithInstallationPath($this->packageToPsr4AutoloadNamespaces($composer, 'autoload-dev'), $projectInstallationPath) : [], + ...array_map(fn (array $package): array => $this->prefixWithPackagePath( + $this->packageToPsr4AutoloadNamespaces($package), + $installedJsonDirectoryPath, + $package, + $vendorDirectory, + ), $installed), + )), + ); + + $locators[] = $this->optimizedPsrAutoloaderLocatorFactory->create( + Psr0Mapping::fromArrayMappings(array_merge_recursive( + $this->prefixWithInstallationPath($this->packageToPsr0AutoloadNamespaces($composer), $projectInstallationPath), + $dev ? $this->prefixWithInstallationPath($this->packageToPsr0AutoloadNamespaces($composer, 'autoload-dev'), $projectInstallationPath) : [], + ...array_map(fn (array $package): array => $this->prefixWithPackagePath( + $this->packageToPsr0AutoloadNamespaces($package), + $installedJsonDirectoryPath, + $package, + $vendorDirectory, + ), $installed), + )), + ); + + $files = []; + foreach ($classMapPaths as $classMapPath) { + if (is_dir($classMapPath)) { + $locators[] = $this->optimizedDirectorySourceLocatorRepository->getOrCreate($classMapPath); + continue; + } + if (!is_file($classMapPath)) { + continue; + } + $files[] = $classMapPath; + } + foreach ($filePaths as $file) { + if (!is_file($file)) { + continue; + } + $files[] = $file; + } + + if (count($files) > 0) { + $locators[] = $this->optimizedDirectorySourceLocatorFactory->createByFiles($files); + } + + $binDir = ComposerHelper::getBinDirFromComposerConfig($projectInstallationPath, $composer); + $phpunitBridgeDir = $binDir . '/.phpunit'; + if (!is_dir($vendorDirectory . '/phpunit/phpunit') && is_dir($phpunitBridgeDir)) { + // from https://github.com/composer/composer/blob/8ff237afb61b8766efa576b8ae1cc8560c8aed96/phpstan/locate-phpunit-autoloader.php + $bestDirFound = null; + $phpunitBridgeDirectories = glob($phpunitBridgeDir . '/phpunit-*', GLOB_ONLYDIR); + if ($phpunitBridgeDirectories !== false) { + foreach (array_reverse($phpunitBridgeDirectories) as $dir) { + $bestDirFound = $dir; + if ($this->phpVersion->getVersionId() >= 80100 && str_contains($dir, 'phpunit-10')) { + break; + } + if ($this->phpVersion->getVersionId() >= 80000) { + if (str_contains($dir, 'phpunit-9')) { + break; + } + continue; + } + + if (str_contains($dir, 'phpunit-8') || str_contains($dir, 'phpunit-7')) { + break; + } + } + + if ($bestDirFound !== null) { + $phpunitBridgeLocator = $this->create($bestDirFound); + if ($phpunitBridgeLocator !== null) { + $locators[] = $phpunitBridgeLocator; + } + } + } + } + + return new AggregateSourceLocator($locators); + } + + /** + * @param mixed[] $package + * + * @return array> + */ + private function packageToPsr4AutoloadNamespaces(array $package, string $autoloadSection = 'autoload'): array + { + return array_map(static fn ($namespacePaths): array => (array) $namespacePaths, $package[$autoloadSection]['psr-4'] ?? []); + } + + /** + * @param mixed[] $package + * + * @return array> + */ + private function packageToPsr0AutoloadNamespaces(array $package, string $autoloadSection = 'autoload'): array + { + return array_map(static fn ($namespacePaths): array => (array) $namespacePaths, $package[$autoloadSection]['psr-0'] ?? []); + } + + /** + * @param mixed[] $package + * + * @return array + */ + private function packageToClassMapPaths(array $package, string $autoloadSection = 'autoload'): array + { + return $package[$autoloadSection]['classmap'] ?? []; + } + + /** + * @param mixed[] $package + * + * @return array + */ + private function packageToFilePaths(array $package, string $autoloadSection = 'autoload'): array + { + return $package[$autoloadSection]['files'] ?? []; + } + + /** + * @param mixed[] $package + */ + private function packagePrefixPath( + string $installedJsonDirectoryPath, + array $package, + string $vendorDirectory, + ): string + { + if (array_key_exists('install-path', $package)) { + return $installedJsonDirectoryPath . '/' . $package['install-path'] . '/'; + } + + return $vendorDirectory . '/' . $package['name'] . '/'; + } + + /** + * @param array> $paths + * @param array> $package + * + * @return array> + */ + private function prefixWithPackagePath(array $paths, string $installedJsonDirectoryPath, array $package, string $vendorDirectory): array + { + $prefix = $this->packagePrefixPath($installedJsonDirectoryPath, $package, $vendorDirectory); + + return array_map(fn (array $paths): array => $this->prefixPaths($paths, $prefix), $paths); + } + + /** + * @param array> $paths + * + * @return array> + */ + private function prefixWithInstallationPath(array $paths, string $trimmedInstallationPath): array + { + return array_map(fn (array $paths): array => $this->prefixPaths($paths, $trimmedInstallationPath . '/'), $paths); + } + + /** + * @param array $paths + * + * @return array + */ + private function prefixPaths(array $paths, string $prefix): array + { + return array_map(static fn (string $path): string => $prefix . $path, $paths); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/FetchedNode.php b/src/Reflection/BetterReflection/SourceLocator/FetchedNode.php new file mode 100644 index 00000000..ab9eec7e --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/FetchedNode.php @@ -0,0 +1,44 @@ +node; + } + + public function getNamespace(): ?Node\Stmt\Namespace_ + { + return $this->namespace; + } + + public function getLocatedSource(): LocatedSource + { + return $this->locatedSource; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/FetchedNodesResult.php b/src/Reflection/BetterReflection/SourceLocator/FetchedNodesResult.php new file mode 100644 index 00000000..af5d1c96 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/FetchedNodesResult.php @@ -0,0 +1,48 @@ +>> $classNodes + * @param array>> $functionNodes + * @param array>> $constantNodes + */ + public function __construct( + private array $classNodes, + private array $functionNodes, + private array $constantNodes, + ) + { + } + + /** + * @return array>> + */ + public function getClassNodes(): array + { + return $this->classNodes; + } + + /** + * @return array>> + */ + public function getFunctionNodes(): array + { + return $this->functionNodes; + } + + /** + * @return array>> + */ + public function getConstantNodes(): array + { + return $this->constantNodes; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/FileNodesFetcher.php b/src/Reflection/BetterReflection/SourceLocator/FileNodesFetcher.php new file mode 100644 index 00000000..6c4ad1f8 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/FileNodesFetcher.php @@ -0,0 +1,47 @@ +addVisitor($this->cachingVisitor); + + $contents = FileReader::read($fileName); + + try { + $ast = $this->parser->parseFile($fileName); + } catch (ParserErrorsException) { + return new FetchedNodesResult([], [], []); + } + $this->cachingVisitor->reset($fileName, $contents); + $nodeTraverser->traverse($ast); + + $result = new FetchedNodesResult( + $this->cachingVisitor->getClassNodes(), + $this->cachingVisitor->getFunctionNodes(), + $this->cachingVisitor->getConstantNodes(), + ); + + $this->cachingVisitor->reset($fileName, $contents); + + return $result; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php b/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php new file mode 100644 index 00000000..2c1593d5 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php @@ -0,0 +1,274 @@ +readFromFile = false; + $this->seekPosition = 0; + + return $exists; + } + + /** + * Since we allow our wrapper's stream_open() to succeed, we need to + * simulate a successful read so autoloaders with require() don't explode. + * + * @param int $count + * + */ + public function stream_read($count): string + { + $this->readFromFile = true; + + // Dummy return value that is also valid PHP for require(). We'll read + // and process the file elsewhere, so it's OK to provide dummy data for + // this read. + return ''; + } + + /** + * Since we allowed the open to succeed, we should allow the close to occur + * as well. + * + */ + public function stream_close(): void + { + // no op + } + + /** + * Required for `require_once` and `include_once` to work per PHP.net + * comment referenced below. We delegate to url_stat(). + * + * @see https://www.php.net/manual/en/function.stream-wrapper-register.php#51855 + * + * @return mixed[]|bool + */ + public function stream_stat() + { + if (self::$autoloadLocatedFiles === []) { + return false; + } + + return $this->url_stat(self::$autoloadLocatedFiles[0], STREAM_URL_STAT_QUIET); + } + + /** + * url_stat is triggered by calls like "file_exists". The call to "file_exists" must not be overloaded. + * This function restores the original "file" stream, issues a call to "stat" to get the real results, + * and then re-registers the AutoloadSourceLocator stream wrapper. + * + * @internal do not call this method directly! This is stream wrapper + * voodoo logic that you **DO NOT** want to touch! + * + * @see https://php.net/manual/en/class.streamwrapper.php + * @see https://php.net/manual/en/streamwrapper.url-stat.php + * + * @param string $path + * @param int $flags + * + * @return mixed[]|bool + */ + public function url_stat($path, $flags) + { + return $this->invokeWithRealFileStreamWrapper(static function ($path, $flags) { + if (($flags & STREAM_URL_STAT_QUIET) !== 0) { + return @stat($path); + } + + return stat($path); + }, [$path, $flags]); + } + + /** + * @param mixed[] $args + * @return mixed + */ + private function invokeWithRealFileStreamWrapper(callable $cb, array $args) + { + if (self::$registeredStreamWrapperProtocols === null) { + throw new ShouldNotHappenException(self::class . ' not registered: cannot operate. Do not call this method directly.'); + } + + foreach (self::$registeredStreamWrapperProtocols as $protocol) { + stream_wrapper_restore($protocol); + } + + $result = $cb(...$args); + + foreach (self::$registeredStreamWrapperProtocols as $protocol) { + stream_wrapper_unregister($protocol); + stream_wrapper_register($protocol, self::class); + } + + return $result; + } + + /** + * Simulates behavior of reading from an empty file. + * + */ + public function stream_eof(): bool + { + return $this->readFromFile; + } + + public function stream_flush(): bool + { + return true; + } + + public function stream_tell(): int + { + return $this->seekPosition; + } + + /** + * @param int $offset + * @param int $whence + */ + public function stream_seek($offset, $whence): bool + { + switch ($whence) { + // Behavior is the same for a zero-length file + case SEEK_SET: + case SEEK_END: + if ($offset < 0) { + return false; + } + $this->seekPosition = $offset; + return true; + + case SEEK_CUR: + if ($offset < 0) { + return false; + } + $this->seekPosition += $offset; + return true; + + default: + return false; + } + } + + /** + * @param int $option + * @param int $arg1 + * @param int $arg2 + */ + public function stream_set_option($option, $arg1, $arg2): bool + { + return false; + } + + public function dir_opendir(string $path, int $options): bool + { + return is_dir($path); + } + + public function dir_readdir(): string + { + return ''; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php new file mode 100644 index 00000000..4c074932 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php @@ -0,0 +1,218 @@ + $classToFile + * @param array> $functionToFiles + * @param array $constantToFile + */ + public function __construct( + private FileNodesFetcher $fileNodesFetcher, + private array $classToFile, + private array $functionToFiles, + private array $constantToFile, + ) + { + } + + public function locateIdentifier(Reflector $reflector, Identifier $identifier): ?Reflection + { + if ($identifier->isClass()) { + $className = strtolower($identifier->getName()); + $file = $this->findFileByClass($className); + if ($file === null) { + return null; + } + + $fetchedClassNodes = $this->fileNodesFetcher->fetchNodes($file)->getClassNodes(); + + if (!array_key_exists($className, $fetchedClassNodes)) { + return null; + } + + /** @var FetchedNode $fetchedClassNode */ + $fetchedClassNode = current($fetchedClassNodes[$className]); + + return $this->nodeToReflection($reflector, $fetchedClassNode); + } + + if ($identifier->isFunction()) { + $functionName = strtolower($identifier->getName()); + $files = $this->findFilesByFunction($functionName); + + $fetchedFunctionNode = null; + foreach ($files as $file) { + $fetchedFunctionNodes = $this->fileNodesFetcher->fetchNodes($file)->getFunctionNodes(); + + if (!array_key_exists($functionName, $fetchedFunctionNodes)) { + continue; + } + + /** @var FetchedNode $fetchedFunctionNode */ + $fetchedFunctionNode = current($fetchedFunctionNodes[$functionName]); + } + + if ($fetchedFunctionNode === null) { + return null; + } + + return $this->nodeToReflection($reflector, $fetchedFunctionNode); + } + + if ($identifier->isConstant()) { + $constantName = ConstantNameHelper::normalize($identifier->getName()); + $file = $this->findFileByConstant($constantName); + + if ($file === null) { + return null; + } + + $fetchedConstantNodes = $this->fileNodesFetcher->fetchNodes($file)->getConstantNodes(); + + if (!array_key_exists($constantName, $fetchedConstantNodes)) { + return null; + } + + /** @var FetchedNode $fetchedConstantNode */ + $fetchedConstantNode = current($fetchedConstantNodes[$constantName]); + + return $this->nodeToReflection( + $reflector, + $fetchedConstantNode, + $this->findConstantPositionInConstNode($fetchedConstantNode->getNode(), $constantName), + ); + } + + return null; + } + + /** + * @param FetchedNode|FetchedNode|FetchedNode $fetchedNode + */ + private function nodeToReflection(Reflector $reflector, FetchedNode $fetchedNode, ?int $positionInNode = null): Reflection + { + $nodeToReflection = new NodeToReflection(); + return $nodeToReflection->__invoke( + $reflector, + $fetchedNode->getNode(), + $fetchedNode->getLocatedSource(), + $fetchedNode->getNamespace(), + $positionInNode, + ); + } + + private function findFileByClass(string $className): ?string + { + if (!array_key_exists($className, $this->classToFile)) { + return null; + } + + return $this->classToFile[$className]; + } + + private function findFileByConstant(string $constantName): ?string + { + if (!array_key_exists($constantName, $this->constantToFile)) { + return null; + } + + return $this->constantToFile[$constantName]; + } + + /** + * @return string[] + */ + private function findFilesByFunction(string $functionName): array + { + if (!array_key_exists($functionName, $this->functionToFiles)) { + return []; + } + + return $this->functionToFiles[$functionName]; + } + + /** + * @return list + */ + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + $reflections = []; + if ($identifierType->isClass()) { + foreach ($this->classToFile as $file) { + $fetchedNodesResult = $this->fileNodesFetcher->fetchNodes($file); + foreach ($fetchedNodesResult->getClassNodes() as $identifierName => $fetchedClassNodes) { + foreach ($fetchedClassNodes as $fetchedClassNode) { + $reflections[$identifierName] = $this->nodeToReflection($reflector, $fetchedClassNode); + } + } + } + } elseif ($identifierType->isFunction()) { + foreach ($this->functionToFiles as $files) { + foreach ($files as $file) { + $fetchedNodesResult = $this->fileNodesFetcher->fetchNodes($file); + foreach ($fetchedNodesResult->getFunctionNodes() as $identifierName => $fetchedFunctionNodes) { + foreach ($fetchedFunctionNodes as $fetchedFunctionNode) { + $reflections[$identifierName] = $this->nodeToReflection($reflector, $fetchedFunctionNode); + continue 2; + } + } + } + } + } elseif ($identifierType->isConstant()) { + foreach ($this->constantToFile as $file) { + $fetchedNodesResult = $this->fileNodesFetcher->fetchNodes($file); + foreach ($fetchedNodesResult->getConstantNodes() as $identifierName => $fetchedConstantNodes) { + foreach ($fetchedConstantNodes as $fetchedConstantNode) { + $reflections[$identifierName] = $this->nodeToReflection( + $reflector, + $fetchedConstantNode, + $this->findConstantPositionInConstNode($fetchedConstantNode->getNode(), $identifierName), + ); + } + } + } + } + + return array_values($reflections); + } + + private function findConstantPositionInConstNode(Node\Stmt\Const_|Node\Expr\FuncCall $constantNode, string $constantName): ?int + { + if ($constantNode instanceof Node\Expr\FuncCall) { + return null; + } + + /** @var int $position */ + foreach ($constantNode->consts as $position => $const) { + if ($const->namespacedName === null) { + throw new ShouldNotHappenException(); + } + + if (ConstantNameHelper::normalize($const->namespacedName->toString()) === $constantName) { + return $position; + } + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php new file mode 100644 index 00000000..4c7c8241 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php @@ -0,0 +1,217 @@ +extraTypes = $this->phpVersion->supportsEnums() ? '|enum' : ''; + $this->cleaner = new PhpFileCleaner(); + } + + public function createByDirectory(string $directory): OptimizedDirectorySourceLocator + { + $files = $this->fileFinder->findFiles([$directory])->getFiles(); + $fileHashes = []; + foreach ($files as $file) { + $hash = sha1_file($file); + if ($hash === false) { + continue; + } + $fileHashes[$file] = $hash; + } + + $cacheKey = sprintf('odsl-%s', $directory); + $variableCacheKey = 'v1'; + + /** @var array|null $cached */ + $cached = $this->cache->load($cacheKey, $variableCacheKey); + if ($cached !== null) { + foreach ($cached as $file => [$hash, $classes, $functions, $constants]) { + if (!array_key_exists($file, $fileHashes)) { + unset($cached[$file]); + continue; + } + $newHash = $fileHashes[$file]; + unset($fileHashes[$file]); + if ($hash === $newHash) { + continue; + } + + [$newClasses, $newFunctions, $newConstants] = $this->findSymbols($file); + $cached[$file] = [$newHash, $newClasses, $newFunctions, $newConstants]; + } + } else { + $cached = []; + } + + foreach ($fileHashes as $file => $newHash) { + [$newClasses, $newFunctions, $newConstants] = $this->findSymbols($file); + $cached[$file] = [$newHash, $newClasses, $newFunctions, $newConstants]; + } + $this->cache->save($cacheKey, $variableCacheKey, $cached); + + [$classToFile, $functionToFiles, $constantToFile] = $this->changeStructure($cached); + + return new OptimizedDirectorySourceLocator( + $this->fileNodesFetcher, + $classToFile, + $functionToFiles, + $constantToFile, + ); + } + + /** + * @param string[] $files + */ + public function createByFiles(array $files): OptimizedDirectorySourceLocator + { + $symbols = []; + foreach ($files as $file) { + [$newClasses, $newFunctions, $newConstants] = $this->findSymbols($file); + $symbols[$file] = ['', $newClasses, $newFunctions, $newConstants]; + } + + [$classToFile, $functionToFiles, $constantToFile] = $this->changeStructure($symbols); + + return new OptimizedDirectorySourceLocator( + $this->fileNodesFetcher, + $classToFile, + $functionToFiles, + $constantToFile, + ); + } + + /** + * @param array $symbols + * @return array{array, array>, array} + */ + private function changeStructure(array $symbols): array + { + $classToFile = []; + $constantToFile = []; + $functionToFiles = []; + foreach ($symbols as $file => [, $classes, $functions, $constants]) { + foreach ($classes as $classInFile) { + $classToFile[$classInFile] = $file; + } + foreach ($functions as $functionInFile) { + if (!array_key_exists($functionInFile, $functionToFiles)) { + $functionToFiles[$functionInFile] = []; + } + $functionToFiles[$functionInFile][] = $file; + } + foreach ($constants as $constantInFile) { + $constantToFile[$constantInFile] = $file; + } + } + + return [ + $classToFile, + $functionToFiles, + $constantToFile, + ]; + } + + /** + * Inspired by Composer\Autoload\ClassMapGenerator::findClasses() + * @link https://github.com/composer/composer/blob/45d3e133a4691eccb12e9cd6f9dfd76eddc1906d/src/Composer/Autoload/ClassMapGenerator.php#L216 + * + * @return array{string[], string[], string[]} + */ + private function findSymbols(string $file): array + { + $contents = @php_strip_whitespace($file); + if ($contents === '') { + return [[], [], []]; + } + + $matchResults = (bool) preg_match_all(sprintf('{\b(?:(?:class|interface|trait|const|function%s)\s)|(?:define\s*\()}i', $this->extraTypes), $contents, $matches); + if (!$matchResults) { + return [[], [], []]; + } + + $contents = $this->cleaner->clean($contents, count($matches[0])); + + preg_match_all(sprintf('{ + (?: + \b(?])(?: + (?: (?Pclass|interface|trait%s) \s++ (?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\-]*+) ) + | (?: (?Pfunction) \s++ (?:&\s*)? (?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\-]*+) \s*+ [&\(] ) + | (?: (?Pconst) \s++ (?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\-]*+) \s*+ [^;] ) + | (?: (?:\\\)? (?Pdefine) \s*+ \( \s*+ [\'"] (?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:[\\\\]{1,2}[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+) ) + | (?: (?Pnamespace) (?P\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+)? \s*+ [\{;] ) + ) + ) + }ix', $this->extraTypes), $contents, $matches); + + $classes = []; + $functions = []; + $constants = []; + $namespace = ''; + + for ($i = 0, $len = count($matches['type']); $i < $len; $i++) { + if (isset($matches['ns'][$i]) && $matches['ns'][$i] !== '') { + $namespace = preg_replace('~\s+~', '', strtolower($matches['nsname'][$i])) . '\\'; + continue; + } + + if ($matches['function'][$i] !== '') { + $functions[] = strtolower(ltrim($namespace . $matches['fname'][$i], '\\')); + continue; + } + + if ($matches['constant'][$i] !== '') { + $constants[] = ConstantNameHelper::normalize(ltrim($namespace . $matches['cname'][$i], '\\')); + } + + if ($matches['define'][$i] !== '') { + $constants[] = ConstantNameHelper::normalize($matches['dname'][$i]); + continue; + } + + $name = $matches['name'][$i]; + + // skip anon classes extending/implementing + if (in_array($name, ['extends', 'implements'], true)) { + continue; + } + + $classes[] = strtolower(ltrim($namespace . $name, '\\')); + } + + return [ + $classes, + $functions, + $constants, + ]; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorRepository.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorRepository.php new file mode 100644 index 00000000..e711286d --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorRepository.php @@ -0,0 +1,29 @@ + */ + private array $locators = []; + + public function __construct(private OptimizedDirectorySourceLocatorFactory $factory) + { + } + + public function getOrCreate(string $directory): OptimizedDirectorySourceLocator + { + if (array_key_exists($directory, $this->locators)) { + return $this->locators[$directory]; + } + + $this->locators[$directory] = $this->factory->createByDirectory($directory); + + return $this->locators[$directory]; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocator.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocator.php new file mode 100644 index 00000000..f58bef74 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocator.php @@ -0,0 +1,65 @@ + */ + private array $locators = []; + + public function __construct( + private PsrAutoloaderMapping $mapping, + private OptimizedSingleFileSourceLocatorRepository $optimizedSingleFileSourceLocatorRepository, + ) + { + } + + public function locateIdentifier(Reflector $reflector, Identifier $identifier): ?Reflection + { + foreach ($this->locators as $locator) { + $reflection = $locator->locateIdentifier($reflector, $identifier); + if ($reflection === null) { + continue; + } + + return $reflection; + } + + foreach ($this->mapping->resolvePossibleFilePaths($identifier) as $file) { + if (!is_file($file)) { + continue; + } + + $locator = $this->optimizedSingleFileSourceLocatorRepository->getOrCreate($file); + $reflection = $locator->locateIdentifier($reflector, $identifier); + if ($reflection === null) { + continue; + } + + $this->locators[$file] = $locator; + + return $reflection; + } + + return null; + } + + /** + * @return list + */ + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return []; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocatorFactory.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocatorFactory.php new file mode 100644 index 00000000..1c42b6e0 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedPsrAutoloaderLocatorFactory.php @@ -0,0 +1,13 @@ +, functions: array, constants: array}|null */ + private ?array $presentSymbols = null; + + public function __construct( + private FileNodesFetcher $fileNodesFetcher, + private string $fileName, + ) + { + } + + public function locateIdentifier(Reflector $reflector, Identifier $identifier): ?Reflection + { + if ($this->presentSymbols !== null) { + if ($identifier->isClass()) { + $className = strtolower($identifier->getName()); + if (!array_key_exists($className, $this->presentSymbols['classes'])) { + return null; + } + } + if ($identifier->isFunction()) { + $className = strtolower($identifier->getName()); + if (!array_key_exists($className, $this->presentSymbols['functions'])) { + return null; + } + } + if ($identifier->isConstant()) { + $constantName = ConstantNameHelper::normalize($identifier->getName()); + if (!array_key_exists($constantName, $this->presentSymbols['constants'])) { + return null; + } + } + } + $fetchedNodesResult = $this->fileNodesFetcher->fetchNodes($this->fileName); + if ($this->presentSymbols === null) { + $presentSymbols = [ + 'classes' => [], + 'functions' => [], + 'constants' => [], + ]; + foreach (array_keys($fetchedNodesResult->getClassNodes()) as $className) { + $presentSymbols['classes'][$className] = true; + } + foreach (array_keys($fetchedNodesResult->getFunctionNodes()) as $functionName) { + $presentSymbols['functions'][$functionName] = true; + } + foreach (array_keys($fetchedNodesResult->getConstantNodes()) as $constantName) { + $presentSymbols['constants'][$constantName] = true; + } + + $this->presentSymbols = $presentSymbols; + } + $nodeToReflection = new NodeToReflection(); + if ($identifier->isClass()) { + $classNodes = $fetchedNodesResult->getClassNodes(); + $className = strtolower($identifier->getName()); + if (!array_key_exists($className, $classNodes)) { + return null; + } + + foreach ($classNodes[$className] as $classNode) { + $classReflection = $nodeToReflection->__invoke( + $reflector, + $classNode->getNode(), + $classNode->getLocatedSource(), + $classNode->getNamespace(), + ); + if (!$classReflection instanceof ReflectionClass) { + throw new ShouldNotHappenException(); + } + + return $classReflection; + } + } + + if ($identifier->isFunction()) { + $functionNodes = $fetchedNodesResult->getFunctionNodes(); + $functionName = strtolower($identifier->getName()); + if (!array_key_exists($functionName, $functionNodes)) { + return null; + } + + foreach ($functionNodes[$functionName] as $functionNode) { + $functionReflection = $nodeToReflection->__invoke( + $reflector, + $functionNode->getNode(), + $functionNode->getLocatedSource(), + $functionNode->getNamespace(), + ); + if (!$functionReflection instanceof ReflectionFunction) { + throw new ShouldNotHappenException(); + } + + return $functionReflection; + } + } + + if ($identifier->isConstant()) { + $constantNodes = $fetchedNodesResult->getConstantNodes(); + $constantName = ConstantNameHelper::normalize($identifier->getName()); + + if (!array_key_exists($constantName, $constantNodes)) { + return null; + } + + foreach ($constantNodes[$constantName] as $fetchedConstantNode) { + $constantNode = $fetchedConstantNode->getNode(); + + $positionInNode = null; + if ($constantNode instanceof Const_) { + foreach ($constantNode->consts as $constPosition => $const) { + if ($const->namespacedName === null) { + throw new ShouldNotHappenException(); + } + + if (ConstantNameHelper::normalize($const->namespacedName->toString()) === $constantName) { + /** @var int $positionInNode */ + $positionInNode = $constPosition; + break; + } + } + + if ($positionInNode === null) { + throw new ShouldNotHappenException(); + } + } + + $constantReflection = $nodeToReflection->__invoke( + $reflector, + $fetchedConstantNode->getNode(), + $fetchedConstantNode->getLocatedSource(), + $fetchedConstantNode->getNamespace(), + $positionInNode, + ); + if (!$constantReflection instanceof ReflectionConstant) { + throw new ShouldNotHappenException(); + } + + return $constantReflection; + } + + return null; + } + + throw new ShouldNotHappenException(); + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + $fetchedNodesResult = $this->fileNodesFetcher->fetchNodes($this->fileName); + $nodeToReflection = new NodeToReflection(); + $reflections = []; + if ($identifierType->isClass()) { + $classNodes = $fetchedNodesResult->getClassNodes(); + + foreach ($classNodes as $classNodesArray) { + foreach ($classNodesArray as $classNode) { + $classReflection = $nodeToReflection->__invoke( + $reflector, + $classNode->getNode(), + $classNode->getLocatedSource(), + $classNode->getNamespace(), + ); + + if (!$classReflection instanceof ReflectionClass) { + throw new ShouldNotHappenException(); + } + + $reflections[] = $classReflection; + } + } + } + + if ($identifierType->isFunction()) { + $functionNodes = $fetchedNodesResult->getFunctionNodes(); + + foreach ($functionNodes as $functionNodesArray) { + foreach ($functionNodesArray as $functionNode) { + $functionReflection = $nodeToReflection->__invoke( + $reflector, + $functionNode->getNode(), + $functionNode->getLocatedSource(), + $functionNode->getNamespace(), + ); + + $reflections[] = $functionReflection; + } + } + } + + if ($identifierType->isConstant()) { + $constantNodes = $fetchedNodesResult->getConstantNodes(); + foreach ($constantNodes as $constantNodesArray) { + foreach ($constantNodesArray as $fetchedConstantNode) { + $constantNode = $fetchedConstantNode->getNode(); + + if ($constantNode instanceof Const_) { + foreach ($constantNode->consts as $constPosition => $const) { + if ($const->namespacedName === null) { + throw new ShouldNotHappenException(); + } + + $constantReflection = $nodeToReflection->__invoke( + $reflector, + $constantNode, + $fetchedConstantNode->getLocatedSource(), + $fetchedConstantNode->getNamespace(), + $constPosition, + ); + if (!$constantReflection instanceof ReflectionConstant) { + throw new ShouldNotHappenException(); + } + + $reflections[] = $constantReflection; + } + + continue; + } + + $constantReflection = $nodeToReflection->__invoke( + $reflector, + $constantNode, + $fetchedConstantNode->getLocatedSource(), + $fetchedConstantNode->getNamespace(), + ); + if (!$constantReflection instanceof ReflectionConstant) { + throw new ShouldNotHappenException(); + } + + $reflections[] = $constantReflection; + } + } + } + + return $reflections; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocatorFactory.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocatorFactory.php new file mode 100644 index 00000000..82d2fa92 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedSingleFileSourceLocatorFactory.php @@ -0,0 +1,11 @@ + */ + private array $locators = []; + + public function __construct(private OptimizedSingleFileSourceLocatorFactory $factory) + { + } + + public function getOrCreate(string $fileName): OptimizedSingleFileSourceLocator + { + if (array_key_exists($fileName, $this->locators)) { + return $this->locators[$fileName]; + } + + $this->locators[$fileName] = $this->factory->create($fileName); + + return $this->locators[$fileName]; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/PhpFileCleaner.php b/src/Reflection/BetterReflection/SourceLocator/PhpFileCleaner.php new file mode 100644 index 00000000..5c60ef12 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/PhpFileCleaner.php @@ -0,0 +1,296 @@ + + * @see https://github.com/composer/composer/pull/10107 + */ +final class PhpFileCleaner +{ + + /** @var array */ + private array $typeConfig = []; + + private string $restPattern; + + private string $contents = ''; + + private int $len = 0; + + private int $index = 0; + + public function __construct() + { + foreach (['class', 'interface', 'trait', 'enum'] as $type) { + $this->typeConfig[$type[0]] = [ + 'name' => $type, + 'length' => strlen($type), + 'pattern' => '{.\b(?])' . $type . '\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\-]*+}Ais', + ]; + } + + $this->restPattern = '{[^{}?"\'typeConfig)) . ']+}A'; + } + + public function clean(string $contents, int $maxMatches): string + { + $this->contents = $contents; + $this->len = strlen($contents); + $this->index = 0; + + $inType = false; + $typeLevel = 0; + + $inDefine = false; + + $clean = ''; + while ($this->index < $this->len) { + $this->skipToPhp(); + $clean .= 'index < $this->len) { + $char = $this->contents[$this->index]; + if ($char === '?' && $this->peek('>')) { + $clean .= '?>'; + $this->index += 2; + continue 2; + } + + if (in_array($char, ['"', "'"], true)) { + if ($inDefine) { + $clean .= $char . $this->consumeString($char); + $inDefine = false; + } else { + $this->skipString($char); + $clean .= 'null'; + } + + continue; + } + + if ($char === '{') { + if ($inType) { + $typeLevel++; + } + + $clean .= $char; + $this->index++; + continue; + } + + if ($char === '}') { + if ($inType) { + $typeLevel--; + + if ($typeLevel === 0) { + $inType = false; + } + } + + $clean .= $char; + $this->index++; + continue; + } + + if ($char === '<' && $this->peek('<') && $this->match('{<<<[ \t]*+([\'"]?)([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*+)\\1(?:\r\n|\n|\r)}A', $match)) { + $this->index += strlen($match[0]); + $this->skipHeredoc($match[2]); + $clean .= 'null'; + continue; + } + + if ($char === '/') { + if ($this->peek('/')) { + $this->skipToNewline(); + continue; + } + if ($this->peek('*')) { + $this->skipComment(); + continue; + } + } + + if ( + $inType + && $char === 'c' + && $this->match('~.\b(?])const(\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff\-]*+)~Ais', $match, $this->index - 1) + ) { + // It's invalid PHP but it does not matter + $clean .= 'class_const' . $match[1]; + $this->index += strlen($match[0]) - 1; + continue; + } + + if ($char === 'd' && $this->match('~.\b(?])define\s*+\(~Ais', $match, $this->index - 1)) { + $inDefine = true; + $clean .= $match[0]; + $this->index += strlen($match[0]) - 1; + continue; + } + + if (isset($this->typeConfig[$char])) { + $type = $this->typeConfig[$char]; + + if (substr($this->contents, $this->index, $type['length']) === $type['name']) { + if ($maxMatches === 1 && $this->match($type['pattern'], $match, $this->index - 1)) { + return $clean . $match[0]; + } + + $inType = true; + } + } + + $this->index += 1; + if ($this->match($this->restPattern, $match)) { + $clean .= $char . $match[0]; + $this->index += strlen($match[0]); + } else { + $clean .= $char; + } + } + } + + return $clean; + } + + private function skipToPhp(): void + { + while ($this->index < $this->len) { + if ($this->contents[$this->index] === '<' && $this->peek('?')) { + $this->index += 2; + break; + } + + $this->index += 1; + } + } + + private function consumeString(string $delimiter): string + { + $string = ''; + + $this->index += 1; + while ($this->index < $this->len) { + if ($this->contents[$this->index] === '\\' && ($this->peek('\\') || $this->peek($delimiter))) { + $string .= $this->contents[$this->index]; + $string .= $this->contents[$this->index + 1]; + + $this->index += 2; + continue; + } + + if ($this->contents[$this->index] === $delimiter) { + $string .= $delimiter; + $this->index += 1; + break; + } + + $string .= $this->contents[$this->index]; + $this->index += 1; + } + + return $string; + } + + private function skipString(string $delimiter): void + { + $this->index += 1; + while ($this->index < $this->len) { + if ($this->contents[$this->index] === '\\' && ($this->peek('\\') || $this->peek($delimiter))) { + $this->index += 2; + continue; + } + if ($this->contents[$this->index] === $delimiter) { + $this->index += 1; + break; + } + $this->index += 1; + } + } + + private function skipComment(): void + { + $this->index += 2; + while ($this->index < $this->len) { + if ($this->contents[$this->index] === '*' && $this->peek('/')) { + $this->index += 2; + break; + } + + $this->index += 1; + } + } + + private function skipToNewline(): void + { + while ($this->index < $this->len) { + if (in_array($this->contents[$this->index], ["\r", "\n"], true)) { + return; + } + $this->index += 1; + } + } + + private function skipHeredoc(string $delimiter): void + { + $firstDelimiterChar = $delimiter[0]; + $delimiterLength = strlen($delimiter); + $delimiterPattern = '{' . preg_quote($delimiter) . '(?![a-zA-Z0-9_\x80-\xff])}A'; + + while ($this->index < $this->len) { + // check if we find the delimiter after some spaces/tabs + switch ($this->contents[$this->index]) { + case "\t": + case ' ': + $this->index += 1; + continue 2; + case $firstDelimiterChar: + if ( + substr($this->contents, $this->index, $delimiterLength) === $delimiter + && $this->match($delimiterPattern) + ) { + $this->index += $delimiterLength; + return; + } + break; + } + + // skip the rest of the line + while ($this->index < $this->len) { + $this->skipToNewline(); + + // skip newlines + while ($this->index < $this->len && ($this->contents[$this->index] === "\r" || $this->contents[$this->index] === "\n")) { + $this->index += 1; + } + + break; + } + } + } + + private function peek(string $char): bool + { + return $this->index + 1 < $this->len && $this->contents[$this->index + 1] === $char; + } + + /** + * @param string[]|null $match + * @param-out string[] $match + */ + private function match(string $regex, ?array &$match = null, ?int $offset = null): bool + { + return preg_match($regex, $this->contents, $match, 0, $offset ?? $this->index) === 1; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/PhpVersionBlacklistSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/PhpVersionBlacklistSourceLocator.php new file mode 100644 index 00000000..745a7623 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/PhpVersionBlacklistSourceLocator.php @@ -0,0 +1,45 @@ +isClass()) { + if ($this->phpStormStubsSourceStubber->isPresentClass($identifier->getName()) === false) { + return null; + } + } + + if ($identifier->isFunction()) { + if ($this->phpStormStubsSourceStubber->isPresentFunction($identifier->getName()) === false) { + return null; + } + } + + return $this->sourceLocator->locateIdentifier($reflector, $identifier); + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return $this->sourceLocator->locateIdentifiersByType($reflector, $identifierType); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/ReflectionClassSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/ReflectionClassSourceLocator.php new file mode 100644 index 00000000..8fb1f229 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/ReflectionClassSourceLocator.php @@ -0,0 +1,54 @@ +isClass()) { + return null; + } + + /** @var class-string $className */ + $className = $identifier->getName(); + + $stub = $this->reflectionSourceStubber->generateClassStub($className); + if ($stub === null) { + return null; + } + + $reflection = new ReflectionClass($className); + + return $this->astLocator->findReflection( + $reflector, + new LocatedSource($stub->getStub(), $reflection->getName(), null), + new Identifier($reflection->getName(), new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return []; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/RewriteClassAliasSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/RewriteClassAliasSourceLocator.php new file mode 100644 index 00000000..837d7c6a --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/RewriteClassAliasSourceLocator.php @@ -0,0 +1,47 @@ +isClass()) { + return $this->originalSourceLocator->locateIdentifier($reflector, $identifier); + } + + if ( + class_exists($identifier->getName(), false) + || interface_exists($identifier->getName(), false) + || trait_exists($identifier->getName(), false) + ) { + $classReflection = new CoreReflectionClass($identifier->getName()); + + return $this->originalSourceLocator->locateIdentifier($reflector, new Identifier($classReflection->getName(), $identifier->getType())); + } + + return $this->originalSourceLocator->locateIdentifier($reflector, $identifier); + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return $this->originalSourceLocator->locateIdentifiersByType($reflector, $identifierType); + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/SkipClassAliasSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/SkipClassAliasSourceLocator.php new file mode 100644 index 00000000..a717eb6d --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/SkipClassAliasSourceLocator.php @@ -0,0 +1,48 @@ +isClass()) { + $className = $identifier->getName(); + if (!class_exists($className, false)) { + return $this->sourceLocator->locateIdentifier($reflector, $identifier); + } + + $reflection = new ReflectionClass($className); + if ($reflection->getName() === 'ReturnTypeWillChange') { + return $this->sourceLocator->locateIdentifier($reflector, $identifier); + } + if ($reflection->getFileName() === false) { + return $this->sourceLocator->locateIdentifier($reflector, $identifier); + } + + return null; + } + + return $this->sourceLocator->locateIdentifier($reflector, $identifier); + } + + public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array + { + return $this->sourceLocator->locateIdentifiersByType($reflector, $identifierType); + } + +} diff --git a/src/Reflection/BetterReflection/SourceStubber/PhpStormStubsSourceStubberFactory.php b/src/Reflection/BetterReflection/SourceStubber/PhpStormStubsSourceStubberFactory.php new file mode 100644 index 00000000..2365bda1 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceStubber/PhpStormStubsSourceStubberFactory.php @@ -0,0 +1,23 @@ +phpParser, $this->printer, $this->phpVersion->getVersionId()); + } + +} diff --git a/src/Reflection/BetterReflection/SourceStubber/ReflectionSourceStubberFactory.php b/src/Reflection/BetterReflection/SourceStubber/ReflectionSourceStubberFactory.php new file mode 100644 index 00000000..07016b70 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceStubber/ReflectionSourceStubberFactory.php @@ -0,0 +1,22 @@ +printer, $this->phpVersion->getVersionId()); + } + +} diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php new file mode 100644 index 00000000..45fde991 --- /dev/null +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php @@ -0,0 +1,66 @@ +class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return in_array($methodReflection->getName(), [ + 'getDocComment', + 'getType', + ], true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if ($this->phpVersion->getVersionId() >= 80000) { + return null; + } + + if ($methodReflection->getName() === 'getDocComment') { + return new UnionType([ + new StringType(), + new ConstantBooleanType(false), + ]); + } + + if ($methodReflection->getName() === 'getType') { + return new UnionType([ + new ObjectType(ReflectionType::class), + new NullType(), + ]); + } + + return null; + } + +} diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php new file mode 100644 index 00000000..13f51a9d --- /dev/null +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php @@ -0,0 +1,103 @@ +getName(), [ + 'getFileName', + 'getStartLine', + 'getEndLine', + 'getDocComment', + 'getReflectionConstant', + 'getParentClass', + 'getExtensionName', + 'getBackingType', + ], true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if ($this->phpVersion->getVersionId() >= 80000) { + return null; + } + + if (in_array($methodReflection->getName(), ['getFileName', 'getExtensionName'], true)) { + return new UnionType([ + new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]), + new ConstantBooleanType(false), + ]); + } + + if ($methodReflection->getName() === 'getDocComment') { + return new UnionType([ + new StringType(), + new ConstantBooleanType(false), + ]); + } + + if (in_array($methodReflection->getName(), ['getStartLine', 'getEndLine'], true)) { + return new IntegerType(); + } + + if ($methodReflection->getName() === 'getReflectionConstant') { + return new UnionType([ + new ObjectType(ReflectionClassConstant::class), + new ConstantBooleanType(false), + ]); + } + + if ($methodReflection->getName() === 'getParentClass') { + return new UnionType([ + new ObjectType(ReflectionClass::class), + new ConstantBooleanType(false), + ]); + } + + if ($methodReflection->getName() === 'getBackingType') { + return new UnionType([ + new ObjectType(ReflectionNamedType::class), + new NullType(), + ]); + } + + return null; + } + +} diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php new file mode 100644 index 00000000..4257706a --- /dev/null +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -0,0 +1,40 @@ + new self($function, $variant), $variants); + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->variant->getTemplateTypeMap(); + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->variant->getResolvedTemplateTypeMap(); + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->variant->getParameters(); + } + + public function isVariadic(): bool + { + return $this->variant->isVariadic(); + } + + public function getReturnType(): Type + { + return $this->variant->getReturnType(); + } + + public function getPhpDocReturnType(): Type + { + return $this->variant->getPhpDocReturnType(); + } + + public function getNativeReturnType(): Type + { + return $this->variant->getNativeReturnType(); + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->variant->getCallSiteVarianceMap(); + } + + public function getThrowPoints(): array + { + if ($this->throwPoints !== null) { + return $this->throwPoints; + } + + if ($this->variant instanceof CallableParametersAcceptor) { + return $this->throwPoints = $this->variant->getThrowPoints(); + } + + $returnType = $this->variant->getReturnType(); + $throwType = $this->function->getThrowType(); + if ($throwType === null) { + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + $throwType = new ObjectType(Throwable::class); + } + } + + $throwPoints = []; + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + $throwPoints[] = SimpleThrowPoint::createExplicit($throwType, true); + } + } else { + if (!(new ObjectType(Throwable::class))->isSuperTypeOf($returnType)->yes()) { + $throwPoints[] = SimpleThrowPoint::createImplicit(); + } + } + + return $this->throwPoints = $throwPoints; + } + + public function isPure(): TrinaryLogic + { + $impurePoints = $this->getImpurePoints(); + if (count($impurePoints) === 0) { + return TrinaryLogic::createYes(); + } + + $certainCount = 0; + foreach ($impurePoints as $impurePoint) { + if (!$impurePoint->isCertain()) { + continue; + } + + $certainCount++; + } + + return $certainCount > 0 ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + } + + public function getImpurePoints(): array + { + if ($this->impurePoints !== null) { + return $this->impurePoints; + } + + if ($this->variant instanceof CallableParametersAcceptor) { + return $this->impurePoints = $this->variant->getImpurePoints(); + } + + $impurePoint = SimpleImpurePoint::createFromVariant($this->function, $this->variant); + if ($impurePoint === null) { + return $this->impurePoints = []; + } + + return $this->impurePoints = [$impurePoint]; + } + + public function getInvalidateExpressions(): array + { + return []; + } + + public function getUsedVariables(): array + { + return []; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->function->acceptsNamedArguments(); + } + +} diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php new file mode 100644 index 00000000..e76ddb28 --- /dev/null +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -0,0 +1,73 @@ +hasSideEffects()->no()) { + $certain = $function->isPure()->no(); + if ($variant !== null) { + $certain = $certain || $variant->getReturnType()->isVoid()->yes(); + } + + if ($function instanceof FunctionReflection) { + return new SimpleImpurePoint( + 'functionCall', + sprintf('call to function %s()', $function->getName()), + $certain, + ); + } + + return new SimpleImpurePoint( + 'methodCall', + sprintf('call to method %s::%s()', $function->getDeclaringClass()->getDisplayName(), $function->getName()), + $certain, + ); + } + + return null; + } + + /** + * @return ImpurePointIdentifier + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getDescription(): string + { + return $this->description; + } + + public function isCertain(): bool + { + return $this->certain; + } + +} diff --git a/src/Reflection/Callables/SimpleThrowPoint.php b/src/Reflection/Callables/SimpleThrowPoint.php new file mode 100644 index 00000000..b5e6d6f9 --- /dev/null +++ b/src/Reflection/Callables/SimpleThrowPoint.php @@ -0,0 +1,46 @@ +type; + } + + public function isExplicit(): bool + { + return $this->explicit; + } + + public function canContainAnyThrowable(): bool + { + return $this->canContainAnyThrowable; + } + +} diff --git a/src/Reflection/ClassConstantReflection.php b/src/Reflection/ClassConstantReflection.php new file mode 100644 index 00000000..488d1dee --- /dev/null +++ b/src/Reflection/ClassConstantReflection.php @@ -0,0 +1,30 @@ + + */ + public function getAttributes(): array; + +} diff --git a/src/Reflection/ClassMemberAccessAnswerer.php b/src/Reflection/ClassMemberAccessAnswerer.php new file mode 100644 index 00000000..7651b7c2 --- /dev/null +++ b/src/Reflection/ClassMemberAccessAnswerer.php @@ -0,0 +1,30 @@ +getClassReflection() + */ + public function isInClass(): bool; + + public function getClassReflection(): ?ClassReflection; + + /** + * @deprecated Use canReadProperty() or canWriteProperty() + */ + public function canAccessProperty(PropertyReflection $propertyReflection): bool; + + public function canReadProperty(ExtendedPropertyReflection $propertyReflection): bool; + + public function canWriteProperty(ExtendedPropertyReflection $propertyReflection): bool; + + public function canCallMethod(MethodReflection $methodReflection): bool; + + public function canAccessConstant(ClassConstantReflection $constantReflection): bool; + +} diff --git a/src/Reflection/ClassMemberReflection.php b/src/Reflection/ClassMemberReflection.php new file mode 100644 index 00000000..c3258e07 --- /dev/null +++ b/src/Reflection/ClassMemberReflection.php @@ -0,0 +1,20 @@ +|null */ + private ?array $ancestors = null; + + private ?string $cacheKey = null; + + /** @var array */ + private array $subclasses = []; + + private string|false|null $filename = false; + + private string|false|null $reflectionDocComment = false; + + private false|ResolvedPhpDocBlock $resolvedPhpDocBlock = false; + + private false|ResolvedPhpDocBlock $traitContextResolvedPhpDocBlock = false; + + /** @var array|null */ + private ?array $cachedInterfaces = null; + + private ClassReflection|false|null $cachedParentClass = false; + + /** @var array|null */ + private ?array $typeAliases = null; + + /** @var array */ + private static array $resolvingTypeAliasImports = []; + + /** @var array */ + private array $hasMethodCache = []; + + /** + * @param PropertiesClassReflectionExtension[] $propertiesClassReflectionExtensions + * @param MethodsClassReflectionExtension[] $methodsClassReflectionExtensions + * @param AllowedSubTypesClassReflectionExtension[] $allowedSubTypesClassReflectionExtensions + * @param string[] $universalObjectCratesClasses + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private FileTypeMapper $fileTypeMapper, + private StubPhpDocProvider $stubPhpDocProvider, + private PhpDocInheritanceResolver $phpDocInheritanceResolver, + private PhpVersion $phpVersion, + private SignatureMapProvider $signatureMapProvider, + private AttributeReflectionFactory $attributeReflectionFactory, + private array $propertiesClassReflectionExtensions, + private array $methodsClassReflectionExtensions, + private array $allowedSubTypesClassReflectionExtensions, + private RequireExtendsPropertiesClassReflectionExtension $requireExtendsPropertiesClassReflectionExtension, + private RequireExtendsMethodsClassReflectionExtension $requireExtendsMethodsClassReflectionExtension, + private string $displayName, + private ReflectionClass|ReflectionEnum $reflection, + private ?string $anonymousFilename, + private ?TemplateTypeMap $resolvedTemplateTypeMap, + private ?ResolvedPhpDocBlock $stubPhpDocBlock, + private array $universalObjectCratesClasses, + private ?string $extraCacheKey = null, + private ?TemplateTypeVarianceMap $resolvedCallSiteVarianceMap = null, + ) + { + } + + public function getNativeReflection(): ReflectionClass|ReflectionEnum + { + return $this->reflection; + } + + public function getFileName(): ?string + { + if (!is_bool($this->filename)) { + return $this->filename; + } + + if ($this->anonymousFilename !== null) { + return $this->filename = $this->anonymousFilename; + } + $fileName = $this->reflection->getFileName(); + if ($fileName === false) { + return $this->filename = null; + } + + if (!is_file($fileName)) { + return $this->filename = null; + } + + return $this->filename = $fileName; + } + + public function getParentClass(): ?ClassReflection + { + if (!is_bool($this->cachedParentClass)) { + return $this->cachedParentClass; + } + + $parentClass = $this->reflection->getParentClass(); + + if ($parentClass === false) { + return $this->cachedParentClass = null; + } + + $extendsTag = $this->getFirstExtendsTag(); + + if ($extendsTag !== null && $this->isValidAncestorType($extendsTag->getType(), [$parentClass->getName()])) { + $extendedType = $extendsTag->getType(); + + if ($this->isGeneric()) { + $extendedType = TemplateTypeHelper::resolveTemplateTypes( + $extendedType, + $this->getPossiblyIncompleteActiveTemplateTypeMap(), + $this->getCallSiteVarianceMap(), + TemplateTypeVariance::createStatic(), + ); + } + + if (!$extendedType instanceof GenericObjectType) { + return $this->reflectionProvider->getClass($parentClass->getName()); + } + + return $extendedType->getClassReflection() ?? $this->reflectionProvider->getClass($parentClass->getName()); + } + + $parentReflection = $this->reflectionProvider->getClass($parentClass->getName()); + if ($parentReflection->isGeneric()) { + return $parentReflection->withTypes( + array_values($parentReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes()), + ); + } + + $this->cachedParentClass = $parentReflection; + + return $parentReflection; + } + + /** + * @return class-string + */ + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getDisplayName(bool $withTemplateTypes = true): string + { + if ( + $withTemplateTypes === false + || $this->resolvedTemplateTypeMap === null + || count($this->resolvedTemplateTypeMap->getTypes()) === 0 + ) { + return $this->displayName; + } + + $templateTypes = []; + $variances = $this->getCallSiteVarianceMap()->getVariances(); + foreach ($this->getActiveTemplateTypeMap()->getTypes() as $name => $templateType) { + $variance = $variances[$name] ?? null; + if ($variance === null) { + continue; + } + + $templateTypes[] = TypeProjectionHelper::describe($templateType, $variance, VerbosityLevel::typeOnly()); + } + + return $this->displayName . '<' . implode(',', $templateTypes) . '>'; + } + + public function getCacheKey(): string + { + $cacheKey = $this->cacheKey; + if ($cacheKey !== null) { + return $this->cacheKey; + } + + $cacheKey = $this->displayName; + + if ($this->resolvedTemplateTypeMap !== null) { + $templateTypes = []; + $variances = $this->getCallSiteVarianceMap()->getVariances(); + foreach ($this->getActiveTemplateTypeMap()->getTypes() as $name => $templateType) { + $variance = $variances[$name] ?? null; + if ($variance === null) { + continue; + } + + $templateTypes[] = TypeProjectionHelper::describe($templateType, $variance, VerbosityLevel::cache()); + } + + $cacheKey .= '<' . implode(',', $templateTypes) . '>'; + } + + if ($this->extraCacheKey !== null) { + $cacheKey .= '-' . $this->extraCacheKey; + } + + $this->cacheKey = $cacheKey; + + return $cacheKey; + } + + /** + * @return int[] + */ + public function getClassHierarchyDistances(): array + { + if ($this->classHierarchyDistances === null) { + $distance = 0; + $distances = [ + $this->getName() => $distance, + ]; + $currentClassReflection = $this->getNativeReflection(); + foreach ($this->collectTraits($this->getNativeReflection()) as $trait) { + $distance++; + if (array_key_exists($trait->getName(), $distances)) { + continue; + } + + $distances[$trait->getName()] = $distance; + } + + while ($currentClassReflection->getParentClass() !== false) { + $distance++; + $parentClassName = $currentClassReflection->getParentClass()->getName(); + if (!array_key_exists($parentClassName, $distances)) { + $distances[$parentClassName] = $distance; + } + $currentClassReflection = $currentClassReflection->getParentClass(); + foreach ($this->collectTraits($currentClassReflection) as $trait) { + $distance++; + if (array_key_exists($trait->getName(), $distances)) { + continue; + } + + $distances[$trait->getName()] = $distance; + } + } + foreach ($this->getNativeReflection()->getInterfaces() as $interface) { + $distance++; + if (array_key_exists($interface->getName(), $distances)) { + continue; + } + + $distances[$interface->getName()] = $distance; + } + + $this->classHierarchyDistances = $distances; + } + + return $this->classHierarchyDistances; + } + + /** + * @return list + */ + private function collectTraits(ReflectionClass|ReflectionEnum $class): array + { + $traits = []; + $traitsLeftToAnalyze = $class->getTraits(); + + while (count($traitsLeftToAnalyze) !== 0) { + $trait = reset($traitsLeftToAnalyze); + $traits[] = $trait; + + foreach ($trait->getTraits() as $subTrait) { + if (in_array($subTrait, $traits, true)) { + continue; + } + + $traitsLeftToAnalyze[] = $subTrait; + } + + array_shift($traitsLeftToAnalyze); + } + + return $traits; + } + + public function allowsDynamicProperties(): bool + { + if ($this->isEnum()) { + return false; + } + + if (!$this->phpVersion->deprecatesDynamicProperties()) { + return true; + } + + if ($this->isReadOnly()) { + return false; + } + + if (UniversalObjectCratesClassReflectionExtension::isUniversalObjectCrate( + $this->reflectionProvider, + $this, + )) { + return true; + } + + $class = $this; + $attributes = $class->reflection->getAttributes('AllowDynamicProperties'); + while (count($attributes) === 0 && $class->getParentClass() !== null) { + $attributes = $class->getParentClass()->reflection->getAttributes('AllowDynamicProperties'); + $class = $class->getParentClass(); + } + + return count($attributes) > 0; + } + + private function allowsDynamicPropertiesExtensions(): bool + { + if ($this->allowsDynamicProperties()) { + return true; + } + + $hasMagicMethod = $this->hasNativeMethod('__get') || $this->hasNativeMethod('__set') || $this->hasNativeMethod('__isset'); + if ($hasMagicMethod) { + return true; + } + + foreach ($this->getRequireExtendsTags() as $extendsTag) { + $type = $extendsTag->getType(); + if (!$type instanceof ObjectType) { + continue; + } + + $reflection = $type->getClassReflection(); + if ($reflection === null) { + continue; + } + + if (!$reflection->allowsDynamicPropertiesExtensions()) { + continue; + } + + return true; + } + + return false; + } + + public function hasProperty(string $propertyName): bool + { + if ($this->isEnum()) { + return $this->hasNativeProperty($propertyName); + } + + foreach ($this->propertiesClassReflectionExtensions as $i => $extension) { + if ($i > 0 && !$this->allowsDynamicPropertiesExtensions()) { + break; + } + if ($extension->hasProperty($this, $propertyName)) { + return true; + } + } + + if ($this->requireExtendsPropertiesClassReflectionExtension->hasProperty($this, $propertyName)) { + return true; + } + + return false; + } + + public function hasMethod(string $methodName): bool + { + if (array_key_exists($methodName, $this->hasMethodCache)) { + return $this->hasMethodCache[$methodName]; + } + + foreach ($this->methodsClassReflectionExtensions as $extension) { + if ($extension->hasMethod($this, $methodName)) { + $this->hasMethodCache[$methodName] = true; + + return true; + } + } + + if ($this->requireExtendsMethodsClassReflectionExtension->hasMethod($this, $methodName)) { + $this->hasMethodCache[$methodName] = true; + + return true; + } + + $this->hasMethodCache[$methodName] = false; + + return false; + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + $key = $methodName; + if ($scope->isInClass()) { + $key = sprintf('%s-%s', $key, $scope->getClassReflection()->getCacheKey()); + } + + if (!isset($this->methods[$key])) { + foreach ($this->methodsClassReflectionExtensions as $extension) { + if (!$extension->hasMethod($this, $methodName)) { + continue; + } + + $method = $this->wrapExtendedMethod($extension->getMethod($this, $methodName)); + if ($scope->canCallMethod($method)) { + return $this->methods[$key] = $method; + } + $this->methods[$key] = $method; + } + } + + if (!isset($this->methods[$key])) { + if ($this->requireExtendsMethodsClassReflectionExtension->hasMethod($this, $methodName)) { + $method = $this->requireExtendsMethodsClassReflectionExtension->getMethod($this, $methodName); + $this->methods[$key] = $method; + } + } + + if (!isset($this->methods[$key])) { + throw new MissingMethodFromReflectionException($this->getName(), $methodName); + } + + return $this->methods[$key]; + } + + private function wrapExtendedMethod(MethodReflection $method): ExtendedMethodReflection + { + if ($method instanceof ExtendedMethodReflection) { + return $method; + } + + return new WrappedExtendedMethodReflection($method); + } + + private function wrapExtendedProperty(PropertyReflection $method): ExtendedPropertyReflection + { + if ($method instanceof ExtendedPropertyReflection) { + return $method; + } + + return new WrappedExtendedPropertyReflection($method); + } + + public function hasNativeMethod(string $methodName): bool + { + return $this->getPhpExtension()->hasNativeMethod($this, $methodName); + } + + public function getNativeMethod(string $methodName): ExtendedMethodReflection + { + if (!$this->hasNativeMethod($methodName)) { + throw new MissingMethodFromReflectionException($this->getName(), $methodName); + } + return $this->getPhpExtension()->getNativeMethod($this, $methodName); + } + + public function hasConstructor(): bool + { + return $this->findConstructor() !== null; + } + + public function getConstructor(): ExtendedMethodReflection + { + $constructor = $this->findConstructor(); + if ($constructor === null) { + throw new ShouldNotHappenException(); + } + return $this->getNativeMethod($constructor->getName()); + } + + private function findConstructor(): ?ReflectionMethod + { + $constructor = $this->reflection->getConstructor(); + if ($constructor === null) { + return null; + } + + if ($this->phpVersion->supportsLegacyConstructor()) { + return $constructor; + } + + if (strtolower($constructor->getName()) !== '__construct') { + return null; + } + + return $constructor; + } + + private function getPhpExtension(): PhpClassReflectionExtension + { + $extension = $this->methodsClassReflectionExtensions[0]; + if (!$extension instanceof PhpClassReflectionExtension) { + throw new ShouldNotHappenException(); + } + + return $extension; + } + + /** @internal */ + public function evictPrivateSymbols(): void + { + foreach ($this->constants as $name => $constant) { + if (!$constant->isPrivate()) { + continue; + } + + unset($this->constants[$name]); + } + foreach ($this->properties as $name => $property) { + if (!$property->isPrivate()) { + continue; + } + + unset($this->properties[$name]); + } + foreach ($this->methods as $name => $method) { + if (!$method->isPrivate()) { + continue; + } + + unset($this->methods[$name]); + } + $this->getPhpExtension()->evictPrivateSymbols($this->getCacheKey()); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + if ($this->isEnum()) { + return $this->getNativeProperty($propertyName); + } + + $key = $propertyName; + if ($scope->isInClass()) { + $key = sprintf('%s-%s', $key, $scope->getClassReflection()->getCacheKey()); + } + + if (!isset($this->properties[$key])) { + foreach ($this->propertiesClassReflectionExtensions as $i => $extension) { + if ($i > 0 && !$this->allowsDynamicPropertiesExtensions()) { + break; + } + + if (!$extension->hasProperty($this, $propertyName)) { + continue; + } + + $property = $this->wrapExtendedProperty($extension->getProperty($this, $propertyName)); + if ($scope->canReadProperty($property)) { + return $this->properties[$key] = $property; + } + $this->properties[$key] = $property; + } + } + + if (!isset($this->properties[$key])) { + if ($this->requireExtendsPropertiesClassReflectionExtension->hasProperty($this, $propertyName)) { + $property = $this->requireExtendsPropertiesClassReflectionExtension->getProperty($this, $propertyName); + $this->properties[$key] = $property; + } + } + + if (!isset($this->properties[$key])) { + throw new MissingPropertyFromReflectionException($this->getName(), $propertyName); + } + + return $this->properties[$key]; + } + + public function hasNativeProperty(string $propertyName): bool + { + return $this->getPhpExtension()->hasProperty($this, $propertyName); + } + + public function getNativeProperty(string $propertyName): PhpPropertyReflection + { + if (!$this->hasNativeProperty($propertyName)) { + throw new MissingPropertyFromReflectionException($this->getName(), $propertyName); + } + + return $this->getPhpExtension()->getNativeProperty($this, $propertyName); + } + + public function isAbstract(): bool + { + return $this->reflection->isAbstract(); + } + + public function isInterface(): bool + { + return $this->reflection->isInterface(); + } + + public function isTrait(): bool + { + return $this->reflection->isTrait(); + } + + /** + * @phpstan-assert-if-true ReflectionEnum $this->reflection + */ + public function isEnum(): bool + { + return $this->reflection instanceof ReflectionEnum && $this->reflection->isEnum(); + } + + /** + * @return 'Interface'|'Trait'|'Enum'|'Class' + */ + public function getClassTypeDescription(): string + { + if ($this->isInterface()) { + return 'Interface'; + } elseif ($this->isTrait()) { + return 'Trait'; + } elseif ($this->isEnum()) { + return 'Enum'; + } + + return 'Class'; + } + + public function isReadOnly(): bool + { + return $this->reflection->isReadOnly(); + } + + public function isBackedEnum(): bool + { + if (!$this->reflection instanceof ReflectionEnum) { + return false; + } + + return $this->reflection->isBacked(); + } + + public function getBackedEnumType(): ?Type + { + if (!$this->reflection instanceof ReflectionEnum) { + return null; + } + + if (!$this->reflection->isBacked()) { + return null; + } + + return TypehintHelper::decideTypeFromReflection($this->reflection->getBackingType()); + } + + public function hasEnumCase(string $name): bool + { + if (!$this->isEnum()) { + return false; + } + + return $this->reflection->hasCase($name); + } + + /** + * @return array + */ + public function getEnumCases(): array + { + if (!$this->isEnum()) { + throw new ShouldNotHappenException(); + } + + if ($this->enumCases !== null) { + return $this->enumCases; + } + + $cases = []; + $initializerExprContext = InitializerExprContext::fromClassReflection($this); + foreach ($this->reflection->getCases() as $case) { + $valueType = null; + if ($case instanceof ReflectionEnumBackedCase) { + $valueType = $this->initializerExprTypeResolver->getType($case->getValueExpression(), $initializerExprContext); + } + $caseName = $case->getName(); + $cases[$caseName] = new EnumCaseReflection($this, $case, $valueType, $this->attributeReflectionFactory->fromNativeReflection($case->getAttributes(), InitializerExprContext::fromClass($this->getName(), $this->getFileName()))); + } + + return $this->enumCases = $cases; + } + + public function getEnumCase(string $name): EnumCaseReflection + { + if (!$this->hasEnumCase($name)) { + throw new ShouldNotHappenException(sprintf('Enum case %s::%s does not exist.', $this->getDisplayName(), $name)); + } + + if (!$this->reflection instanceof ReflectionEnum) { + throw new ShouldNotHappenException(); + } + + if ($this->enumCases !== null && array_key_exists($name, $this->enumCases)) { + return $this->enumCases[$name]; + } + + $case = $this->reflection->getCase($name); + $valueType = null; + if ($case instanceof ReflectionEnumBackedCase) { + $valueType = $this->initializerExprTypeResolver->getType($case->getValueExpression(), InitializerExprContext::fromClassReflection($this)); + } + + return new EnumCaseReflection($this, $case, $valueType, $this->attributeReflectionFactory->fromNativeReflection($case->getAttributes(), InitializerExprContext::fromClass($this->getName(), $this->getFileName()))); + } + + public function isClass(): bool + { + return !$this->isInterface() && !$this->isTrait() && !$this->isEnum(); + } + + public function isAnonymous(): bool + { + return $this->anonymousFilename !== null; + } + + public function is(string $className): bool + { + return $this->getName() === $className || $this->isSubclassOf($className); + } + + public function isSubclassOf(string $className): bool + { + if (isset($this->subclasses[$className])) { + return $this->subclasses[$className]; + } + + if (!$this->reflectionProvider->hasClass($className)) { + return $this->subclasses[$className] = false; + } + + try { + return $this->subclasses[$className] = $this->reflection->isSubclassOf($className); + } catch (ReflectionException) { + return $this->subclasses[$className] = false; + } + } + + public function implementsInterface(string $className): bool + { + try { + return $this->reflection->implementsInterface($className); + } catch (ReflectionException) { + return false; + } + } + + /** + * @return list + */ + public function getParents(): array + { + $parents = []; + $parent = $this->getParentClass(); + while ($parent !== null) { + $parents[] = $parent; + $parent = $parent->getParentClass(); + } + + return $parents; + } + + /** + * @return array + */ + public function getInterfaces(): array + { + if ($this->cachedInterfaces !== null) { + return $this->cachedInterfaces; + } + + $interfaces = $this->getImmediateInterfaces(); + $immediateInterfaces = $interfaces; + $parent = $this->getParentClass(); + while ($parent !== null) { + foreach ($parent->getImmediateInterfaces() as $parentInterface) { + $interfaces[$parentInterface->getName()] = $parentInterface; + foreach ($this->collectInterfaces($parentInterface) as $parentInterfaceInterface) { + $interfaces[$parentInterfaceInterface->getName()] = $parentInterfaceInterface; + } + } + + $parent = $parent->getParentClass(); + } + + foreach ($immediateInterfaces as $immediateInterface) { + foreach ($this->collectInterfaces($immediateInterface) as $interfaceInterface) { + $interfaces[$interfaceInterface->getName()] = $interfaceInterface; + } + } + + $this->cachedInterfaces = $interfaces; + + return $interfaces; + } + + /** + * @return array + */ + private function collectInterfaces(ClassReflection $interface): array + { + $interfaces = []; + foreach ($interface->getImmediateInterfaces() as $immediateInterface) { + $interfaces[$immediateInterface->getName()] = $immediateInterface; + foreach ($this->collectInterfaces($immediateInterface) as $immediateInterfaceInterface) { + $interfaces[$immediateInterfaceInterface->getName()] = $immediateInterfaceInterface; + } + } + + return $interfaces; + } + + /** + * @return array + */ + public function getImmediateInterfaces(): array + { + $indirectInterfaceNames = []; + $parent = $this->getParentClass(); + while ($parent !== null) { + foreach ($parent->getNativeReflection()->getInterfaceNames() as $parentInterfaceName) { + $indirectInterfaceNames[] = $parentInterfaceName; + } + + $parent = $parent->getParentClass(); + } + + foreach ($this->getNativeReflection()->getInterfaces() as $interfaceInterface) { + foreach ($interfaceInterface->getInterfaceNames() as $interfaceInterfaceName) { + $indirectInterfaceNames[] = $interfaceInterfaceName; + } + } + + if ($this->reflection->isInterface()) { + $implementsTags = $this->getExtendsTags(); + } else { + $implementsTags = $this->getImplementsTags(); + } + + $immediateInterfaceNames = array_diff($this->getNativeReflection()->getInterfaceNames(), $indirectInterfaceNames); + $immediateInterfaces = []; + foreach ($immediateInterfaceNames as $immediateInterfaceName) { + if (!$this->reflectionProvider->hasClass($immediateInterfaceName)) { + continue; + } + + $immediateInterface = $this->reflectionProvider->getClass($immediateInterfaceName); + if (array_key_exists($immediateInterface->getName(), $implementsTags)) { + $implementsTag = $implementsTags[$immediateInterface->getName()]; + $implementedType = $implementsTag->getType(); + if ($this->isGeneric()) { + $implementedType = TemplateTypeHelper::resolveTemplateTypes( + $implementedType, + $this->getPossiblyIncompleteActiveTemplateTypeMap(), + $this->getCallSiteVarianceMap(), + TemplateTypeVariance::createStatic(), + true, + ); + } + + if ( + $implementedType instanceof GenericObjectType + && $implementedType->getClassReflection() !== null + ) { + $immediateInterfaces[$immediateInterface->getName()] = $implementedType->getClassReflection(); + continue; + } + } + + if ($immediateInterface->isGeneric()) { + $immediateInterfaces[$immediateInterface->getName()] = $immediateInterface->withTypes( + array_values($immediateInterface->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes()), + ); + continue; + } + + $immediateInterfaces[$immediateInterface->getName()] = $immediateInterface; + } + + return $immediateInterfaces; + } + + /** + * @return array + */ + public function getTraits(bool $recursive = false): array + { + $traits = []; + + if ($recursive) { + foreach ($this->collectTraits($this->getNativeReflection()) as $trait) { + $traits[$trait->getName()] = $trait; + } + } else { + $traits = $this->getNativeReflection()->getTraits(); + } + + $traits = array_map(fn (ReflectionClass $trait): ClassReflection => $this->reflectionProvider->getClass($trait->getName()), $traits); + + if ($recursive) { + $parentClass = $this->getNativeReflection()->getParentClass(); + + if ($parentClass !== false) { + return array_merge( + $traits, + $this->reflectionProvider->getClass($parentClass->getName())->getTraits(true), + ); + } + } + + return $traits; + } + + /** + * @return list + */ + public function getParentClassesNames(): array + { + $parentNames = []; + $parentClass = $this->getParentClass(); + while ($parentClass !== null) { + $parentNames[] = $parentClass->getName(); + $parentClass = $parentClass->getParentClass(); + } + + return $parentNames; + } + + public function hasConstant(string $name): bool + { + if (!$this->getNativeReflection()->hasConstant($name)) { + return false; + } + + $reflectionConstant = $this->getNativeReflection()->getReflectionConstant($name); + if ($reflectionConstant === false) { + return false; + } + + return $this->reflectionProvider->hasClass($reflectionConstant->getDeclaringClass()->getName()); + } + + public function getConstant(string $name): ClassConstantReflection + { + if (!isset($this->constants[$name])) { + $reflectionConstant = $this->getNativeReflection()->getReflectionConstant($name); + if ($reflectionConstant === false) { + throw new MissingConstantFromReflectionException($this->getName(), $name); + } + + $declaringClass = $this->reflectionProvider->getClass($reflectionConstant->getDeclaringClass()->getName()); + $fileName = $declaringClass->getFileName(); + $phpDocType = null; + $resolvedPhpDoc = $this->stubPhpDocProvider->findClassConstantPhpDoc( + $declaringClass->getName(), + $name, + ); + if ($resolvedPhpDoc === null) { + $docComment = null; + if ($reflectionConstant->getDocComment() !== false) { + $docComment = $reflectionConstant->getDocComment(); + } + $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForConstant( + $docComment, + $declaringClass, + $fileName, + $name, + ); + } + + $deprecatedDescription = $resolvedPhpDoc->getDeprecatedTag() !== null ? $resolvedPhpDoc->getDeprecatedTag()->getMessage() : null; + $isDeprecated = $resolvedPhpDoc->isDeprecated(); + $isInternal = $resolvedPhpDoc->isInternal(); + $isFinal = $resolvedPhpDoc->isFinal(); + $varTags = $resolvedPhpDoc->getVarTags(); + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } + + $nativeType = null; + if ($reflectionConstant->getType() !== null) { + $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType(), null, $declaringClass); + } elseif ($this->signatureMapProvider->hasClassConstantMetadata($declaringClass->getName(), $name)) { + $nativeType = $this->signatureMapProvider->getClassConstantMetadata($declaringClass->getName(), $name)['nativeType']; + } + + $this->constants[$name] = new RealClassClassConstantReflection( + $this->initializerExprTypeResolver, + $declaringClass, + $reflectionConstant, + $nativeType, + $phpDocType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isFinal, + $this->attributeReflectionFactory->fromNativeReflection($reflectionConstant->getAttributes(), InitializerExprContext::fromClass($declaringClass->getName(), $fileName)), + ); + } + return $this->constants[$name]; + } + + public function hasTraitUse(string $traitName): bool + { + return in_array($traitName, $this->getTraitNames(), true); + } + + /** + * @return list + */ + private function getTraitNames(): array + { + $class = $this->reflection; + $traitNames = array_map(static fn (ReflectionClass $class) => $class->getName(), $this->collectTraits($class)); + while ($class->getParentClass() !== false) { + $traitNames = array_values(array_unique(array_merge($traitNames, $class->getParentClass()->getTraitNames()))); + $class = $class->getParentClass(); + } + + return $traitNames; + } + + /** + * @return array + */ + public function getTypeAliases(): array + { + if ($this->typeAliases === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return $this->typeAliases = []; + } + + $typeAliasImportTags = $resolvedPhpDoc->getTypeAliasImportTags(); + $typeAliasTags = $resolvedPhpDoc->getTypeAliasTags(); + + // prevent circular imports + if (array_key_exists($this->getName(), self::$resolvingTypeAliasImports)) { + throw new CircularTypeAliasDefinitionException(); + } + + self::$resolvingTypeAliasImports[$this->getName()] = true; + + $importedAliases = array_map(function (TypeAliasImportTag $typeAliasImportTag): ?TypeAlias { + $importedAlias = $typeAliasImportTag->getImportedAlias(); + $importedFromClassName = $typeAliasImportTag->getImportedFrom(); + + if (!$this->reflectionProvider->hasClass($importedFromClassName)) { + return null; + } + + $importedFromReflection = $this->reflectionProvider->getClass($importedFromClassName); + + try { + $typeAliases = $importedFromReflection->getTypeAliases(); + } catch (CircularTypeAliasDefinitionException) { + return TypeAlias::invalid(); + } + + if (!array_key_exists($importedAlias, $typeAliases)) { + return null; + } + + return $typeAliases[$importedAlias]; + }, $typeAliasImportTags); + + unset(self::$resolvingTypeAliasImports[$this->getName()]); + + $localAliases = array_map(static fn (TypeAliasTag $typeAliasTag): TypeAlias => $typeAliasTag->getTypeAlias(), $typeAliasTags); + + $this->typeAliases = array_filter( + array_merge($importedAliases, $localAliases), + static fn (?TypeAlias $typeAlias): bool => $typeAlias !== null, + ); + } + + return $this->typeAliases; + } + + public function getDeprecatedDescription(): ?string + { + if ($this->deprecatedDescription === null && $this->isDeprecated()) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc !== null && $resolvedPhpDoc->getDeprecatedTag() !== null) { + $this->deprecatedDescription = $resolvedPhpDoc->getDeprecatedTag()->getMessage(); + } + } + + return $this->deprecatedDescription; + } + + public function isDeprecated(): bool + { + if ($this->isDeprecated === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->isDeprecated = $resolvedPhpDoc !== null && $resolvedPhpDoc->isDeprecated(); + } + + return $this->isDeprecated; + } + + public function isBuiltin(): bool + { + return $this->reflection->isInternal(); + } + + public function isInternal(): bool + { + if ($this->isInternal === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->isInternal = $resolvedPhpDoc !== null && $resolvedPhpDoc->isInternal(); + } + + return $this->isInternal; + } + + public function isFinal(): bool + { + if ($this->isFinalByKeyword()) { + return true; + } + + if ($this->isFinal === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->isFinal = $resolvedPhpDoc !== null && $resolvedPhpDoc->isFinal(); + } + + return $this->isFinal; + } + + public function isImmutable(): bool + { + if ($this->isImmutable === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->isImmutable = $resolvedPhpDoc !== null && ($resolvedPhpDoc->isImmutable() || $resolvedPhpDoc->isReadOnly()); + + $parentClass = $this->getParentClass(); + if ($parentClass !== null && !$this->isImmutable) { + $this->isImmutable = $parentClass->isImmutable(); + } + } + + return $this->isImmutable; + } + + public function hasConsistentConstructor(): bool + { + if ($this->hasConsistentConstructor === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->hasConsistentConstructor = $resolvedPhpDoc !== null && $resolvedPhpDoc->hasConsistentConstructor(); + } + + return $this->hasConsistentConstructor; + } + + public function acceptsNamedArguments(): bool + { + if ($this->acceptsNamedArguments === null) { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + $this->acceptsNamedArguments = $resolvedPhpDoc === null || $resolvedPhpDoc->acceptsNamedArguments(); + } + + return $this->acceptsNamedArguments; + } + + public function isFinalByKeyword(): bool + { + if ($this->isAnonymous()) { + return true; + } + + return $this->reflection->isFinal(); + } + + public function isAttributeClass(): bool + { + return $this->findAttributeFlags() !== null; + } + + private function findAttributeFlags(): ?int + { + if ($this->isInterface() || $this->isTrait() || $this->isEnum()) { + return null; + } + + $nativeAttributes = $this->reflection->getAttributes(Attribute::class); + if (count($nativeAttributes) === 1) { + if (!$this->reflectionProvider->hasClass(Attribute::class)) { + return null; + } + + $attributeClass = $this->reflectionProvider->getClass(Attribute::class); + $arguments = []; + foreach ($nativeAttributes[0]->getArgumentsExpressions() as $i => $expression) { + $arguments[] = new Arg($expression, false, false, [], is_int($i) ? null : new Identifier($i)); + } + + if (!$attributeClass->hasConstructor()) { + return null; + } + $attributeConstructor = $attributeClass->getConstructor(); + $attributeConstructorVariant = $attributeConstructor->getOnlyVariant(); + + if (count($arguments) === 0) { + $flagType = $attributeConstructorVariant->getParameters()[0]->getDefaultValue(); + } else { + $staticCall = ArgumentsNormalizer::reorderStaticCallArguments( + $attributeConstructorVariant, + new StaticCall(new FullyQualified(Attribute::class), $attributeConstructor->getName(), $arguments), + ); + if ($staticCall === null) { + return null; + } + $flagExpr = $staticCall->getArgs()[0]->value; + $flagType = $this->initializerExprTypeResolver->getType($flagExpr, InitializerExprContext::fromClassReflection($this)); + } + + if (!$flagType instanceof ConstantIntegerType) { + return null; + } + + return $flagType->getValue(); + } + + return null; + } + + /** + * @return list + */ + public function getAttributes(): array + { + return $this->attributeReflectionFactory->fromNativeReflection($this->reflection->getAttributes(), InitializerExprContext::fromClass($this->getName(), $this->getFileName())); + } + + public function getAttributeClassFlags(): int + { + $flags = $this->findAttributeFlags(); + if ($flags === null) { + throw new ShouldNotHappenException(); + } + + return $flags; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + if ($this->templateTypeMap !== null) { + return $this->templateTypeMap; + } + + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + $this->templateTypeMap = TemplateTypeMap::createEmpty(); + return $this->templateTypeMap; + } + + $templateTypeScope = TemplateTypeScope::createWithClass($this->getName()); + + $templateTypeMap = new TemplateTypeMap(array_map(static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), $this->getTemplateTags())); + + $this->templateTypeMap = $templateTypeMap; + + return $templateTypeMap; + } + + public function getActiveTemplateTypeMap(): TemplateTypeMap + { + if ($this->activeTemplateTypeMap !== null) { + return $this->activeTemplateTypeMap; + } + $resolved = $this->resolvedTemplateTypeMap; + if ($resolved !== null) { + $templateTypeMap = $this->getTemplateTypeMap(); + return $this->activeTemplateTypeMap = $resolved->map(static function (string $name, Type $type) use ($templateTypeMap): Type { + if ($type instanceof ErrorType) { + $templateType = $templateTypeMap->getType($name); + if ($templateType !== null) { + return TemplateTypeHelper::resolveToDefaults($templateType); + } + } + + return $type; + }); + } + + return $this->activeTemplateTypeMap = $this->getTemplateTypeMap(); + } + + public function getPossiblyIncompleteActiveTemplateTypeMap(): TemplateTypeMap + { + return $this->resolvedTemplateTypeMap ?? $this->getTemplateTypeMap(); + } + + private function getDefaultCallSiteVarianceMap(): TemplateTypeVarianceMap + { + if ($this->defaultCallSiteVarianceMap !== null) { + return $this->defaultCallSiteVarianceMap; + } + + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + $this->defaultCallSiteVarianceMap = TemplateTypeVarianceMap::createEmpty(); + return $this->defaultCallSiteVarianceMap; + } + + $map = []; + foreach ($this->getTemplateTags() as $templateTag) { + $map[$templateTag->getName()] = TemplateTypeVariance::createInvariant(); + } + + $this->defaultCallSiteVarianceMap = new TemplateTypeVarianceMap($map); + return $this->defaultCallSiteVarianceMap; + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->callSiteVarianceMap ??= $this->resolvedCallSiteVarianceMap ?? $this->getDefaultCallSiteVarianceMap(); + } + + public function isGeneric(): bool + { + if ($this->isGeneric === null) { + if ($this->isEnum()) { + return $this->isGeneric = false; + } + + $this->isGeneric = count($this->getTemplateTags()) > 0; + } + + return $this->isGeneric; + } + + /** + * @param array $types + */ + public function typeMapFromList(array $types): TemplateTypeMap + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return TemplateTypeMap::createEmpty(); + } + + $map = []; + $i = 0; + foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { + $map[$tag->getName()] = $types[$i] ?? $tag->getDefault() ?? $tag->getBound(); + $i++; + } + + return new TemplateTypeMap($map); + } + + /** + * @param array $variances + */ + public function varianceMapFromList(array $variances): TemplateTypeVarianceMap + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return new TemplateTypeVarianceMap([]); + } + + $map = []; + $i = 0; + foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { + $map[$tag->getName()] = $variances[$i] ?? TemplateTypeVariance::createInvariant(); + $i++; + } + + return new TemplateTypeVarianceMap($map); + } + + /** @return list */ + public function typeMapToList(TemplateTypeMap $typeMap): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + $list = []; + foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { + $list[] = $typeMap->getType($tag->getName()) ?? $tag->getDefault() ?? $tag->getBound(); + } + + return $list; + } + + /** @return list */ + public function varianceMapToList(TemplateTypeVarianceMap $varianceMap): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + $list = []; + foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { + $list[] = $varianceMap->getVariance($tag->getName()) ?? TemplateTypeVariance::createInvariant(); + } + + return $list; + } + + /** + * @param array $types + */ + public function withTypes(array $types): self + { + return new self( + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->fileTypeMapper, + $this->stubPhpDocProvider, + $this->phpDocInheritanceResolver, + $this->phpVersion, + $this->signatureMapProvider, + $this->attributeReflectionFactory, + $this->propertiesClassReflectionExtensions, + $this->methodsClassReflectionExtensions, + $this->allowedSubTypesClassReflectionExtensions, + $this->requireExtendsPropertiesClassReflectionExtension, + $this->requireExtendsMethodsClassReflectionExtension, + $this->displayName, + $this->reflection, + $this->anonymousFilename, + $this->typeMapFromList($types), + $this->stubPhpDocBlock, + $this->universalObjectCratesClasses, + null, + $this->resolvedCallSiteVarianceMap, + ); + } + + /** + * @param array $variances + */ + public function withVariances(array $variances): self + { + return new self( + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->fileTypeMapper, + $this->stubPhpDocProvider, + $this->phpDocInheritanceResolver, + $this->phpVersion, + $this->signatureMapProvider, + $this->attributeReflectionFactory, + $this->propertiesClassReflectionExtensions, + $this->methodsClassReflectionExtensions, + $this->allowedSubTypesClassReflectionExtensions, + $this->requireExtendsPropertiesClassReflectionExtension, + $this->requireExtendsMethodsClassReflectionExtension, + $this->displayName, + $this->reflection, + $this->anonymousFilename, + $this->resolvedTemplateTypeMap, + $this->stubPhpDocBlock, + $this->universalObjectCratesClasses, + null, + $this->varianceMapFromList($variances), + ); + } + + public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock + { + if ($this->stubPhpDocBlock !== null) { + return $this->stubPhpDocBlock; + } + + $fileName = $this->getFileName(); + if (is_bool($this->reflectionDocComment)) { + $docComment = $this->reflection->getDocComment(); + $this->reflectionDocComment = $docComment !== false ? $docComment : null; + } + + if ($this->reflectionDocComment === null) { + return null; + } + + if ($this->resolvedPhpDocBlock !== false) { + return $this->resolvedPhpDocBlock; + } + + return $this->resolvedPhpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc($fileName, $this->getName(), null, null, $this->reflectionDocComment); + } + + public function getTraitContextResolvedPhpDoc(self $implementingClass): ?ResolvedPhpDocBlock + { + if (!$this->isTrait()) { + throw new ShouldNotHappenException(); + } + if ($implementingClass->isTrait()) { + throw new ShouldNotHappenException(); + } + $fileName = $this->getFileName(); + if (is_bool($this->reflectionDocComment)) { + $docComment = $this->reflection->getDocComment(); + $this->reflectionDocComment = $docComment !== false ? $docComment : null; + } + + if ($this->reflectionDocComment === null) { + return null; + } + + if ($this->traitContextResolvedPhpDocBlock !== false) { + return $this->traitContextResolvedPhpDocBlock; + } + + return $this->traitContextResolvedPhpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc($fileName, $implementingClass->getName(), $this->getName(), null, $this->reflectionDocComment); + } + + private function getFirstExtendsTag(): ?ExtendsTag + { + foreach ($this->getExtendsTags() as $tag) { + return $tag; + } + + return null; + } + + /** @return array */ + public function getExtendsTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getExtendsTags(); + } + + /** @return array */ + public function getImplementsTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getImplementsTags(); + } + + /** @return array */ + public function getTemplateTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getTemplateTags(); + } + + /** + * @return array + */ + public function getAncestors(): array + { + $ancestors = $this->ancestors; + + if ($ancestors === null) { + $ancestors = [ + $this->getName() => $this, + ]; + + $addToAncestors = static function (string $name, ClassReflection $classReflection) use (&$ancestors): void { + if (array_key_exists($name, $ancestors)) { + return; + } + + $ancestors[$name] = $classReflection; + }; + + foreach ($this->getInterfaces() as $interface) { + $addToAncestors($interface->getName(), $interface); + foreach ($interface->getAncestors() as $name => $ancestor) { + $addToAncestors($name, $ancestor); + } + } + + foreach ($this->getTraits() as $trait) { + $addToAncestors($trait->getName(), $trait); + foreach ($trait->getAncestors() as $name => $ancestor) { + $addToAncestors($name, $ancestor); + } + } + + $parent = $this->getParentClass(); + if ($parent !== null) { + $addToAncestors($parent->getName(), $parent); + foreach ($parent->getAncestors() as $name => $ancestor) { + $addToAncestors($name, $ancestor); + } + } + + $this->ancestors = $ancestors; + } + + return $ancestors; + } + + public function getAncestorWithClassName(string $className): ?self + { + return $this->getAncestors()[$className] ?? null; + } + + /** + * @param string[] $ancestorClasses + */ + private function isValidAncestorType(Type $type, array $ancestorClasses): bool + { + if (!$type instanceof GenericObjectType) { + return false; + } + + $reflection = $type->getClassReflection(); + if ($reflection === null) { + return false; + } + + return in_array($reflection->getName(), $ancestorClasses, true); + } + + /** + * @return array + */ + public function getMixinTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getMixinTags(); + } + + /** + * @return array + */ + public function getRequireExtendsTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getRequireExtendsTags(); + } + + /** + * @return array + */ + public function getRequireImplementsTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getRequireImplementsTags(); + } + + /** + * @return array + */ + public function getPropertyTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getPropertyTags(); + } + + /** + * @return array + */ + public function getMethodTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getMethodTags(); + } + + /** + * @return list + */ + public function getResolvedMixinTypes(): array + { + $types = []; + foreach ($this->getMixinTags() as $mixinTag) { + if (!$this->isGeneric()) { + $types[] = $mixinTag->getType(); + continue; + } + + $types[] = TemplateTypeHelper::resolveTemplateTypes( + $mixinTag->getType(), + $this->getActiveTemplateTypeMap(), + $this->getCallSiteVarianceMap(), + TemplateTypeVariance::createStatic(), + ); + } + + return $types; + } + + /** + * @return array|null + */ + public function getAllowedSubTypes(): ?array + { + foreach ($this->allowedSubTypesClassReflectionExtensions as $allowedSubTypesClassReflectionExtension) { + if ($allowedSubTypesClassReflectionExtension->supports($this)) { + return $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this); + } + } + + return null; + } + +} diff --git a/src/Reflection/ClassReflectionExtensionRegistry.php b/src/Reflection/ClassReflectionExtensionRegistry.php new file mode 100644 index 00000000..ec34e423 --- /dev/null +++ b/src/Reflection/ClassReflectionExtensionRegistry.php @@ -0,0 +1,61 @@ +propertiesClassReflectionExtensions; + } + + /** + * @return MethodsClassReflectionExtension[] + */ + public function getMethodsClassReflectionExtensions(): array + { + return $this->methodsClassReflectionExtensions; + } + + /** + * @return AllowedSubTypesClassReflectionExtension[] + */ + public function getAllowedSubTypesClassReflectionExtensions(): array + { + return $this->allowedSubTypesClassReflectionExtensions; + } + + public function getRequireExtendsPropertyClassReflectionExtension(): RequireExtendsPropertiesClassReflectionExtension + { + return $this->requireExtendsPropertiesClassReflectionExtension; + } + + public function getRequireExtendsMethodsClassReflectionExtension(): RequireExtendsMethodsClassReflectionExtension + { + return $this->requireExtendsMethodsClassReflectionExtension; + } + +} diff --git a/src/Reflection/Constant/RuntimeConstantReflection.php b/src/Reflection/Constant/RuntimeConstantReflection.php new file mode 100644 index 00000000..d634091e --- /dev/null +++ b/src/Reflection/Constant/RuntimeConstantReflection.php @@ -0,0 +1,53 @@ +name; + } + + public function getValueType(): Type + { + return $this->valueType; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function isDeprecated(): TrinaryLogic + { + return $this->isDeprecated; + } + + public function getDeprecatedDescription(): ?string + { + return $this->deprecatedDescription; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + +} diff --git a/src/Reflection/ConstantNameHelper.php b/src/Reflection/ConstantNameHelper.php new file mode 100644 index 00000000..84c1640a --- /dev/null +++ b/src/Reflection/ConstantNameHelper.php @@ -0,0 +1,27 @@ + $part !== ''); + return strtolower(implode('\\', array_slice($nameParts, 0, -1))) . '\\' . end($nameParts); + } + +} diff --git a/src/Reflection/ConstantReflection.php b/src/Reflection/ConstantReflection.php new file mode 100644 index 00000000..3390b840 --- /dev/null +++ b/src/Reflection/ConstantReflection.php @@ -0,0 +1,25 @@ +> */ + private array $additionalConstructorsCache = []; + + /** + * @param list $additionalConstructors + */ + public function __construct( + private Container $container, + private array $additionalConstructors, + ) + { + } + + /** + * @return list + */ + public function getConstructors(ClassReflection $classReflection): array + { + if (array_key_exists($classReflection->getName(), $this->additionalConstructorsCache)) { + return $this->additionalConstructorsCache[$classReflection->getName()]; + } + $constructors = []; + if ($classReflection->hasConstructor()) { + $constructors[] = $classReflection->getConstructor()->getName(); + } + + /** @var AdditionalConstructorsExtension[] $extensions */ + $extensions = $this->container->getServicesByTag(AdditionalConstructorsExtension::EXTENSION_TAG); + foreach ($extensions as $extension) { + $extensionConstructors = $extension->getAdditionalConstructors($classReflection); + foreach ($extensionConstructors as $extensionConstructor) { + $constructors[] = $extensionConstructor; + } + } + + $nativeReflection = $classReflection->getNativeReflection(); + foreach ($this->additionalConstructors as $additionalConstructor) { + [$className, $methodName] = explode('::', $additionalConstructor); + if (!$nativeReflection->hasMethod($methodName)) { + continue; + } + $nativeMethod = $nativeReflection->getMethod($methodName); + if ($nativeMethod->getDeclaringClass()->getName() !== $nativeReflection->getName()) { + continue; + } + + try { + $prototype = $nativeMethod->getPrototype(); + } catch (ReflectionException) { + $prototype = $nativeMethod; + } + + if ($prototype->getDeclaringClass()->getName() !== $className) { + continue; + } + + $constructors[] = $methodName; + } + + $this->additionalConstructorsCache[$classReflection->getName()] = $constructors; + + return $constructors; + } + +} diff --git a/src/Reflection/Dummy/ChangedTypeMethodReflection.php b/src/Reflection/Dummy/ChangedTypeMethodReflection.php new file mode 100644 index 00000000..d749dc2d --- /dev/null +++ b/src/Reflection/Dummy/ChangedTypeMethodReflection.php @@ -0,0 +1,164 @@ + $variants + * @param list|null $namedArgumentsVariants + */ + public function __construct( + private ClassReflection $declaringClass, + private ExtendedMethodReflection $reflection, + private array $variants, + private ?array $namedArgumentsVariants, + private ?Type $selfOutType, + ) + { + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment(); + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this->reflection->getPrototype(); + } + + public function getVariants(): array + { + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + $variants = $this->getVariants(); + if (count($variants) !== 1) { + throw new ShouldNotHappenException(); + } + + return $variants[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return $this->namedArgumentsVariants; + } + + public function isDeprecated(): TrinaryLogic + { + return $this->reflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->reflection->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return $this->reflection->isFinalByKeyword(); + } + + public function isInternal(): TrinaryLogic + { + return $this->reflection->isInternal(); + } + + public function getThrowType(): ?Type + { + return $this->reflection->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return $this->reflection->hasSideEffects(); + } + + public function getAsserts(): Assertions + { + return $this->reflection->getAsserts(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->reflection->acceptsNamedArguments(); + } + + public function getSelfOutType(): ?Type + { + return $this->selfOutType; + } + + public function returnsByReference(): TrinaryLogic + { + return $this->reflection->returnsByReference(); + } + + public function isAbstract(): TrinaryLogic + { + $abstract = $this->reflection->isAbstract(); + if (is_bool($abstract)) { + return TrinaryLogic::createFromBoolean($abstract); + } + + return $abstract; + } + + public function isPure(): TrinaryLogic + { + return $this->reflection->isPure(); + } + + public function getAttributes(): array + { + return $this->reflection->getAttributes(); + } + +} diff --git a/src/Reflection/Dummy/ChangedTypePropertyReflection.php b/src/Reflection/Dummy/ChangedTypePropertyReflection.php new file mode 100644 index 00000000..ac5e694d --- /dev/null +++ b/src/Reflection/Dummy/ChangedTypePropertyReflection.php @@ -0,0 +1,150 @@ +declaringClass; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment(); + } + + public function hasPhpDocType(): bool + { + return $this->reflection->hasPhpDocType(); + } + + public function getPhpDocType(): Type + { + return $this->phpDocType; + } + + public function hasNativeType(): bool + { + return $this->reflection->hasNativeType(); + } + + public function getNativeType(): Type + { + return $this->nativeType; + } + + public function getReadableType(): Type + { + return $this->readableType; + } + + public function getWritableType(): Type + { + return $this->writableType; + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->reflection->canChangeTypeAfterAssignment(); + } + + public function isReadable(): bool + { + return $this->reflection->isReadable(); + } + + public function isWritable(): bool + { + return $this->reflection->isWritable(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->reflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->reflection->getDeprecatedDescription(); + } + + public function isInternal(): TrinaryLogic + { + return $this->reflection->isInternal(); + } + + public function getOriginalReflection(): ExtendedPropertyReflection + { + return $this->reflection; + } + + public function isAbstract(): TrinaryLogic + { + return $this->reflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->reflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->reflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return $this->reflection->getHook($hookType); + } + + public function isProtectedSet(): bool + { + return $this->reflection->isProtectedSet(); + } + + public function isPrivateSet(): bool + { + return $this->reflection->isPrivateSet(); + } + + public function getAttributes(): array + { + return $this->reflection->getAttributes(); + } + +} diff --git a/src/Reflection/Dummy/DummyClassConstantReflection.php b/src/Reflection/Dummy/DummyClassConstantReflection.php new file mode 100644 index 00000000..bbb505cb --- /dev/null +++ b/src/Reflection/Dummy/DummyClassConstantReflection.php @@ -0,0 +1,115 @@ +getClass(stdClass::class); + } + + public function isFinal(): bool + { + return false; + } + + public function getFileName(): ?string + { + return null; + } + + public function isStatic(): bool + { + return true; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getName(): string + { + return $this->name; + } + + public function getValueType(): Type + { + return new MixedType(); + } + + public function getValueExpr(): Expr + { + return new TypeExpr(new MixedType()); + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): ?Type + { + return null; + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): ?Type + { + return null; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Dummy/DummyConstructorReflection.php b/src/Reflection/Dummy/DummyConstructorReflection.php new file mode 100644 index 00000000..99214a12 --- /dev/null +++ b/src/Reflection/Dummy/DummyConstructorReflection.php @@ -0,0 +1,156 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getName(): string + { + return '__construct'; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + return [ + new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + null, + [], + false, + new VoidType(), + new MixedType(), + new MixedType(), + null, + ), + ]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->declaringClass->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Dummy/DummyMethodReflection.php b/src/Reflection/Dummy/DummyMethodReflection.php new file mode 100644 index 00000000..93b141bd --- /dev/null +++ b/src/Reflection/Dummy/DummyMethodReflection.php @@ -0,0 +1,148 @@ +getClass(stdClass::class); + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getName(): string + { + return $this->name; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + return [ + new TrivialParametersAcceptor(), + ]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Dummy/DummyPropertyReflection.php b/src/Reflection/Dummy/DummyPropertyReflection.php new file mode 100644 index 00000000..41c02924 --- /dev/null +++ b/src/Reflection/Dummy/DummyPropertyReflection.php @@ -0,0 +1,146 @@ +getClass(stdClass::class); + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): Type + { + return new MixedType(); + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return new MixedType(); + } + + public function getWritableType(): Type + { + return new MixedType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + return true; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return true; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/EnumCaseReflection.php b/src/Reflection/EnumCaseReflection.php new file mode 100644 index 00000000..deea18a2 --- /dev/null +++ b/src/Reflection/EnumCaseReflection.php @@ -0,0 +1,68 @@ + $attributes + */ + public function __construct( + private ClassReflection $declaringEnum, + private ReflectionEnumUnitCase|ReflectionEnumBackedCase $reflection, + private ?Type $backingValueType, + private array $attributes, + ) + { + } + + public function getDeclaringEnum(): ClassReflection + { + return $this->declaringEnum; + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getBackingValueType(): ?Type + { + return $this->backingValueType; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + + return null; + } + + /** + * @return list + */ + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php new file mode 100644 index 00000000..02d6eb76 --- /dev/null +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -0,0 +1,84 @@ + $parameters + * @param SimpleThrowPoint[] $throwPoints + * @param SimpleImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + * @param string[] $usedVariables + */ + public function __construct( + TemplateTypeMap $templateTypeMap, + ?TemplateTypeMap $resolvedTemplateTypeMap, + array $parameters, + bool $isVariadic, + Type $returnType, + Type $phpDocReturnType, + Type $nativeReturnType, + ?TemplateTypeVarianceMap $callSiteVarianceMap, + private array $throwPoints, + private TrinaryLogic $isPure, + private array $impurePoints, + private array $invalidateExpressions, + private array $usedVariables, + private TrinaryLogic $acceptsNamedArguments, + ) + { + parent::__construct( + $templateTypeMap, + $resolvedTemplateTypeMap, + $parameters, + $isVariadic, + $returnType, + $phpDocReturnType, + $nativeReturnType, + $callSiteVarianceMap, + ); + } + + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + public function isPure(): TrinaryLogic + { + return $this->isPure; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + + public function getUsedVariables(): array + { + return $this->usedVariables; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->acceptsNamedArguments; + } + +} diff --git a/src/Reflection/ExtendedFunctionVariant.php b/src/Reflection/ExtendedFunctionVariant.php new file mode 100644 index 00000000..3424b544 --- /dev/null +++ b/src/Reflection/ExtendedFunctionVariant.php @@ -0,0 +1,62 @@ + $parameters + * @api + */ + public function __construct( + TemplateTypeMap $templateTypeMap, + ?TemplateTypeMap $resolvedTemplateTypeMap, + array $parameters, + bool $isVariadic, + Type $returnType, + private Type $phpDocReturnType, + private Type $nativeReturnType, + ?TemplateTypeVarianceMap $callSiteVarianceMap = null, + ) + { + parent::__construct( + $templateTypeMap, + $resolvedTemplateTypeMap, + $parameters, + $isVariadic, + $returnType, + $callSiteVarianceMap, + ); + } + + /** + * @return list + */ + public function getParameters(): array + { + /** @var list $parameters */ + $parameters = parent::getParameters(); + + return $parameters; + } + + public function getPhpDocReturnType(): Type + { + return $this->phpDocReturnType; + } + + public function getNativeReturnType(): Type + { + return $this->nativeReturnType; + } + +} diff --git a/src/Reflection/ExtendedMethodReflection.php b/src/Reflection/ExtendedMethodReflection.php new file mode 100644 index 00000000..d34b6530 --- /dev/null +++ b/src/Reflection/ExtendedMethodReflection.php @@ -0,0 +1,67 @@ + + */ + public function getVariants(): array; + + /** + * @internal + */ + public function getOnlyVariant(): ExtendedParametersAcceptor; + + /** + * @return list|null + */ + public function getNamedArgumentsVariants(): ?array; + + public function acceptsNamedArguments(): TrinaryLogic; + + public function getAsserts(): Assertions; + + public function getSelfOutType(): ?Type; + + public function returnsByReference(): TrinaryLogic; + + public function isFinalByKeyword(): TrinaryLogic; + + public function isAbstract(): TrinaryLogic|bool; + + /** + * This indicates whether the method has phpstan-pure + * or phpstan-impure annotation above it. + * + * In most cases asking hasSideEffects() is much more practical + * as it also accounts for void return type (method being always impure). + */ + public function isPure(): TrinaryLogic; + + /** + * @return list + */ + public function getAttributes(): array; + +} diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php new file mode 100644 index 00000000..585b229f --- /dev/null +++ b/src/Reflection/ExtendedParameterReflection.php @@ -0,0 +1,30 @@ + + */ + public function getAttributes(): array; + +} diff --git a/src/Reflection/ExtendedParametersAcceptor.php b/src/Reflection/ExtendedParametersAcceptor.php new file mode 100644 index 00000000..e1fa7d30 --- /dev/null +++ b/src/Reflection/ExtendedParametersAcceptor.php @@ -0,0 +1,24 @@ + + */ + public function getParameters(): array; + + public function getPhpDocReturnType(): Type; + + public function getNativeReturnType(): Type; + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap; + +} diff --git a/src/Reflection/ExtendedPropertyReflection.php b/src/Reflection/ExtendedPropertyReflection.php new file mode 100644 index 00000000..d5029b20 --- /dev/null +++ b/src/Reflection/ExtendedPropertyReflection.php @@ -0,0 +1,63 @@ + + */ + public function getAttributes(): array; + +} diff --git a/src/Reflection/FunctionReflection.php b/src/Reflection/FunctionReflection.php new file mode 100644 index 00000000..777e626e --- /dev/null +++ b/src/Reflection/FunctionReflection.php @@ -0,0 +1,66 @@ + + */ + public function getVariants(): array; + + /** + * @internal + */ + public function getOnlyVariant(): ExtendedParametersAcceptor; + + /** + * @return list|null + */ + public function getNamedArgumentsVariants(): ?array; + + public function acceptsNamedArguments(): TrinaryLogic; + + public function isDeprecated(): TrinaryLogic; + + public function getDeprecatedDescription(): ?string; + + public function isInternal(): TrinaryLogic; + + public function getThrowType(): ?Type; + + public function hasSideEffects(): TrinaryLogic; + + public function isBuiltin(): bool; + + public function getAsserts(): Assertions; + + public function getDocComment(): ?string; + + public function returnsByReference(): TrinaryLogic; + + /** + * This indicates whether the function has phpstan-pure + * or phpstan-impure annotation above it. + * + * In most cases asking hasSideEffects() is much more practical + * as it also accounts for void return type (method being always impure). + */ + public function isPure(): TrinaryLogic; + + /** + * @return list + */ + public function getAttributes(): array; + +} diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php new file mode 100644 index 00000000..05d4c6d9 --- /dev/null +++ b/src/Reflection/FunctionReflectionFactory.php @@ -0,0 +1,41 @@ + $phpDocParameterTypes + * @param array $phpDocParameterOutTypes + * @param array $phpDocParameterImmediatelyInvokedCallable + * @param array $phpDocParameterClosureThisTypes + * @param list $attributes + */ + public function create( + ReflectionFunction $reflection, + TemplateTypeMap $templateTypeMap, + array $phpDocParameterTypes, + ?Type $phpDocReturnType, + ?Type $phpDocThrowType, + ?string $deprecatedDescription, + bool $isDeprecated, + bool $isInternal, + ?string $filename, + ?bool $isPure, + Assertions $asserts, + bool $acceptsNamedArguments, + ?string $phpDocComment, + array $phpDocParameterOutTypes, + array $phpDocParameterImmediatelyInvokedCallable, + array $phpDocParameterClosureThisTypes, + array $attributes, + ): PhpFunctionReflection; + +} diff --git a/src/Reflection/FunctionVariant.php b/src/Reflection/FunctionVariant.php new file mode 100644 index 00000000..d65e0857 --- /dev/null +++ b/src/Reflection/FunctionVariant.php @@ -0,0 +1,67 @@ + $parameters + */ + public function __construct( + private TemplateTypeMap $templateTypeMap, + private ?TemplateTypeMap $resolvedTemplateTypeMap, + private array $parameters, + private bool $isVariadic, + private Type $returnType, + ?TemplateTypeVarianceMap $callSiteVarianceMap = null, + ) + { + $this->callSiteVarianceMap = $callSiteVarianceMap ?? TemplateTypeVarianceMap::createEmpty(); + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->callSiteVarianceMap; + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function isVariadic(): bool + { + return $this->isVariadic; + } + + public function getReturnType(): Type + { + return $this->returnType; + } + +} diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php new file mode 100644 index 00000000..be5681ba --- /dev/null +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -0,0 +1,138 @@ + $argTypes + */ + public static function resolve(array $argTypes, ParametersAcceptor $parametersAcceptor): ExtendedParametersAcceptor + { + $typeMap = TemplateTypeMap::createEmpty(); + $passedArgs = []; + + $parameters = $parametersAcceptor->getParameters(); + $namedArgTypes = []; + foreach ($argTypes as $i => $argType) { + if (is_int($i)) { + if (isset($parameters[$i])) { + $namedArgTypes[$parameters[$i]->getName()] = $argType; + continue; + } + if (count($parameters) > 0) { + $lastParameter = $parameters[count($parameters) - 1]; + if ($lastParameter->isVariadic()) { + $parameterName = $lastParameter->getName(); + if (array_key_exists($parameterName, $namedArgTypes)) { + $namedArgTypes[$parameterName] = TypeCombinator::union($namedArgTypes[$parameterName], $argType); + continue; + } + $namedArgTypes[$parameterName] = $argType; + } + } + continue; + } + + $namedArgTypes[$i] = $argType; + } + + foreach ($parametersAcceptor->getParameters() as $param) { + if (isset($namedArgTypes[$param->getName()])) { + $argType = $namedArgTypes[$param->getName()]; + } elseif ($param->getDefaultValue() !== null) { + $argType = $param->getDefaultValue(); + } else { + continue; + } + + $paramType = $param->getType(); + $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); + $passedArgs['$' . $param->getName()] = $argType; + } + + $returnType = $parametersAcceptor->getReturnType(); + if ( + $returnType instanceof ConditionalTypeForParameter + && !$returnType->isNegated() + && array_key_exists($returnType->getParameterName(), $passedArgs) + ) { + $paramType = $returnType->getTarget(); + $argType = $passedArgs[$returnType->getParameterName()]; + $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); + } + + $resolvedTemplateTypeMap = new TemplateTypeMap(array_merge( + $parametersAcceptor->getTemplateTypeMap()->map(static fn (string $name, Type $type): Type => new ErrorType())->getTypes(), + $typeMap->getTypes(), + )); + + $originalParametersAcceptor = $parametersAcceptor; + + if (!$parametersAcceptor instanceof ExtendedParametersAcceptor) { + $parametersAcceptor = new ExtendedFunctionVariant( + $parametersAcceptor->getTemplateTypeMap(), + $parametersAcceptor->getResolvedTemplateTypeMap(), + array_map(static fn (ParameterReflection $parameter): ExtendedParameterReflection => new ExtendedDummyParameter( + $parameter->getName(), + $parameter->getType(), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + new MixedType(), + $parameter->getType(), + null, + TrinaryLogic::createMaybe(), + null, + [], + ), $parametersAcceptor->getParameters()), + $parametersAcceptor->isVariadic(), + $parametersAcceptor->getReturnType(), + $parametersAcceptor->getReturnType(), + new MixedType(), + TemplateTypeVarianceMap::createEmpty(), + ); + } + + $result = new ResolvedFunctionVariantWithOriginal( + $parametersAcceptor, + $resolvedTemplateTypeMap, + $parametersAcceptor->getCallSiteVarianceMap(), + $passedArgs, + ); + if ($originalParametersAcceptor instanceof CallableParametersAcceptor) { + return new ResolvedFunctionVariantWithCallable( + $result, + $originalParametersAcceptor->getThrowPoints(), + $originalParametersAcceptor->isPure(), + $originalParametersAcceptor->getImpurePoints(), + $originalParametersAcceptor->getInvalidateExpressions(), + $originalParametersAcceptor->getUsedVariables(), + $originalParametersAcceptor->acceptsNamedArguments(), + ); + } + + return $result; + } + +} diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php new file mode 100644 index 00000000..23d823ef --- /dev/null +++ b/src/Reflection/InaccessibleMethod.php @@ -0,0 +1,92 @@ +methodReflection; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return TemplateTypeMap::createEmpty(); + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return TemplateTypeMap::createEmpty(); + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return TemplateTypeVarianceMap::createEmpty(); + } + + public function getParameters(): array + { + return []; + } + + public function isVariadic(): bool + { + return true; + } + + public function getReturnType(): Type + { + return new MixedType(); + } + + public function getThrowPoints(): array + { + return []; + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getImpurePoints(): array + { + return [ + new SimpleImpurePoint( + 'methodCall', + 'call to unknown method', + false, + ), + ]; + } + + public function getInvalidateExpressions(): array + { + return []; + } + + public function getUsedVariables(): array + { + return []; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->methodReflection->acceptsNamedArguments(); + } + +} diff --git a/src/Reflection/InitializerExprContext.php b/src/Reflection/InitializerExprContext.php new file mode 100644 index 00000000..f625465f --- /dev/null +++ b/src/Reflection/InitializerExprContext.php @@ -0,0 +1,251 @@ +getFunction(); + + return new self( + $scope->getFile(), + $scope->getNamespace(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $scope->isInAnonymousFunction() ? '{closure}' : ($function !== null ? $function->getName() : null), + $scope->isInAnonymousFunction() ? '{closure}' : ($function instanceof MethodReflection + ? sprintf('%s::%s', $function->getDeclaringClass()->getName(), $function->getName()) + : ($function instanceof FunctionReflection ? $function->getName() : null)), + $function instanceof PhpMethodFromParserNodeReflection && $function->isPropertyHook() ? $function->getHookedPropertyName() : null, + ); + } + + /** + * @return non-empty-string|null + */ + private static function parseNamespace(string $name): ?string + { + $parts = explode('\\', $name); + if (count($parts) > 1) { + $ns = implode('\\', array_slice($parts, 0, -1)); + if ($ns === '') { + throw new ShouldNotHappenException('Namespace cannot be empty.'); + } + return $ns; + } + + return null; + } + + public static function fromClassReflection(ClassReflection $classReflection): self + { + return self::fromClass($classReflection->getName(), $classReflection->getFileName()); + } + + public static function fromClass(string $className, ?string $fileName): self + { + return new self( + $fileName, + self::parseNamespace($className), + $className, + null, + null, + null, + null, + ); + } + + public static function fromFunction(string $functionName, ?string $fileName): self + { + return new self( + $fileName, + self::parseNamespace($functionName), + null, + null, + $functionName, + $functionName, + null, + ); + } + + public static function fromClassMethod(string $className, ?string $traitName, string $methodName, ?string $fileName): self + { + return new self( + $fileName, + self::parseNamespace($className), + $className, + $traitName, + $methodName, + sprintf('%s::%s', $className, $methodName), + null, + ); + } + + public static function fromReflectionParameter(ReflectionParameter $parameter): self + { + $declaringFunction = $parameter->getDeclaringFunction(); + if ($declaringFunction instanceof ReflectionFunction) { + $file = $declaringFunction->getFileName(); + return new self( + $file === false ? null : $file, + self::parseNamespace($declaringFunction->getName()), + null, + null, + $declaringFunction->getName(), + $declaringFunction->getName(), + null, // Property hook parameter cannot have a default value. fromReflectionParameter is only used for that + ); + } + + $file = $declaringFunction->getFileName(); + + $betterReflection = $declaringFunction->getBetterReflection(); + + return new self( + $file === false ? null : $file, + self::parseNamespace($betterReflection->getDeclaringClass()->getName()), + $declaringFunction->getDeclaringClass()->getName(), + $betterReflection->getDeclaringClass()->isTrait() ? $betterReflection->getDeclaringClass()->getName() : null, + $declaringFunction->getName(), + sprintf('%s::%s', $declaringFunction->getDeclaringClass()->getName(), $declaringFunction->getName()), + null, // Property hook parameter cannot have a default value. fromReflectionParameter is only used for that + ); + } + + public static function fromStubParameter( + ?string $className, + string $stubFile, + ClassMethod|Function_|PropertyHook $function, + ): self + { + $namespace = null; + if ($className !== null) { + $namespace = self::parseNamespace($className); + } else { + if ($function instanceof Function_ && $function->namespacedName !== null) { + $namespace = self::parseNamespace($function->namespacedName->toString()); + } + } + + $functionName = null; + $propertyName = null; + if ($function instanceof Function_ && $function->namespacedName !== null) { + $functionName = $function->namespacedName->toString(); + } elseif ($function instanceof ClassMethod) { + $functionName = $function->name->toString(); + } elseif ($function instanceof PropertyHook) { + $propertyName = $function->getAttribute('propertyName'); + $functionName = sprintf('$%s::%s', $propertyName, $function->name->toString()); + } + + $methodName = null; + if ($function instanceof ClassMethod && $className !== null) { + $methodName = sprintf('%s::%s', $className, $function->name->toString()); + } elseif ($function instanceof PropertyHook) { + $propertyName = $function->getAttribute('propertyName'); + $methodName = sprintf('%s::$%s::%s', $className, $propertyName, $function->name->toString()); + } elseif ($function instanceof Function_ && $function->namespacedName !== null) { + $methodName = $function->namespacedName->toString(); + } + + return new self( + $stubFile, + $namespace, + $className, + null, + $functionName, + $methodName, + $propertyName, + ); + } + + public static function fromGlobalConstant(ReflectionConstant $constant): self + { + return new self( + $constant->getFileName(), + $constant->getNamespaceName(), + null, + null, + null, + null, + null, + ); + } + + public static function createEmpty(): self + { + return new self(null, null, null, null, null, null, null); + } + + public function getFile(): ?string + { + return $this->file; + } + + public function getClassName(): ?string + { + return $this->className; + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + public function getTraitName(): ?string + { + return $this->traitName; + } + + public function getFunction(): ?string + { + return $this->function; + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function getProperty(): ?string + { + return $this->property; + } + +} diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php new file mode 100644 index 00000000..23b511f7 --- /dev/null +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -0,0 +1,2180 @@ + */ + private array $currentlyResolvingClassConstant = []; + + public function __construct( + private ConstantResolver $constantResolver, + private ReflectionProviderProvider $reflectionProviderProvider, + private PhpVersion $phpVersion, + private OperatorTypeSpecifyingExtensionRegistryProvider $operatorTypeSpecifyingExtensionRegistryProvider, + private OversizedArrayBuilder $oversizedArrayBuilder, + private bool $usePathConstantsAsConstantString, + ) + { + } + + /** @api */ + public function getType(Expr $expr, InitializerExprContext $context): Type + { + if ($expr instanceof TypeExpr) { + return $expr->getExprType(); + } + if ($expr instanceof Int_) { + return new ConstantIntegerType($expr->value); + } + if ($expr instanceof Float_) { + return new ConstantFloatType($expr->value); + } + if ($expr instanceof String_) { + return new ConstantStringType($expr->value); + } + if ($expr instanceof ConstFetch) { + $constName = (string) $expr->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } + + $constant = $this->constantResolver->resolveConstant($expr->name, $context); + if ($constant !== null) { + return $constant; + } + + return new ErrorType(); + } + if ($expr instanceof File) { + $file = $context->getFile(); + if ($file === null) { + return new StringType(); + } + $stringType = new ConstantStringType($file); + return $this->usePathConstantsAsConstantString ? $stringType : $stringType->generalize(GeneralizePrecision::moreSpecific()); + } + if ($expr instanceof Dir) { + $file = $context->getFile(); + if ($file === null) { + return new StringType(); + } + $stringType = new ConstantStringType(dirname($file)); + return $this->usePathConstantsAsConstantString ? $stringType : $stringType->generalize(GeneralizePrecision::moreSpecific()); + } + if ($expr instanceof Line) { + return new ConstantIntegerType($expr->getStartLine()); + } + if ($expr instanceof Expr\New_) { + if ($expr->class instanceof Name) { + return new ObjectType((string) $expr->class); + } + + return new ObjectWithoutClassType(); + } + if ($expr instanceof Expr\Array_) { + return $this->getArrayType($expr, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + $var = $this->getType($expr->var, $context); + $dim = $this->getType($expr->dim, $context); + return $var->getOffsetValueType($dim); + } + if ($expr instanceof ClassConstFetch && $expr->name instanceof Identifier) { + return $this->getClassConstFetchType($expr->class, $expr->name->toString(), $context->getClassName(), fn (Expr $expr): Type => $this->getType($expr, $context)); + } + if ($expr instanceof Expr\UnaryPlus) { + return $this->getType($expr->expr, $context)->toNumber(); + } + if ($expr instanceof Expr\UnaryMinus) { + return $this->getUnaryMinusType($expr->expr, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + if ($expr instanceof Expr\BinaryOp\Coalesce) { + $leftType = $this->getType($expr->left, $context); + $rightType = $this->getType($expr->right, $context); + + return TypeCombinator::union(TypeCombinator::removeNull($leftType), $rightType); + } + + if ($expr instanceof Expr\Ternary) { + $condType = $this->getType($expr->cond, $context); + $elseType = $this->getType($expr->else, $context); + if ($expr->if === null) { + return TypeCombinator::union( + TypeCombinator::removeFalsey($condType), + $elseType, + ); + } + + $ifType = $this->getType($expr->if, $context); + + return TypeCombinator::union( + TypeCombinator::removeFalsey($ifType), + $elseType, + ); + } + + if ($expr instanceof Expr\FuncCall && $expr->name instanceof Name && $expr->name->toLowerString() === 'constant') { + $firstArg = $expr->args[0] ?? null; + if ($firstArg instanceof Arg && $firstArg->value instanceof String_) { + $constant = $this->constantResolver->resolvePredefinedConstant($firstArg->value->value); + if ($constant !== null) { + return $constant; + } + } + } + + if ($expr instanceof Expr\BooleanNot) { + $exprBooleanType = $this->getType($expr->expr, $context)->toBoolean(); + + if ($exprBooleanType instanceof ConstantBooleanType) { + return new ConstantBooleanType(!$exprBooleanType->getValue()); + } + + return new BooleanType(); + } + + if ($expr instanceof Expr\BitwiseNot) { + return $this->getBitwiseNotType($expr->expr, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Concat) { + return $this->getConcatType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\BitwiseAnd) { + return $this->getBitwiseAndType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\BitwiseOr) { + return $this->getBitwiseOrType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\BitwiseXor) { + return $this->getBitwiseXorType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Spaceship) { + return $this->getSpaceshipType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ( + $expr instanceof Expr\BinaryOp\BooleanAnd + || $expr instanceof Expr\BinaryOp\LogicalAnd + || $expr instanceof Expr\BinaryOp\BooleanOr + || $expr instanceof Expr\BinaryOp\LogicalOr + ) { + return new BooleanType(); + } + + if ($expr instanceof Expr\BinaryOp\Div) { + return $this->getDivType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Mod) { + return $this->getModType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Plus) { + return $this->getPlusType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Minus) { + return $this->getMinusType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Mul) { + return $this->getMulType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\Pow) { + return $this->getPowType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\ShiftLeft) { + return $this->getShiftLeftType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof Expr\BinaryOp\ShiftRight) { + return $this->getShiftRightType($expr->left, $expr->right, fn (Expr $expr): Type => $this->getType($expr, $context)); + } + + if ($expr instanceof BinaryOp\Identical) { + return $this->resolveIdenticalType( + $this->getType($expr->left, $context), + $this->getType($expr->right, $context), + )->type; + } + + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->getType(new Expr\BooleanNot(new BinaryOp\Identical($expr->left, $expr->right)), $context); + } + + if ($expr instanceof BinaryOp\Equal) { + return $this->resolveEqualType( + $this->getType($expr->left, $context), + $this->getType($expr->right, $context), + )->type; + } + + if ($expr instanceof BinaryOp\NotEqual) { + return $this->getType(new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right)), $context); + } + + if ($expr instanceof Expr\BinaryOp\Smaller) { + return $this->getType($expr->left, $context)->isSmallerThan($this->getType($expr->right, $context), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof Expr\BinaryOp\SmallerOrEqual) { + return $this->getType($expr->left, $context)->isSmallerThanOrEqual($this->getType($expr->right, $context), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof Expr\BinaryOp\Greater) { + return $this->getType($expr->right, $context)->isSmallerThan($this->getType($expr->left, $context), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof Expr\BinaryOp\GreaterOrEqual) { + return $this->getType($expr->right, $context)->isSmallerThanOrEqual($this->getType($expr->left, $context), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof Expr\BinaryOp\LogicalXor) { + $leftBooleanType = $this->getType($expr->left, $context)->toBoolean(); + $rightBooleanType = $this->getType($expr->right, $context)->toBoolean(); + + if ( + $leftBooleanType instanceof ConstantBooleanType + && $rightBooleanType instanceof ConstantBooleanType + ) { + return new ConstantBooleanType( + $leftBooleanType->getValue() xor $rightBooleanType->getValue(), + ); + } + + return new BooleanType(); + } + + if ($expr instanceof MagicConst\Class_) { + if ($context->getTraitName() !== null) { + return TypeCombinator::intersect( + new ClassStringType(), + new AccessoryLiteralStringType(), + ); + } + + if ($context->getClassName() === null) { + return new ConstantStringType(''); + } + + return new ConstantStringType($context->getClassName(), true); + } + + if ($expr instanceof MagicConst\Namespace_) { + if ($context->getTraitName() !== null) { + return TypeCombinator::intersect( + new StringType(), + new AccessoryLiteralStringType(), + ); + } + + return new ConstantStringType($context->getNamespace() ?? ''); + } + + if ($expr instanceof MagicConst\Method) { + return new ConstantStringType($context->getMethod() ?? ''); + } + + if ($expr instanceof MagicConst\Function_) { + return new ConstantStringType($context->getFunction() ?? ''); + } + + if ($expr instanceof MagicConst\Trait_) { + if ($context->getTraitName() === null) { + return new ConstantStringType(''); + } + + return new ConstantStringType($context->getTraitName(), true); + } + + if ($expr instanceof MagicConst\Property) { + $contextProperty = $context->getProperty(); + if ($contextProperty === null) { + return new ConstantStringType(''); + } + + return new ConstantStringType($contextProperty); + } + + if ($expr instanceof PropertyFetch && $expr->name instanceof Identifier) { + $fetchedOnType = $this->getType($expr->var, $context); + if (!$fetchedOnType->hasProperty($expr->name->name)->yes()) { + return new ErrorType(); + } + + return $fetchedOnType->getProperty($expr->name->name, new OutOfClassScope())->getReadableType(); + } + + return new MixedType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getConcatType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + return $this->resolveConcatType($leftType, $rightType); + } + + public function resolveConcatType(Type $left, Type $right): Type + { + $leftStringType = $left->toString(); + $rightStringType = $right->toString(); + if (TypeCombinator::union( + $leftStringType, + $rightStringType, + ) instanceof ErrorType) { + return new ErrorType(); + } + + if ($leftStringType instanceof ConstantStringType && $leftStringType->getValue() === '') { + return $rightStringType; + } + + if ($rightStringType instanceof ConstantStringType && $rightStringType->getValue() === '') { + return $leftStringType; + } + + if ($leftStringType instanceof ConstantStringType && $rightStringType instanceof ConstantStringType) { + return $leftStringType->append($rightStringType); + } + + $leftConstantStrings = $leftStringType->getConstantStrings(); + $rightConstantStrings = $rightStringType->getConstantStrings(); + $combinedConstantStringsCount = count($leftConstantStrings) * count($rightConstantStrings); + + // we limit the number of union-types for performance reasons + if ($combinedConstantStringsCount > 0 && $combinedConstantStringsCount <= 16) { + $strings = []; + + foreach ($leftConstantStrings as $leftConstantString) { + if ($leftConstantString->getValue() === '') { + $strings = array_merge($strings, $rightConstantStrings); + + continue; + } + + foreach ($rightConstantStrings as $rightConstantString) { + if ($rightConstantString->getValue() === '') { + $strings[] = $leftConstantString; + + continue; + } + + $strings[] = $leftConstantString->append($rightConstantString); + } + } + + if (count($strings) > 0) { + return TypeCombinator::union(...$strings); + } + } + + $accessoryTypes = []; + if ($leftStringType->isNonEmptyString()->and($rightStringType->isNonEmptyString())->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($leftStringType->isNonFalsyString()->or($rightStringType->isNonFalsyString())->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($leftStringType->isNonEmptyString()->or($rightStringType->isNonEmptyString())->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + + if ($leftStringType->isLiteralString()->and($rightStringType->isLiteralString())->yes()) { + $accessoryTypes[] = new AccessoryLiteralStringType(); + } + + if ($leftStringType->isLowercaseString()->and($rightStringType->isLowercaseString())->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + + if ($leftStringType->isUppercaseString()->and($rightStringType->isUppercaseString())->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + $leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType('')); + if ($leftNumericStringNonEmpty->isNumericString()->yes()) { + $allRightConstantsZeroOrMore = false; + foreach ($rightConstantStrings as $rightConstantString) { + if ($rightConstantString->getValue() === '') { + continue; + } + + if ( + !is_numeric($rightConstantString->getValue()) + || Strings::match($rightConstantString->getValue(), '#^[0-9]+$#') === null + ) { + $allRightConstantsZeroOrMore = false; + break; + } + + $allRightConstantsZeroOrMore = true; + } + + $zeroOrMoreInteger = IntegerRangeType::fromInterval(0, null); + $nonNegativeRight = $allRightConstantsZeroOrMore || $zeroOrMoreInteger->isSuperTypeOf($right)->yes(); + if ($nonNegativeRight) { + $accessoryTypes[] = new AccessoryNumericStringType(); + } + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + return new IntersectionType($accessoryTypes); + } + + return new StringType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type + { + if (count($expr->items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return $this->oversizedArrayBuilder->build($expr, $getTypeCallback); + } + + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $isList = null; + foreach ($expr->items as $arrayItem) { + $valueType = $getTypeCallback($arrayItem->value); + if ($arrayItem->unpack) { + $constantArrays = $valueType->getConstantArrays(); + if (count($constantArrays) === 1) { + $constantArrayType = $constantArrays[0]; + $hasStringKey = false; + foreach ($constantArrayType->getKeyTypes() as $keyType) { + if ($keyType->isString()->yes()) { + $hasStringKey = true; + break; + } + } + + foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) { + if ($hasStringKey && $this->phpVersion->supportsArrayUnpackingWithStringKeys()) { + $arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i)); + } else { + $arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i)); + } + } + } else { + $arrayBuilder->degradeToGeneralArray(); + + if ($this->phpVersion->supportsArrayUnpackingWithStringKeys() && !$valueType->getIterableKeyType()->isString()->no()) { + $isList = false; + $offsetType = $valueType->getIterableKeyType(); + } else { + $isList ??= $arrayBuilder->isList(); + $offsetType = new IntegerType(); + } + + $arrayBuilder->setOffsetValueType($offsetType, $valueType->getIterableValueType(), !$valueType->isIterableAtLeastOnce()->yes()); + } + } else { + $arrayBuilder->setOffsetValueType( + $arrayItem->key !== null ? $getTypeCallback($arrayItem->key) : null, + $valueType, + ); + } + } + + $arrayType = $arrayBuilder->getArray(); + if ($isList === true) { + return TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + if ($leftTypeInner instanceof ConstantStringType && $rightTypeInner instanceof ConstantStringType) { + $resultType = $this->getTypeFromValue($leftTypeInner->getValue() & $rightTypeInner->getValue()); + } else { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() & $rightNumberType->getValue()); + } + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + if ($leftType->isString()->yes() && $rightType->isString()->yes()) { + return new StringType(); + } + + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if ($rightNumberType instanceof ConstantIntegerType && $rightNumberType->getValue() >= 0) { + return IntegerRangeType::fromInterval(0, $rightNumberType->getValue()); + } + if ($leftNumberType instanceof ConstantIntegerType && $leftNumberType->getValue() >= 0) { + return IntegerRangeType::fromInterval(0, $leftNumberType->getValue()); + } + + return new IntegerType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + if ($leftTypeInner instanceof ConstantStringType && $rightTypeInner instanceof ConstantStringType) { + $resultType = $this->getTypeFromValue($leftTypeInner->getValue() | $rightTypeInner->getValue()); + } else { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() | $rightNumberType->getValue()); + } + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + if ($leftType->isString()->yes() && $rightType->isString()->yes()) { + return new StringType(); + } + + if (TypeCombinator::union($leftType->toNumber(), $rightType->toNumber()) instanceof ErrorType) { + return new ErrorType(); + } + + return new IntegerType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + if ($leftTypeInner instanceof ConstantStringType && $rightTypeInner instanceof ConstantStringType) { + $resultType = $this->getTypeFromValue($leftTypeInner->getValue() ^ $rightTypeInner->getValue()); + } else { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() ^ $rightNumberType->getValue()); + } + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + if ($leftType->isString()->yes() && $rightType->isString()->yes()) { + return new StringType(); + } + + if (TypeCombinator::union($leftType->toNumber(), $rightType->toNumber()) instanceof ErrorType) { + return new ErrorType(); + } + + return new IntegerType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getSpaceshipType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $callbackLeftType = $getTypeCallback($left); + $callbackRightType = $getTypeCallback($right); + + if ($callbackLeftType instanceof NeverType || $callbackRightType instanceof NeverType) { + return $this->getNeverType($callbackLeftType, $callbackRightType); + } + + $leftTypes = $callbackLeftType->getConstantScalarTypes(); + $rightTypes = $callbackRightType->getConstantScalarTypes(); + + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0 && $leftTypesCount * $rightTypesCount <= self::CALCULATE_SCALARS_LIMIT) { + $resultTypes = []; + foreach ($leftTypes as $leftType) { + foreach ($rightTypes as $rightType) { + $leftValue = $leftType->getValue(); + $rightValue = $rightType->getValue(); + $resultType = $this->getTypeFromValue($leftValue <=> $rightValue); + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + return IntegerRangeType::fromInterval(-1, 1); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getDivType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + if (in_array($rightNumberType->getValue(), [0, 0.0], true)) { + return new ErrorType(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() / $rightNumberType->getValue()); // @phpstan-ignore binaryOp.invalid + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + $rightScalarValues = $rightType->toNumber()->getConstantScalarValues(); + foreach ($rightScalarValues as $scalarValue) { + if ($scalarValue === 0 || $scalarValue === 0.0) { + return new ErrorType(); + } + } + + return $this->resolveCommonMath(new BinaryOp\Div($left, $right), $leftType, $rightType); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getModType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + if ($leftType->toNumber() instanceof ErrorType || $rightType->toNumber() instanceof ErrorType) { + return new ErrorType(); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $rightIntegerValue = (int) $rightNumberType->getValue(); + if ($rightIntegerValue === 0) { + return new ErrorType(); + } + + $resultType = $this->getTypeFromValue((int) $leftNumberType->getValue() % $rightIntegerValue); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + return TypeCombinator::union(...$resultTypes); + } + + $integerType = $rightType->toInteger(); + if ($integerType instanceof ConstantIntegerType && $integerType->getValue() === 1) { + return new ConstantIntegerType(0); + } + + $rightScalarValues = $rightType->toNumber()->getConstantScalarValues(); + foreach ($rightScalarValues as $scalarValue) { + + if ($scalarValue === 0 || $scalarValue === 0.0) { + return new ErrorType(); + } + } + + $positiveInt = IntegerRangeType::fromInterval(0, null); + if ($rightType->isInteger()->yes()) { + $rangeMin = null; + $rangeMax = null; + + if ($rightType instanceof IntegerRangeType) { + $rangeMax = $rightType->getMax() !== null ? $rightType->getMax() - 1 : null; + } elseif ($rightType instanceof ConstantIntegerType) { + $rangeMax = $rightType->getValue() - 1; + } elseif ($rightType instanceof UnionType) { + foreach ($rightType->getTypes() as $type) { + if ($type instanceof IntegerRangeType) { + if ($type->getMax() === null) { + $rangeMax = null; + } else { + $rangeMax = max($rangeMax, $type->getMax()); + } + } elseif ($type instanceof ConstantIntegerType) { + $rangeMax = max($rangeMax, $type->getValue() - 1); + } + } + } + + if ($positiveInt->isSuperTypeOf($leftType)->yes()) { + $rangeMin = 0; + } elseif ($rangeMax !== null) { + $rangeMin = $rangeMax * -1; + } + + return IntegerRangeType::fromInterval($rangeMin, $rangeMax); + } elseif ($positiveInt->isSuperTypeOf($leftType)->yes()) { + return IntegerRangeType::fromInterval(0, null); + } + + return new IntegerType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getPlusType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() + $rightNumberType->getValue()); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + + return TypeCombinator::union(...$resultTypes); + } + + $leftConstantArrays = $leftType->getConstantArrays(); + $rightConstantArrays = $rightType->getConstantArrays(); + + $leftCount = count($leftConstantArrays); + $rightCount = count($rightConstantArrays); + if ($leftCount > 0 && $rightCount > 0 + && ($leftCount + $rightCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT)) { + $resultTypes = []; + foreach ($rightConstantArrays as $rightConstantArray) { + foreach ($leftConstantArrays as $leftConstantArray) { + $newArrayBuilder = ConstantArrayTypeBuilder::createFromConstantArray($rightConstantArray); + foreach ($leftConstantArray->getKeyTypes() as $i => $leftKeyType) { + $optional = $leftConstantArray->isOptionalKey($i); + $valueType = $leftConstantArray->getOffsetValueType($leftKeyType); + if (!$optional) { + if ($rightConstantArray->hasOffsetValueType($leftKeyType)->maybe()) { + $valueType = TypeCombinator::union($valueType, $rightConstantArray->getOffsetValueType($leftKeyType)); + } + } + $newArrayBuilder->setOffsetValueType( + $leftKeyType, + $valueType, + $optional, + ); + } + $resultTypes[] = $newArrayBuilder->getArray(); + } + } + return TypeCombinator::union(...$resultTypes); + } + + $leftIsArray = $leftType->isArray(); + $rightIsArray = $rightType->isArray(); + if ($leftIsArray->yes() && $rightIsArray->yes()) { + if ($leftType->getIterableKeyType()->equals($rightType->getIterableKeyType())) { + // to preserve BenevolentUnionType + $keyType = $leftType->getIterableKeyType(); + } else { + $keyTypes = []; + foreach ([ + $leftType->getIterableKeyType(), + $rightType->getIterableKeyType(), + ] as $keyType) { + $keyTypes[] = $keyType; + } + $keyType = TypeCombinator::union(...$keyTypes); + } + + $arrayType = new ArrayType( + $keyType, + TypeCombinator::union($leftType->getIterableValueType(), $rightType->getIterableValueType()), + ); + + if ($leftType->isIterableAtLeastOnce()->yes() || $rightType->isIterableAtLeastOnce()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($leftType->isList()->yes() && $rightType->isList()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + + if ($leftType instanceof MixedType && $rightType instanceof MixedType) { + if ( + ($leftIsArray->no() && $rightIsArray->no()) + ) { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + new ArrayType(new MixedType(), new MixedType()), + ]); + } + + if ( + ($leftIsArray->yes() && $rightIsArray->no()) + || ($leftIsArray->no() && $rightIsArray->yes()) + ) { + return new ErrorType(); + } + + if ( + ($leftIsArray->yes() && $rightIsArray->maybe()) + || ($leftIsArray->maybe() && $rightIsArray->yes()) + ) { + $resultType = new ArrayType(new MixedType(), new MixedType()); + if ($leftType->isIterableAtLeastOnce()->yes() || $rightType->isIterableAtLeastOnce()->yes()) { + return TypeCombinator::intersect($resultType, new NonEmptyArrayType()); + } + + return $resultType; + } + + if ($leftIsArray->maybe() && $rightIsArray->maybe()) { + $plusable = new UnionType([ + new StringType(), + new FloatType(), + new IntegerType(), + new ArrayType(new MixedType(), new MixedType()), + new BooleanType(), + ]); + + $plusableSuperTypeOfLeft = $plusable->isSuperTypeOf($leftType)->yes(); + $plusableSuperTypeOfRight = $plusable->isSuperTypeOf($rightType)->yes(); + if ($plusableSuperTypeOfLeft && $plusableSuperTypeOfRight) { + return TypeCombinator::union($leftType, $rightType); + } + if ($plusableSuperTypeOfLeft && $rightType instanceof MixedType) { + return $leftType; + } + if ($plusableSuperTypeOfRight && $leftType instanceof MixedType) { + return $rightType; + } + } + + return $this->resolveCommonMath(new BinaryOp\Plus($left, $right), $leftType, $rightType); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getMinusType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() - $rightNumberType->getValue()); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + + return TypeCombinator::union(...$resultTypes); + } + + return $this->resolveCommonMath(new BinaryOp\Minus($left, $right), $leftType, $rightType); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getMulType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + $resultType = $this->getTypeFromValue($leftNumberType->getValue() * $rightNumberType->getValue()); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + + return TypeCombinator::union(...$resultTypes); + } + + $leftNumberType = $leftType->toNumber(); + if ($leftNumberType instanceof ConstantIntegerType && $leftNumberType->getValue() === 0) { + if ($rightType->isFloat()->yes()) { + return new ConstantFloatType(0.0); + } + return new ConstantIntegerType(0); + } + $rightNumberType = $rightType->toNumber(); + if ($rightNumberType instanceof ConstantIntegerType && $rightNumberType->getValue() === 0) { + if ($leftType->isFloat()->yes()) { + return new ConstantFloatType(0.0); + } + return new ConstantIntegerType(0); + } + + return $this->resolveCommonMath(new BinaryOp\Mul($left, $right), $leftType, $rightType); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getPowType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + $exponentiatedTyped = $leftType->exponentiate($rightType); + if (!$exponentiatedTyped instanceof ErrorType) { + return $exponentiatedTyped; + } + + $extensionSpecified = $this->callOperatorTypeSpecifyingExtensions(new BinaryOp\Pow($left, $right), $leftType, $rightType); + if ($extensionSpecified !== null) { + return $extensionSpecified; + } + + return new ErrorType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getShiftLeftType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + if ($rightNumberType->getValue() < 0) { + return new ErrorType(); + } + + $resultType = $this->getTypeFromValue(intval($leftNumberType->getValue()) << intval($rightNumberType->getValue())); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + + return TypeCombinator::union(...$resultTypes); + } + + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + return $this->resolveCommonMath(new Expr\BinaryOp\ShiftLeft($left, $right), $leftType, $rightType); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getShiftRightType(Expr $left, Expr $right, callable $getTypeCallback): Type + { + $leftType = $getTypeCallback($left); + $rightType = $getTypeCallback($right); + + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return $this->getNeverType($leftType, $rightType); + } + + $leftTypes = $leftType->getConstantScalarTypes(); + $rightTypes = $rightType->getConstantScalarTypes(); + $leftTypesCount = count($leftTypes); + $rightTypesCount = count($rightTypes); + if ($leftTypesCount > 0 && $rightTypesCount > 0) { + $resultTypes = []; + $generalize = $leftTypesCount * $rightTypesCount > self::CALCULATE_SCALARS_LIMIT; + foreach ($leftTypes as $leftTypeInner) { + foreach ($rightTypes as $rightTypeInner) { + $leftNumberType = $leftTypeInner->toNumber(); + $rightNumberType = $rightTypeInner->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + if (!$leftNumberType instanceof ConstantScalarType || !$rightNumberType instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + if ($rightNumberType->getValue() < 0) { + return new ErrorType(); + } + + $resultType = $this->getTypeFromValue(intval($leftNumberType->getValue()) >> intval($rightNumberType->getValue())); + if ($generalize) { + $resultType = $resultType->generalize(GeneralizePrecision::lessSpecific()); + } + $resultTypes[] = $resultType; + } + } + + return TypeCombinator::union(...$resultTypes); + } + + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + + return $this->resolveCommonMath(new Expr\BinaryOp\ShiftRight($left, $right), $leftType, $rightType); + } + + /** + * @return TypeResult + */ + public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResult + { + if ($leftType instanceof NeverType || $rightType instanceof NeverType) { + return new TypeResult(new ConstantBooleanType(false), []); + } + + if ($leftType instanceof ConstantScalarType && $rightType instanceof ConstantScalarType) { + return new TypeResult(new ConstantBooleanType($leftType->getValue() === $rightType->getValue()), []); + } + + $leftTypeFiniteTypes = $leftType->getFiniteTypes(); + $rightTypeFiniteType = $rightType->getFiniteTypes(); + if (count($leftTypeFiniteTypes) === 1 && count($rightTypeFiniteType) === 1) { + return new TypeResult(new ConstantBooleanType($leftTypeFiniteTypes[0]->equals($rightTypeFiniteType[0])), []); + } + + $leftIsSuperTypeOfRight = $leftType->isSuperTypeOf($rightType); + $rightIsSuperTypeOfLeft = $rightType->isSuperTypeOf($leftType); + if ($leftIsSuperTypeOfRight->no() && $rightIsSuperTypeOfLeft->no()) { + return new TypeResult(new ConstantBooleanType(false), array_merge($leftIsSuperTypeOfRight->reasons, $rightIsSuperTypeOfLeft->reasons)); + } + + if ($leftType instanceof ConstantArrayType && $rightType instanceof ConstantArrayType) { + return $this->resolveConstantArrayTypeComparison($leftType, $rightType, fn ($leftValueType, $rightValueType): TypeResult => $this->resolveIdenticalType($leftValueType, $rightValueType)); + } + + return new TypeResult(new BooleanType(), []); + } + + /** + * @return TypeResult + */ + public function resolveEqualType(Type $leftType, Type $rightType): TypeResult + { + if ( + ($leftType->isEnum()->yes() && $rightType->isTrue()->no()) + || ($rightType->isEnum()->yes() && $leftType->isTrue()->no()) + ) { + return $this->resolveIdenticalType($leftType, $rightType); + } + + if ($leftType instanceof ConstantArrayType && $rightType instanceof ConstantArrayType) { + return $this->resolveConstantArrayTypeComparison($leftType, $rightType, fn ($leftValueType, $rightValueType): TypeResult => $this->resolveEqualType($leftValueType, $rightValueType)); + } + + return new TypeResult($leftType->looseCompare($rightType, $this->phpVersion), []); + } + + /** + * @param callable(Type, Type): TypeResult $valueComparisonCallback + * @return TypeResult + */ + private function resolveConstantArrayTypeComparison(ConstantArrayType $leftType, ConstantArrayType $rightType, callable $valueComparisonCallback): TypeResult + { + $leftKeyTypes = $leftType->getKeyTypes(); + $rightKeyTypes = $rightType->getKeyTypes(); + $leftValueTypes = $leftType->getValueTypes(); + $rightValueTypes = $rightType->getValueTypes(); + + $resultType = new ConstantBooleanType(true); + + foreach ($leftKeyTypes as $i => $leftKeyType) { + $leftOptional = $leftType->isOptionalKey($i); + if ($leftOptional) { + $resultType = new BooleanType(); + } + + if (count($rightKeyTypes) === 0) { + if (!$leftOptional) { + return new TypeResult(new ConstantBooleanType(false), []); + } + continue; + } + + $found = false; + foreach ($rightKeyTypes as $j => $rightKeyType) { + unset($rightKeyTypes[$j]); + + if ($leftKeyType->equals($rightKeyType)) { + $found = true; + break; + } elseif (!$rightType->isOptionalKey($j)) { + return new TypeResult(new ConstantBooleanType(false), []); + } + } + + if (!$found) { + if (!$leftOptional) { + return new TypeResult(new ConstantBooleanType(false), []); + } + continue; + } + + if (!isset($j)) { + throw new ShouldNotHappenException(); + } + + $rightOptional = $rightType->isOptionalKey($j); + if ($rightOptional) { + $resultType = new BooleanType(); + if ($leftOptional) { + continue; + } + } + + $leftIdenticalToRightResult = $valueComparisonCallback($leftValueTypes[$i], $rightValueTypes[$j]); + $leftIdenticalToRight = $leftIdenticalToRightResult->type; + if ($leftIdenticalToRight->isFalse()->yes()) { + return $leftIdenticalToRightResult; + } + $resultType = TypeCombinator::union($resultType, $leftIdenticalToRight); + } + + foreach (array_keys($rightKeyTypes) as $j) { + if (!$rightType->isOptionalKey($j)) { + return new TypeResult(new ConstantBooleanType(false), []); + } + $resultType = new BooleanType(); + } + + return new TypeResult($resultType->toBoolean(), []); + } + + private function callOperatorTypeSpecifyingExtensions(Expr\BinaryOp $expr, Type $leftType, Type $rightType): ?Type + { + $operatorSigil = $expr->getOperatorSigil(); + $operatorTypeSpecifyingExtensions = $this->operatorTypeSpecifyingExtensionRegistryProvider->getRegistry()->getOperatorTypeSpecifyingExtensions($operatorSigil, $leftType, $rightType); + + /** @var Type[] $extensionTypes */ + $extensionTypes = []; + + foreach ($operatorTypeSpecifyingExtensions as $extension) { + $extensionTypes[] = $extension->specifyType($operatorSigil, $leftType, $rightType); + } + + if (count($extensionTypes) > 0) { + return TypeCombinator::union(...$extensionTypes); + } + + return null; + } + + /** + * @param BinaryOp\Plus|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Div|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $expr + */ + private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $rightType): Type + { + $types = TypeCombinator::union($leftType, $rightType); + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); + + if ( + !$types instanceof MixedType + && ( + $rightNumberType instanceof IntegerRangeType + || $rightNumberType instanceof ConstantIntegerType + || $rightNumberType instanceof UnionType + ) + ) { + if ($leftNumberType instanceof IntegerRangeType || $leftNumberType instanceof ConstantIntegerType) { + return $this->integerRangeMath( + $leftNumberType, + $expr, + $rightNumberType, + ); + } elseif ($leftNumberType instanceof UnionType) { + $unionParts = []; + + foreach ($leftNumberType->getTypes() as $type) { + $numberType = $type->toNumber(); + if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { + $unionParts[] = $this->integerRangeMath($numberType, $expr, $rightNumberType); + } else { + $unionParts[] = $numberType; + } + } + + $union = TypeCombinator::union(...$unionParts); + if ($leftNumberType instanceof BenevolentUnionType) { + return TypeUtils::toBenevolentUnion($union)->toNumber(); + } + + return $union->toNumber(); + } + } + + $specifiedTypes = $this->callOperatorTypeSpecifyingExtensions($expr, $leftType, $rightType); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + + if ( + $leftType->isArray()->yes() + || $rightType->isArray()->yes() + || $types->isArray()->yes() + ) { + return new ErrorType(); + } + + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + return new ErrorType(); + } + if ($leftNumberType instanceof NeverType || $rightNumberType instanceof NeverType) { + return $this->getNeverType($leftNumberType, $rightNumberType); + } + + if ( + $leftNumberType->isFloat()->yes() + || $rightNumberType->isFloat()->yes() + ) { + if ($expr instanceof Expr\BinaryOp\ShiftLeft || $expr instanceof Expr\BinaryOp\ShiftRight) { + return new IntegerType(); + } + return new FloatType(); + } + + $resultType = TypeCombinator::union($leftNumberType, $rightNumberType); + if ($expr instanceof Expr\BinaryOp\Div) { + if ($types instanceof MixedType || $resultType->isInteger()->yes()) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return new UnionType([new IntegerType(), new FloatType()]); + } + + if ($types instanceof MixedType + || $leftType instanceof BenevolentUnionType + || $rightType instanceof BenevolentUnionType + ) { + return TypeUtils::toBenevolentUnion($resultType); + } + + return $resultType; + } + + /** + * @param ConstantIntegerType|IntegerRangeType $range + * @param BinaryOp\Div|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Plus|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $node + */ + private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): Type + { + if ($range instanceof IntegerRangeType) { + $rangeMin = $range->getMin(); + $rangeMax = $range->getMax(); + } else { + $rangeMin = $range->getValue(); + $rangeMax = $rangeMin; + } + + if ($operand instanceof UnionType) { + + $unionParts = []; + + foreach ($operand->getTypes() as $type) { + $numberType = $type->toNumber(); + if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { + $unionParts[] = $this->integerRangeMath($range, $node, $numberType); + } else { + $unionParts[] = $type->toNumber(); + } + } + + $union = TypeCombinator::union(...$unionParts); + if ($operand instanceof BenevolentUnionType) { + return TypeUtils::toBenevolentUnion($union)->toNumber(); + } + + return $union->toNumber(); + } + + $operand = $operand->toNumber(); + if ($operand instanceof IntegerRangeType) { + $operandMin = $operand->getMin(); + $operandMax = $operand->getMax(); + } elseif ($operand instanceof ConstantIntegerType) { + $operandMin = $operand->getValue(); + $operandMax = $operand->getValue(); + } else { + return $operand; + } + + if ($node instanceof BinaryOp\Plus) { + if ($operand instanceof ConstantIntegerType) { + /** @var int|float|null $min */ + $min = $rangeMin !== null ? $rangeMin + $operand->getValue() : null; + + /** @var int|float|null $max */ + $max = $rangeMax !== null ? $rangeMax + $operand->getValue() : null; + } else { + /** @var int|float|null $min */ + $min = $rangeMin !== null && $operand->getMin() !== null ? $rangeMin + $operand->getMin() : null; + + /** @var int|float|null $max */ + $max = $rangeMax !== null && $operand->getMax() !== null ? $rangeMax + $operand->getMax() : null; + } + } elseif ($node instanceof BinaryOp\Minus) { + if ($operand instanceof ConstantIntegerType) { + /** @var int|float|null $min */ + $min = $rangeMin !== null ? $rangeMin - $operand->getValue() : null; + + /** @var int|float|null $max */ + $max = $rangeMax !== null ? $rangeMax - $operand->getValue() : null; + } else { + if ($rangeMin === $rangeMax && $rangeMin !== null + && ($operand->getMin() === null || $operand->getMax() === null)) { + $min = null; + $max = $rangeMin; + } else { + if ($operand->getMin() === null) { + $min = null; + } elseif ($rangeMin !== null) { + if ($operand->getMax() !== null) { + /** @var int|float $min */ + $min = $rangeMin - $operand->getMax(); + } else { + /** @var int|float $min */ + $min = $rangeMin - $operand->getMin(); + } + } else { + $min = null; + } + + if ($operand->getMax() === null) { + $min = null; + $max = null; + } elseif ($rangeMax !== null) { + if ($rangeMin !== null && $operand->getMin() === null) { + /** @var int|float $min */ + $min = $rangeMin - $operand->getMax(); + $max = null; + } elseif ($operand->getMin() !== null) { + /** @var int|float $max */ + $max = $rangeMax - $operand->getMin(); + } else { + $max = null; + } + } else { + $max = null; + } + + if ($min !== null && $max !== null && $min > $max) { + [$min, $max] = [$max, $min]; + } + } + } + } elseif ($node instanceof Expr\BinaryOp\Mul) { + $min1 = $rangeMin === 0 || $operandMin === 0 ? 0 : ($rangeMin ?? -INF) * ($operandMin ?? -INF); + $min2 = $rangeMin === 0 || $operandMax === 0 ? 0 : ($rangeMin ?? -INF) * ($operandMax ?? INF); + $max1 = $rangeMax === 0 || $operandMin === 0 ? 0 : ($rangeMax ?? INF) * ($operandMin ?? -INF); + $max2 = $rangeMax === 0 || $operandMax === 0 ? 0 : ($rangeMax ?? INF) * ($operandMax ?? INF); + + $min = min($min1, $min2, $max1, $max2); + $max = max($min1, $min2, $max1, $max2); + + if (!is_finite($min)) { + $min = null; + } + if (!is_finite($max)) { + $max = null; + } + } elseif ($node instanceof Expr\BinaryOp\Div) { + if ($operand instanceof ConstantIntegerType) { + $min = $rangeMin !== null && $operand->getValue() !== 0 ? $rangeMin / $operand->getValue() : null; + $max = $rangeMax !== null && $operand->getValue() !== 0 ? $rangeMax / $operand->getValue() : null; + } else { + // Avoid division by zero when looking for the min and the max by using the closest int + $operandMin = $operandMin !== 0 ? $operandMin : 1; + $operandMax = $operandMax !== 0 ? $operandMax : -1; + + if ( + ($operandMin < 0 || $operandMin === null) + && ($operandMax > 0 || $operandMax === null) + ) { + $negativeOperand = IntegerRangeType::fromInterval($operandMin, 0); + assert($negativeOperand instanceof IntegerRangeType); + $positiveOperand = IntegerRangeType::fromInterval(0, $operandMax); + assert($positiveOperand instanceof IntegerRangeType); + + $result = TypeCombinator::union( + $this->integerRangeMath($range, $node, $negativeOperand), + $this->integerRangeMath($range, $node, $positiveOperand), + )->toNumber(); + + if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return $result; + } + if ( + ($rangeMin < 0 || $rangeMin === null) + && ($rangeMax > 0 || $rangeMax === null) + ) { + $negativeRange = IntegerRangeType::fromInterval($rangeMin, 0); + assert($negativeRange instanceof IntegerRangeType); + $positiveRange = IntegerRangeType::fromInterval(0, $rangeMax); + assert($positiveRange instanceof IntegerRangeType); + + $result = TypeCombinator::union( + $this->integerRangeMath($negativeRange, $node, $operand), + $this->integerRangeMath($positiveRange, $node, $operand), + )->toNumber(); + + if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return $result; + } + + $rangeMinSign = ($rangeMin ?? -INF) <=> 0; + $rangeMaxSign = ($rangeMax ?? INF) <=> 0; + + $min1 = $operandMin !== null ? ($rangeMin ?? -INF) / $operandMin : $rangeMinSign * -0.1; + $min2 = $operandMax !== null ? ($rangeMin ?? -INF) / $operandMax : $rangeMinSign * 0.1; + $max1 = $operandMin !== null ? ($rangeMax ?? INF) / $operandMin : $rangeMaxSign * -0.1; + $max2 = $operandMax !== null ? ($rangeMax ?? INF) / $operandMax : $rangeMaxSign * 0.1; + + $min = min($min1, $min2, $max1, $max2); + $max = max($min1, $min2, $max1, $max2); + + if ($min === -INF) { + $min = null; + } + if ($max === INF) { + $max = null; + } + } + + if ($min !== null && $max !== null && $min > $max) { + [$min, $max] = [$max, $min]; + } + + if (is_float($min)) { + $min = (int) ceil($min); + } + if (is_float($max)) { + $max = (int) floor($max); + } + + // invert maximas on division with negative constants + if ((($range instanceof ConstantIntegerType && $range->getValue() < 0) + || ($operand instanceof ConstantIntegerType && $operand->getValue() < 0)) + && ($min === null || $max === null)) { + [$min, $max] = [$max, $min]; + } + + if ($min === null && $max === null) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return TypeCombinator::union(IntegerRangeType::fromInterval($min, $max), new FloatType()); + } elseif ($node instanceof Expr\BinaryOp\ShiftLeft) { + if (!$operand instanceof ConstantIntegerType) { + return new IntegerType(); + } + if ($operand->getValue() < 0) { + return new ErrorType(); + } + $min = $rangeMin !== null ? intval($rangeMin) << $operand->getValue() : null; + $max = $rangeMax !== null ? intval($rangeMax) << $operand->getValue() : null; + } elseif ($node instanceof Expr\BinaryOp\ShiftRight) { + if (!$operand instanceof ConstantIntegerType) { + return new IntegerType(); + } + if ($operand->getValue() < 0) { + return new ErrorType(); + } + $min = $rangeMin !== null ? intval($rangeMin) >> $operand->getValue() : null; + $max = $rangeMax !== null ? intval($rangeMax) >> $operand->getValue() : null; + } else { + throw new ShouldNotHappenException(); + } + + if (is_float($min)) { + $min = null; + } + if (is_float($max)) { + $max = null; + } + + return IntegerRangeType::fromInterval($min, $max); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getClassConstFetchTypeByReflection(Name|Expr $class, string $constantName, ?ClassReflection $classReflection, callable $getTypeCallback): Type + { + $isObject = false; + if ($class instanceof Name) { + $constantClass = (string) $class; + $constantClassType = new ObjectType($constantClass); + $namesToResolve = [ + 'self', + 'parent', + ]; + if ($classReflection !== null) { + if ($classReflection->isFinal()) { + $namesToResolve[] = 'static'; + } elseif (strtolower($constantClass) === 'static') { + if (strtolower($constantName) === 'class') { + return new GenericClassStringType(new StaticType($classReflection)); + } + + $namesToResolve[] = 'static'; + $isObject = true; + } + } + if (in_array(strtolower($constantClass), $namesToResolve, true)) { + $resolvedName = $this->resolveName($class, $classReflection); + if (strtolower($resolvedName) === 'parent' && strtolower($constantName) === 'class') { + return new ClassStringType(); + } + $constantClassType = $this->resolveTypeByName($class, $classReflection); + } + + if (strtolower($constantName) === 'class') { + return new ConstantStringType($constantClassType->getClassName(), true); + } + } elseif ($class instanceof String_ && strtolower($constantName) === 'class') { + return new ConstantStringType($class->value, true); + } else { + $constantClassType = $getTypeCallback($class); + $isObject = true; + } + + if (strtolower($constantName) === 'class') { + return TypeTraverser::map( + $constantClassType, + function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof NullType) { + return $type; + } + + if ($type instanceof EnumCaseObjectType) { + return TypeCombinator::intersect( + new GenericClassStringType(new ObjectType($type->getClassName())), + new AccessoryLiteralStringType(), + ); + } + + $objectClassNames = $type->getObjectClassNames(); + if (count($objectClassNames) > 1) { + throw new ShouldNotHappenException(); + } + + if ($type instanceof TemplateType && $objectClassNames === []) { + return TypeCombinator::intersect( + new GenericClassStringType($type), + new AccessoryLiteralStringType(), + ); + } elseif ($objectClassNames !== [] && $this->getReflectionProvider()->hasClass($objectClassNames[0])) { + $reflection = $this->getReflectionProvider()->getClass($objectClassNames[0]); + if ($reflection->isFinalByKeyword()) { + return new ConstantStringType($reflection->getName(), true); + } + + return TypeCombinator::intersect( + new GenericClassStringType($type), + new AccessoryLiteralStringType(), + ); + } elseif ($type->isObject()->yes()) { + return TypeCombinator::intersect( + new ClassStringType(), + new AccessoryLiteralStringType(), + ); + } + + return new ErrorType(); + }, + ); + } + + if ($constantClassType->isClassString()->yes()) { + if ($constantClassType->isConstantScalarValue()->yes()) { + $isObject = false; + } + $constantClassType = $constantClassType->getClassStringObjectType(); + } + + $types = []; + foreach ($constantClassType->getObjectClassNames() as $referencedClass) { + if (!$this->getReflectionProvider()->hasClass($referencedClass)) { + continue; + } + + $constantClassReflection = $this->getReflectionProvider()->getClass($referencedClass); + if (!$constantClassReflection->hasConstant($constantName)) { + continue; + } + + if ($constantClassReflection->isEnum() && $constantClassReflection->hasEnumCase($constantName)) { + $types[] = new EnumCaseObjectType($constantClassReflection->getName(), $constantName); + continue; + } + + $resolvingName = sprintf('%s::%s', $constantClassReflection->getName(), $constantName); + if (array_key_exists($resolvingName, $this->currentlyResolvingClassConstant)) { + $types[] = new MixedType(); + continue; + } + + $this->currentlyResolvingClassConstant[$resolvingName] = true; + + if (!$isObject) { + $reflectionConstant = $constantClassReflection->getNativeReflection()->getReflectionConstant($constantName); + if ($reflectionConstant === false) { + unset($this->currentlyResolvingClassConstant[$resolvingName]); + continue; + } + $reflectionConstantDeclaringClass = $reflectionConstant->getDeclaringClass(); + $constantType = $this->getType($reflectionConstant->getValueExpression(), InitializerExprContext::fromClass($reflectionConstantDeclaringClass->getName(), $reflectionConstantDeclaringClass->getFileName() ?: null)); + $nativeType = null; + if ($reflectionConstant->getType() !== null) { + $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType(), null, $constantClassReflection); + } + $types[] = $this->constantResolver->resolveClassConstantType( + $constantClassReflection->getName(), + $constantName, + $constantType, + $nativeType, + ); + unset($this->currentlyResolvingClassConstant[$resolvingName]); + continue; + } + + $constantReflection = $constantClassReflection->getConstant($constantName); + if ( + !$constantClassReflection->isFinal() + && !$constantReflection->isFinal() + && !$constantReflection->hasPhpDocType() + && !$constantReflection->hasNativeType() + ) { + unset($this->currentlyResolvingClassConstant[$resolvingName]); + return new MixedType(); + } + + if (!$constantClassReflection->isFinal()) { + $constantType = $constantReflection->getValueType(); + } else { + $constantType = $this->getType($constantReflection->getValueExpr(), InitializerExprContext::fromClassReflection($constantReflection->getDeclaringClass())); + } + + $nativeType = $constantReflection->getNativeType(); + $constantType = $this->constantResolver->resolveClassConstantType( + $constantClassReflection->getName(), + $constantName, + $constantType, + $nativeType, + ); + unset($this->currentlyResolvingClassConstant[$resolvingName]); + $types[] = $constantType; + } + + if (count($types) > 0) { + return TypeCombinator::union(...$types); + } + + if (!$constantClassType->hasConstant($constantName)->yes()) { + return new ErrorType(); + } + + return $constantClassType->getConstant($constantName)->getValueType(); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getClassConstFetchType(Name|Expr $class, string $constantName, ?string $className, callable $getTypeCallback): Type + { + $classReflection = null; + if ($className !== null && $this->getReflectionProvider()->hasClass($className)) { + $classReflection = $this->getReflectionProvider()->getClass($className); + } + + return $this->getClassConstFetchTypeByReflection($class, $constantName, $classReflection, $getTypeCallback); + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getUnaryMinusType(Expr $expr, callable $getTypeCallback): Type + { + $type = $getTypeCallback($expr)->toNumber(); + $scalarValues = $type->getConstantScalarValues(); + + if (count($scalarValues) > 0) { + $newTypes = []; + foreach ($scalarValues as $scalarValue) { + if (is_int($scalarValue)) { + /** @var int|float $newValue */ + $newValue = -$scalarValue; + if (!is_int($newValue)) { + return $type; + } + $newTypes[] = new ConstantIntegerType($newValue); + } elseif (is_float($scalarValue)) { + $newTypes[] = new ConstantFloatType(-$scalarValue); + } + } + + return TypeCombinator::union(...$newTypes); + } + + if ($type instanceof IntegerRangeType) { + return $getTypeCallback(new Expr\BinaryOp\Mul($expr, new Int_(-1))); + } + + return $type; + } + + /** + * @param callable(Expr): Type $getTypeCallback + */ + public function getBitwiseNotType(Expr $expr, callable $getTypeCallback): Type + { + $exprType = $getTypeCallback($expr); + return TypeTraverser::map($exprType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type instanceof ConstantStringType) { + return new ConstantStringType(~$type->getValue()); + } + if ($type->isString()->yes()) { + $accessories = [ + new StringType(), + ]; + if ($type->isNonEmptyString()->yes()) { + $accessories[] = new AccessoryNonEmptyStringType(); + } + // it is not useful to apply numeric and literal strings here. + // numeric string isn't certainly kept numeric: 3v4l.org/JERDB + + return TypeCombinator::intersect(...$accessories); + } + if ($type->isInteger()->yes() || $type->isFloat()->yes()) { + return new IntegerType(); //no const types here, result depends on PHP_INT_SIZE + } + return new ErrorType(); + }); + } + + private function resolveName(Name $name, ?ClassReflection $classReflection): string + { + $originalClass = (string) $name; + if ($classReflection !== null) { + $lowerClass = strtolower($originalClass); + + if (in_array($lowerClass, [ + 'self', + 'static', + ], true)) { + return $classReflection->getName(); + } elseif ($lowerClass === 'parent') { + if ($classReflection->getParentClass() !== null) { + return $classReflection->getParentClass()->getName(); + } + } + } + + return $originalClass; + } + + private function resolveTypeByName(Name $name, ?ClassReflection $classReflection): TypeWithClassName + { + if ($name->toLowerString() === 'static' && $classReflection !== null) { + return new StaticType($classReflection); + } + + $originalClass = $this->resolveName($name, $classReflection); + if ($classReflection !== null) { + $thisType = new ThisType($classReflection); + $ancestor = $thisType->getAncestorWithClassName($originalClass); + if ($ancestor !== null) { + return $ancestor; + } + } + + return new ObjectType($originalClass); + } + + /** + * @param mixed $value + */ + private function getTypeFromValue($value): Type + { + return ConstantTypeHelper::getTypeFromValue($value); + } + + private function getReflectionProvider(): ReflectionProvider + { + return $this->reflectionProviderProvider->getReflectionProvider(); + } + + private function getNeverType(Type $leftType, Type $rightType): Type + { + // make sure we don't lose the explicit flag in the process + if ($leftType instanceof NeverType && $leftType->isExplicit()) { + return $leftType; + } + if ($rightType instanceof NeverType && $rightType->isExplicit()) { + return $rightType; + } + return new NeverType(); + } + +} diff --git a/src/Reflection/MethodPrototypeReflection.php b/src/Reflection/MethodPrototypeReflection.php new file mode 100644 index 00000000..7df60650 --- /dev/null +++ b/src/Reflection/MethodPrototypeReflection.php @@ -0,0 +1,87 @@ +name; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function isStatic(): bool + { + return $this->isStatic; + } + + public function isPrivate(): bool + { + return $this->isPrivate; + } + + public function isPublic(): bool + { + return $this->isPublic; + } + + public function isAbstract(): bool + { + return $this->isAbstract; + } + + public function isFinal(): bool + { + return $this->isFinal; + } + + public function isInternal(): bool + { + return $this->isInternal; + } + + public function getDocComment(): ?string + { + return null; + } + + /** + * @return ParametersAcceptor[] + */ + public function getVariants(): array + { + return $this->variants; + } + + public function getTentativeReturnType(): ?Type + { + return $this->tentativeReturnType; + } + +} diff --git a/src/Reflection/MethodReflection.php b/src/Reflection/MethodReflection.php new file mode 100644 index 00000000..6ff0b447 --- /dev/null +++ b/src/Reflection/MethodReflection.php @@ -0,0 +1,34 @@ + + */ + public function getVariants(): array; + + public function isDeprecated(): TrinaryLogic; + + public function getDeprecatedDescription(): ?string; + + public function isFinal(): TrinaryLogic; + + public function isInternal(): TrinaryLogic; + + public function getThrowType(): ?Type; + + public function hasSideEffects(): TrinaryLogic; + +} diff --git a/src/Reflection/MethodsClassReflectionExtension.php b/src/Reflection/MethodsClassReflectionExtension.php new file mode 100644 index 00000000..585f187c --- /dev/null +++ b/src/Reflection/MethodsClassReflectionExtension.php @@ -0,0 +1,30 @@ +reflection->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->static; + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment(); + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this->reflection->getPrototype(); + } + + public function getVariants(): array + { + return $this->reflection->getVariants(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->reflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->reflection->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isInternal(): TrinaryLogic + { + return $this->reflection->isInternal(); + } + + public function getThrowType(): ?Type + { + return $this->reflection->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return $this->reflection->hasSideEffects(); + } + +} diff --git a/src/Reflection/Mixin/MixinMethodsClassReflectionExtension.php b/src/Reflection/Mixin/MixinMethodsClassReflectionExtension.php new file mode 100644 index 00000000..5e523f8f --- /dev/null +++ b/src/Reflection/Mixin/MixinMethodsClassReflectionExtension.php @@ -0,0 +1,100 @@ +> */ + private array $inProcess = []; + + /** + * @param string[] $mixinExcludeClasses + */ + public function __construct(private array $mixinExcludeClasses) + { + } + + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->findMethod($classReflection, $methodName) !== null; + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + $method = $this->findMethod($classReflection, $methodName); + if ($method === null) { + throw new ShouldNotHappenException(); + } + + return $method; + } + + private function findMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $mixinTypes = $classReflection->getResolvedMixinTypes(); + foreach ($mixinTypes as $type) { + if (count(array_intersect($type->getObjectClassNames(), $this->mixinExcludeClasses)) > 0) { + continue; + } + + $typeDescription = $type->describe(VerbosityLevel::typeOnly()); + if (isset($this->inProcess[$typeDescription][$methodName])) { + continue; + } + + $this->inProcess[$typeDescription][$methodName] = true; + + if (!$type->hasMethod($methodName)->yes()) { + unset($this->inProcess[$typeDescription][$methodName]); + continue; + } + + $method = $type->getMethod($methodName, new OutOfClassScope()); + + unset($this->inProcess[$typeDescription][$methodName]); + + $static = $method->isStatic(); + if ( + !$static + && $classReflection->hasNativeMethod('__callStatic') + ) { + $static = true; + } + + return new MixinMethodReflection($method, $static); + } + + foreach ($classReflection->getTraits() as $traitClass) { + $methodWithDeclaringClass = $this->findMethod($traitClass, $methodName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + $parentClass = $classReflection->getParentClass(); + while ($parentClass !== null) { + $method = $this->findMethod($parentClass, $methodName); + if ($method !== null) { + return $method; + } + + $parentClass = $parentClass->getParentClass(); + } + + return null; + } + +} diff --git a/src/Reflection/Mixin/MixinPropertiesClassReflectionExtension.php b/src/Reflection/Mixin/MixinPropertiesClassReflectionExtension.php new file mode 100644 index 00000000..fceb035f --- /dev/null +++ b/src/Reflection/Mixin/MixinPropertiesClassReflectionExtension.php @@ -0,0 +1,91 @@ +> */ + private array $inProcess = []; + + /** + * @param string[] $mixinExcludeClasses + */ + public function __construct(private array $mixinExcludeClasses) + { + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return $this->findProperty($classReflection, $propertyName) !== null; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + $property = $this->findProperty($classReflection, $propertyName); + if ($property === null) { + throw new ShouldNotHappenException(); + } + + return $property; + } + + private function findProperty(ClassReflection $classReflection, string $propertyName): ?PropertyReflection + { + $mixinTypes = $classReflection->getResolvedMixinTypes(); + foreach ($mixinTypes as $type) { + if (count(array_intersect($type->getObjectClassNames(), $this->mixinExcludeClasses)) > 0) { + continue; + } + + $typeDescription = $type->describe(VerbosityLevel::typeOnly()); + if (isset($this->inProcess[$typeDescription][$propertyName])) { + continue; + } + + $this->inProcess[$typeDescription][$propertyName] = true; + + if (!$type->hasProperty($propertyName)->yes()) { + unset($this->inProcess[$typeDescription][$propertyName]); + continue; + } + + $property = $type->getProperty($propertyName, new OutOfClassScope()); + unset($this->inProcess[$typeDescription][$propertyName]); + + return $property; + } + + foreach ($classReflection->getTraits() as $traitClass) { + $methodWithDeclaringClass = $this->findProperty($traitClass, $propertyName); + if ($methodWithDeclaringClass === null) { + continue; + } + + return $methodWithDeclaringClass; + } + + $parentClass = $classReflection->getParentClass(); + while ($parentClass !== null) { + $property = $this->findProperty($parentClass, $propertyName); + if ($property !== null) { + return $property; + } + + $parentClass = $parentClass->getParentClass(); + } + + return null; + } + +} diff --git a/src/Reflection/NamespaceAnswerer.php b/src/Reflection/NamespaceAnswerer.php new file mode 100644 index 00000000..89dac78f --- /dev/null +++ b/src/Reflection/NamespaceAnswerer.php @@ -0,0 +1,15 @@ + $attributes + */ + public function __construct( + private string $name, + private bool $optional, + private Type $type, + private Type $phpDocType, + private Type $nativeType, + private PassedByReference $passedByReference, + private bool $variadic, + private ?Type $defaultValue, + private ?Type $outType, + private TrinaryLogic $immediatelyInvokedCallable, + private ?Type $closureThisType, + private array $attributes, + ) + { + } + + public function getName(): string + { + return $this->name; + } + + public function isOptional(): bool + { + return $this->optional; + } + + public function getType(): Type + { + return $this->type; + } + + public function getPhpDocType(): Type + { + return $this->phpDocType; + } + + public function hasNativeType(): bool + { + return !$this->nativeType instanceof MixedType || $this->nativeType->isExplicitMixed(); + } + + public function getNativeType(): Type + { + return $this->nativeType; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + + public function getOutType(): ?Type + { + return $this->outType; + } + + public function isImmediatelyInvokedCallable(): TrinaryLogic + { + return $this->immediatelyInvokedCallable; + } + + public function getClosureThisType(): ?Type + { + return $this->closureThisType; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Native/NativeFunctionReflection.php b/src/Reflection/Native/NativeFunctionReflection.php new file mode 100644 index 00000000..c18b67d6 --- /dev/null +++ b/src/Reflection/Native/NativeFunctionReflection.php @@ -0,0 +1,154 @@ + $variants + * @param list|null $namedArgumentsVariants + * @param list $attributes + */ + public function __construct( + private string $name, + private array $variants, + private ?array $namedArgumentsVariants, + private ?Type $throwType, + private TrinaryLogic $hasSideEffects, + private bool $isDeprecated, + ?Assertions $assertions, + private ?string $phpDocComment, + ?TrinaryLogic $returnsByReference, + private bool $acceptsNamedArguments, + private array $attributes, + ) + { + $this->assertions = $assertions ?? Assertions::createEmpty(); + $this->returnsByReference = $returnsByReference ?? TrinaryLogic::createMaybe(); + } + + public function getName(): string + { + return $this->name; + } + + public function getFileName(): ?string + { + return null; + } + + public function getVariants(): array + { + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + $variants = $this->getVariants(); + if (count($variants) !== 1) { + throw new ShouldNotHappenException(); + } + + return $variants[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return $this->namedArgumentsVariants; + } + + public function getThrowType(): ?Type + { + return $this->throwType; + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isDeprecated); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasSideEffects(): TrinaryLogic + { + if ($this->isVoid()) { + return TrinaryLogic::createYes(); + } + + return $this->hasSideEffects; + } + + public function isPure(): TrinaryLogic + { + if ($this->hasSideEffects()->yes()) { + return TrinaryLogic::createNo(); + } + + return $this->hasSideEffects->negate(); + } + + private function isVoid(): bool + { + foreach ($this->variants as $variant) { + if (!$variant->getReturnType()->isVoid()->yes()) { + return false; + } + } + + return true; + } + + public function isBuiltin(): bool + { + return true; + } + + public function getAsserts(): Assertions + { + return $this->assertions; + } + + public function getDocComment(): ?string + { + return $this->phpDocComment; + } + + public function returnsByReference(): TrinaryLogic + { + return $this->returnsByReference; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->acceptsNamedArguments); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Native/NativeMethodReflection.php b/src/Reflection/Native/NativeMethodReflection.php new file mode 100644 index 00000000..8adaac01 --- /dev/null +++ b/src/Reflection/Native/NativeMethodReflection.php @@ -0,0 +1,227 @@ + $variants + * @param list|null $namedArgumentsVariants + * @param list $attributes + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassReflection $declaringClass, + private ReflectionMethod $reflection, + private array $variants, + private ?array $namedArgumentsVariants, + private TrinaryLogic $hasSideEffects, + private ?Type $throwType, + private Assertions $assertions, + private bool $acceptsNamedArguments, + private ?Type $selfOutType, + private ?string $phpDocComment, + private array $attributes, + ) + { + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isAbstract()); + } + + public function getPrototype(): ClassMemberReflection + { + try { + $prototypeMethod = $this->reflection->getPrototype(); + $prototypeDeclaringClass = $this->declaringClass->getAncestorWithClassName($prototypeMethod->getDeclaringClass()->getName()); + if ($prototypeDeclaringClass === null) { + $prototypeDeclaringClass = $this->reflectionProvider->getClass($prototypeMethod->getDeclaringClass()->getName()); + } + + if (!$prototypeDeclaringClass->hasNativeMethod($prototypeMethod->getName())) { + return $this; + } + + $tentativeReturnType = null; + if ($prototypeMethod->getTentativeReturnType() !== null) { + $tentativeReturnType = TypehintHelper::decideTypeFromReflection($prototypeMethod->getTentativeReturnType(), null, $prototypeDeclaringClass); + } + + return new MethodPrototypeReflection( + $prototypeMethod->getName(), + $prototypeDeclaringClass, + $prototypeMethod->isStatic(), + $prototypeMethod->isPrivate(), + $prototypeMethod->isPublic(), + $prototypeMethod->isAbstract(), + $prototypeMethod->isFinal(), + $prototypeMethod->isInternal(), + $prototypeDeclaringClass->getNativeMethod($prototypeMethod->getName())->getVariants(), + $tentativeReturnType, + ); + } catch (ReflectionException) { + return $this; + } + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getVariants(): array + { + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + $variants = $this->getVariants(); + if (count($variants) !== 1) { + throw new ShouldNotHappenException(); + } + + return $variants[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return $this->namedArgumentsVariants; + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isDeprecated()); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isInternal()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isFinal()); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return $this->isFinal(); + } + + public function getThrowType(): ?Type + { + return $this->throwType; + } + + public function hasSideEffects(): TrinaryLogic + { + $name = strtolower($this->getName()); + $isVoid = $this->isVoid(); + if ( + $name !== '__construct' + && $isVoid + ) { + return TrinaryLogic::createYes(); + } + + return $this->hasSideEffects; + } + + public function isPure(): TrinaryLogic + { + if ($this->hasSideEffects()->yes()) { + return TrinaryLogic::createNo(); + } + + return $this->hasSideEffects->negate(); + } + + private function isVoid(): bool + { + foreach ($this->variants as $variant) { + if (!$variant->getReturnType()->isVoid()->yes()) { + return false; + } + } + + return true; + } + + public function getDocComment(): ?string + { + return $this->phpDocComment; + } + + public function getAsserts(): Assertions + { + return $this->assertions; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->declaringClass->acceptsNamedArguments() && $this->acceptsNamedArguments); + } + + public function getSelfOutType(): ?Type + { + return $this->selfOutType; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->returnsReference()); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Native/NativeParameterReflection.php b/src/Reflection/Native/NativeParameterReflection.php new file mode 100644 index 00000000..1f1b5406 --- /dev/null +++ b/src/Reflection/Native/NativeParameterReflection.php @@ -0,0 +1,67 @@ +name; + } + + public function isOptional(): bool + { + return $this->optional; + } + + public function getType(): Type + { + return $this->type; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + + public function union(self $other): self + { + return new self( + $this->name, + $this->optional && $other->optional, + TypeCombinator::union($this->type, $other->type), + $this->passedByReference->combine($other->passedByReference), + $this->variadic && $other->variadic, + $this->optional && $other->optional ? $this->defaultValue : null, + ); + } + +} diff --git a/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php new file mode 100644 index 00000000..609a8672 --- /dev/null +++ b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php @@ -0,0 +1,44 @@ +className; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === $this->methodName; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if ($this->phpVersion->getVersionId() >= 80000) { + return null; + } + + return new ObjectType(ReflectionClass::class); + } + +} diff --git a/src/Reflection/ParameterReflection.php b/src/Reflection/ParameterReflection.php new file mode 100644 index 00000000..5883ce63 --- /dev/null +++ b/src/Reflection/ParameterReflection.php @@ -0,0 +1,24 @@ + + */ + public function getParameters(): array; + + public function isVariadic(): bool; + + public function getReturnType(): Type; + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php new file mode 100644 index 00000000..07c30823 --- /dev/null +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -0,0 +1,1102 @@ + 0 + && count($parametersAcceptors) > 0 + ) { + $arrayMapArgs = $args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + if ($arrayMapArgs !== null) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $callbackParameters = []; + foreach ($arrayMapArgs as $arg) { + $argType = $scope->getType($arg->value); + if ($arg->unpack) { + $constantArrays = $argType->getConstantArrays(); + if (count($constantArrays) > 0) { + foreach ($constantArrays as $constantArray) { + $valueTypes = $constantArray->getValueTypes(); + foreach ($valueTypes as $valueType) { + $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($valueType), false, PassedByReference::createNo(), false, null); + } + } + } + } else { + $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($argType), false, PassedByReference::createNo(), false, null); + } + } + $parameters[0] = new NativeParameterReflection( + $parameters[0]->getName(), + $parameters[0]->isOptional(), + new UnionType([ + new CallableType($callbackParameters, new MixedType(), false), + new NullType(), + ]), + $parameters[0]->passedByReference(), + $parameters[0]->isVariadic(), + $parameters[0]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + $parameters, + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + if (count($args) >= 3 && (bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { + $optType = $scope->getType($args[1]->value); + if ($optType instanceof ConstantIntegerType) { + $optValueType = self::getCurlOptValueType($optType->getValue()); + + if ($optValueType !== null) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + + $parameters[2] = new NativeParameterReflection( + $parameters[2]->getName(), + $parameters[2]->isOptional(), + $optValueType, + $parameters[2]->passedByReference(), + $parameters[2]->isVariadic(), + $parameters[2]->getDefaultValue(), + ); + + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_values($parameters), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + } + } + + if (isset($args[0]) && (bool) $args[0]->getAttribute(ArrayFilterArgVisitor::ATTRIBUTE_NAME)) { + if (isset($args[2])) { + $mode = $scope->getType($args[2]->value); + if ($mode instanceof ConstantIntegerType) { + if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { + $arrayFilterParameters = [ + new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), false, PassedByReference::createNo(), false, null), + ]; + } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { + $arrayFilterParameters = [ + new DummyParameter('item', $scope->getIterableValueType($scope->getType($args[0]->value)), false, PassedByReference::createNo(), false, null), + new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), false, PassedByReference::createNo(), false, null), + ]; + } + } + } + + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $parameters[1] = new NativeParameterReflection( + $parameters[1]->getName(), + $parameters[1]->isOptional(), + new UnionType([ + new CallableType( + $arrayFilterParameters ?? [ + new DummyParameter('item', $scope->getIterableValueType($scope->getType($args[0]->value)), false, PassedByReference::createNo(), false, null), + ], + new BooleanType(), + false, + ), + new NullType(), + ]), + $parameters[1]->passedByReference(), + $parameters[1]->isVariadic(), + $parameters[1]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_values($parameters), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + if (isset($args[0]) && (bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { + $arrayWalkParameters = [ + new DummyParameter('item', $scope->getIterableValueType($scope->getType($args[0]->value)), false, PassedByReference::createReadsArgument(), false, null), + new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), false, PassedByReference::createNo(), false, null), + ]; + if (isset($args[2])) { + $arrayWalkParameters[] = new DummyParameter('arg', $scope->getType($args[2]->value), false, PassedByReference::createNo(), false, null); + } + + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $parameters[1] = new NativeParameterReflection( + $parameters[1]->getName(), + $parameters[1]->isOptional(), + new CallableType($arrayWalkParameters, new MixedType(), false), + $parameters[1]->passedByReference(), + $parameters[1]->isVariadic(), + $parameters[1]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_values($parameters), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + if (isset($args[0]) && (bool) $args[0]->getAttribute(ArrayFindArgVisitor::ATTRIBUTE_NAME)) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $argType = $scope->getType($args[0]->value); + $parameters[1] = new NativeParameterReflection( + $parameters[1]->getName(), + $parameters[1]->isOptional(), + new CallableType( + [ + new DummyParameter('value', $scope->getIterableValueType($argType), false, PassedByReference::createNo(), false, null), + new DummyParameter('key', $scope->getIterableKeyType($argType), false, PassedByReference::createNo(), false, null), + ], + new BooleanType(), + false, + ), + $parameters[1]->passedByReference(), + $parameters[1]->isVariadic(), + $parameters[1]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_values($parameters), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + if (isset($args[0])) { + $closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME); + if ( + $closureBindToVar !== null + && $closureBindToVar instanceof Node\Expr\Variable + && is_string($closureBindToVar->name) + ) { + $varType = $scope->getType($closureBindToVar); + if ((new ObjectType(Closure::class))->isSuperTypeOf($varType)->yes()) { + $inFunction = $scope->getFunction(); + if ($inFunction !== null) { + $closureThisParameters = []; + foreach ($inFunction->getParameters() as $parameter) { + if ($parameter->getClosureThisType() === null) { + continue; + } + $closureThisParameters[$parameter->getName()] = $parameter->getClosureThisType(); + } + if (array_key_exists($closureBindToVar->name, $closureThisParameters)) { + if ($scope->hasExpressionType(new ParameterVariableOriginalValueExpr($closureBindToVar->name))->yes()) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $parameters[0] = new NativeParameterReflection( + $parameters[0]->getName(), + $parameters[0]->isOptional(), + $closureThisParameters[$closureBindToVar->name], + $parameters[0]->passedByReference(), + $parameters[0]->isVariadic(), + $parameters[0]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + $parameters, + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + } + } + } + } + + if ( + $args[0]->getAttribute(ClosureBindArgVisitor::ATTRIBUTE_NAME) !== null + && $args[0]->value instanceof Node\Expr\Variable + && is_string($args[0]->value->name) + ) { + $closureVarName = $args[0]->value->name; + $inFunction = $scope->getFunction(); + if ($inFunction !== null) { + $closureThisParameters = []; + foreach ($inFunction->getParameters() as $parameter) { + if ($parameter->getClosureThisType() === null) { + continue; + } + $closureThisParameters[$parameter->getName()] = $parameter->getClosureThisType(); + } + if (array_key_exists($closureVarName, $closureThisParameters)) { + if ($scope->hasExpressionType(new ParameterVariableOriginalValueExpr($closureVarName))->yes()) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $parameters[1] = new NativeParameterReflection( + $parameters[1]->getName(), + $parameters[1]->isOptional(), + $closureThisParameters[$closureVarName], + $parameters[1]->passedByReference(), + $parameters[1]->isVariadic(), + $parameters[1]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_values($parameters), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + } + } + } + } + } + + if (count($parametersAcceptors) === 1) { + $acceptor = $parametersAcceptors[0]; + if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) { + return $acceptor; + } + } + + $reorderedArgs = $args; + $parameters = null; + $singleParametersAcceptor = null; + if (count($parametersAcceptors) === 1) { + $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptors[0], $args); + $singleParametersAcceptor = $parametersAcceptors[0]; + } + + $hasName = false; + foreach ($reorderedArgs ?? $args as $i => $arg) { + $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $parameter = null; + if ($singleParametersAcceptor !== null) { + $parameters = $singleParametersAcceptor->getParameters(); + if (isset($parameters[$i])) { + $parameter = $parameters[$i]; + } elseif (count($parameters) > 0 && $singleParametersAcceptor->isVariadic()) { + $parameter = $parameters[count($parameters) - 1]; + } + } + + if ($parameter !== null && $scope instanceof MutatingScope) { + $scope = $scope->pushInFunctionCall(null, $parameter); + } + + $type = $scope->getType($originalArg->value); + + if ($parameter !== null && $scope instanceof MutatingScope) { + $scope = $scope->popInFunctionCall(); + } + + if ($originalArg->name !== null) { + $index = $originalArg->name->toString(); + $hasName = true; + } else { + $index = $i; + } + if ($originalArg->unpack) { + $unpack = true; + $types[$index] = $type->getIterableValueType(); + } else { + $types[$index] = $type; + } + } + + if ($hasName && $namedArgumentsVariants !== null) { + return self::selectFromTypes($types, $namedArgumentsVariants, $unpack); + } + + return self::selectFromTypes($types, $parametersAcceptors, $unpack); + } + + private static function hasAcceptorTemplateOrLateResolvableType(ParametersAcceptor $acceptor): bool + { + if (self::hasTemplateOrLateResolvableType($acceptor->getReturnType())) { + return true; + } + + foreach ($acceptor->getParameters() as $parameter) { + if ( + $parameter instanceof ExtendedParameterReflection + && $parameter->getOutType() !== null + && self::hasTemplateOrLateResolvableType($parameter->getOutType()) + ) { + return true; + } + + if ( + $parameter instanceof ExtendedParameterReflection + && $parameter->getClosureThisType() !== null + && self::hasTemplateOrLateResolvableType($parameter->getClosureThisType()) + ) { + return true; + } + + if (!self::hasTemplateOrLateResolvableType($parameter->getType())) { + continue; + } + + return true; + } + + return false; + } + + private static function hasTemplateOrLateResolvableType(Type $type): bool + { + $has = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$has): Type { + if ($type instanceof TemplateType || $type instanceof LateResolvableType) { + $has = true; + return $type; + } + + return $traverse($type); + }); + + return $has; + } + + /** + * @param array $types + * @param ParametersAcceptor[] $parametersAcceptors + */ + public static function selectFromTypes( + array $types, + array $parametersAcceptors, + bool $unpack, + ): ParametersAcceptor + { + if (count($parametersAcceptors) === 1) { + return GenericParametersAcceptorResolver::resolve($types, $parametersAcceptors[0]); + } + + if (count($parametersAcceptors) === 0) { + throw new ShouldNotHappenException( + 'getVariants() must return at least one variant.', + ); + } + + $typesCount = count($types); + $acceptableAcceptors = []; + + foreach ($parametersAcceptors as $parametersAcceptor) { + if ($unpack) { + $acceptableAcceptors[] = $parametersAcceptor; + continue; + } + + $functionParametersMinCount = 0; + $functionParametersMaxCount = 0; + foreach ($parametersAcceptor->getParameters() as $parameter) { + if (!$parameter->isOptional()) { + $functionParametersMinCount++; + } + + $functionParametersMaxCount++; + } + + if ($typesCount < $functionParametersMinCount) { + continue; + } + + if ( + !$parametersAcceptor->isVariadic() + && $typesCount > $functionParametersMaxCount + ) { + continue; + } + + $acceptableAcceptors[] = $parametersAcceptor; + } + + if (count($acceptableAcceptors) === 0) { + return GenericParametersAcceptorResolver::resolve($types, self::combineAcceptors($parametersAcceptors)); + } + + if (count($acceptableAcceptors) === 1) { + return GenericParametersAcceptorResolver::resolve($types, $acceptableAcceptors[0]); + } + + $winningAcceptors = []; + $winningCertainty = null; + foreach ($acceptableAcceptors as $acceptableAcceptor) { + $isSuperType = TrinaryLogic::createYes(); + $acceptableAcceptor = GenericParametersAcceptorResolver::resolve($types, $acceptableAcceptor); + foreach ($acceptableAcceptor->getParameters() as $i => $parameter) { + if (!isset($types[$i])) { + if (!$unpack || count($types) <= 0) { + break; + } + + $type = $types[array_key_last($types)]; + } else { + $type = $types[$i]; + } + + if ($parameter->getType() instanceof MixedType) { + $isSuperType = $isSuperType->and(TrinaryLogic::createMaybe()); + } else { + $isSuperType = $isSuperType->and($parameter->getType()->isSuperTypeOf($type)->result); + } + } + + if ($isSuperType->no()) { + continue; + } + + if ($winningCertainty === null) { + $winningAcceptors[] = $acceptableAcceptor; + $winningCertainty = $isSuperType; + } else { + $comparison = $winningCertainty->compareTo($isSuperType); + if ($comparison === $isSuperType) { + $winningAcceptors = [$acceptableAcceptor]; + $winningCertainty = $isSuperType; + } elseif ($comparison === null) { + $winningAcceptors[] = $acceptableAcceptor; + } + } + } + + if (count($winningAcceptors) === 0) { + return GenericParametersAcceptorResolver::resolve($types, self::combineAcceptors($acceptableAcceptors)); + } + + return GenericParametersAcceptorResolver::resolve($types, self::combineAcceptors($winningAcceptors)); + } + + /** + * @param ParametersAcceptor[] $acceptors + */ + public static function combineAcceptors(array $acceptors): ExtendedParametersAcceptor + { + if (count($acceptors) === 0) { + throw new ShouldNotHappenException( + 'getVariants() must return at least one variant.', + ); + } + if (count($acceptors) === 1) { + return self::wrapAcceptor($acceptors[0]); + } + + $minimumNumberOfParameters = null; + foreach ($acceptors as $acceptor) { + $acceptorParametersMinCount = 0; + foreach ($acceptor->getParameters() as $parameter) { + if ($parameter->isOptional()) { + continue; + } + + $acceptorParametersMinCount++; + } + + if ($minimumNumberOfParameters !== null && $minimumNumberOfParameters <= $acceptorParametersMinCount) { + continue; + } + + $minimumNumberOfParameters = $acceptorParametersMinCount; + } + + $parameters = []; + $isVariadic = false; + $returnTypes = []; + $phpDocReturnTypes = []; + $nativeReturnTypes = []; + $callableOccurred = false; + $throwPoints = []; + $isPure = TrinaryLogic::createNo(); + $impurePoints = []; + $invalidateExpressions = []; + $usedVariables = []; + $acceptsNamedArguments = TrinaryLogic::createNo(); + + foreach ($acceptors as $acceptor) { + $returnTypes[] = $acceptor->getReturnType(); + + if ($acceptor instanceof ExtendedParametersAcceptor) { + $phpDocReturnTypes[] = $acceptor->getPhpDocReturnType(); + $nativeReturnTypes[] = $acceptor->getNativeReturnType(); + } + if ($acceptor instanceof CallableParametersAcceptor) { + $callableOccurred = true; + $throwPoints = array_merge($throwPoints, $acceptor->getThrowPoints()); + $isPure = $isPure->or($acceptor->isPure()); + $impurePoints = array_merge($impurePoints, $acceptor->getImpurePoints()); + $invalidateExpressions = array_merge($invalidateExpressions, $acceptor->getInvalidateExpressions()); + $usedVariables = array_merge($usedVariables, $acceptor->getUsedVariables()); + $acceptsNamedArguments = $acceptsNamedArguments->or($acceptor->acceptsNamedArguments()); + } + $isVariadic = $isVariadic || $acceptor->isVariadic(); + + foreach ($acceptor->getParameters() as $i => $parameter) { + if (!isset($parameters[$i])) { + $parameters[$i] = new ExtendedDummyParameter( + $parameter->getName(), + $parameter->getType(), + $i + 1 > $minimumNumberOfParameters, + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + $parameter instanceof ExtendedParameterReflection ? $parameter->getNativeType() : new MixedType(), + $parameter instanceof ExtendedParameterReflection ? $parameter->getPhpDocType() : new MixedType(), + $parameter instanceof ExtendedParameterReflection ? $parameter->getOutType() : null, + $parameter instanceof ExtendedParameterReflection ? $parameter->isImmediatelyInvokedCallable() : TrinaryLogic::createMaybe(), + $parameter instanceof ExtendedParameterReflection ? $parameter->getClosureThisType() : null, + $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], + ); + continue; + } + + $isVariadic = $parameters[$i]->isVariadic() || $parameter->isVariadic(); + $defaultValueLeft = $parameters[$i]->getDefaultValue(); + $defaultValueRight = $parameter->getDefaultValue(); + if ($defaultValueLeft !== null && $defaultValueRight !== null) { + $defaultValue = TypeCombinator::union($defaultValueLeft, $defaultValueRight); + } else { + $defaultValue = null; + } + + $type = TypeCombinator::union($parameters[$i]->getType(), $parameter->getType()); + $nativeType = $parameters[$i]->getNativeType(); + $phpDocType = $parameters[$i]->getPhpDocType(); + $outType = $parameters[$i]->getOutType(); + $immediatelyInvokedCallable = $parameters[$i]->isImmediatelyInvokedCallable(); + $closureThisType = $parameters[$i]->getClosureThisType(); + $attributes = $parameters[$i]->getAttributes(); + if ($parameter instanceof ExtendedParameterReflection) { + $nativeType = TypeCombinator::union($nativeType, $parameter->getNativeType()); + $phpDocType = TypeCombinator::union($phpDocType, $parameter->getPhpDocType()); + + if ($parameter->getOutType() !== null) { + $outType = $outType === null ? null : TypeCombinator::union($outType, $parameter->getOutType()); + } else { + $outType = null; + } + + if ($parameter->getClosureThisType() !== null && $closureThisType !== null) { + $closureThisType = TypeCombinator::union($closureThisType, $parameter->getClosureThisType()); + } else { + $closureThisType = null; + } + + $immediatelyInvokedCallable = $parameter->isImmediatelyInvokedCallable()->or($immediatelyInvokedCallable); + $attributes = array_merge($attributes, $parameter->getAttributes()); + } else { + $nativeType = new MixedType(); + $phpDocType = $type; + $outType = null; + $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); + $closureThisType = null; + } + + $parameters[$i] = new ExtendedDummyParameter( + $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), + $type, + $i + 1 > $minimumNumberOfParameters, + $parameters[$i]->passedByReference()->combine($parameter->passedByReference()), + $isVariadic, + $defaultValue, + $nativeType, + $phpDocType, + $outType, + $immediatelyInvokedCallable, + $closureThisType, + $attributes, + ); + + if ($isVariadic) { + $parameters = array_slice($parameters, 0, $i + 1); + break; + } + } + } + + $returnType = TypeCombinator::union(...$returnTypes); + $phpDocReturnType = $phpDocReturnTypes === [] ? null : TypeCombinator::union(...$phpDocReturnTypes); + $nativeReturnType = $nativeReturnTypes === [] ? null : TypeCombinator::union(...$nativeReturnTypes); + + if ($callableOccurred) { + return new ExtendedCallableFunctionVariant( + TemplateTypeMap::createEmpty(), + null, + array_values($parameters), + $isVariadic, + $returnType, + $phpDocReturnType ?? $returnType, + $nativeReturnType ?? new MixedType(), + null, + $throwPoints, + $isPure, + $impurePoints, + $invalidateExpressions, + $usedVariables, + $acceptsNamedArguments, + ); + } + + return new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + null, + array_values($parameters), + $isVariadic, + $returnType, + $phpDocReturnType ?? $returnType, + $nativeReturnType ?? new MixedType(), + ); + } + + private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedParametersAcceptor + { + if ($acceptor instanceof ExtendedParametersAcceptor) { + return $acceptor; + } + + if ($acceptor instanceof CallableParametersAcceptor) { + return new ExtendedCallableFunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_map(static fn (ParameterReflection $parameter): ExtendedParameterReflection => self::wrapParameter($parameter), $acceptor->getParameters()), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor->getReturnType(), + new MixedType(), + TemplateTypeVarianceMap::createEmpty(), + $acceptor->getThrowPoints(), + $acceptor->isPure(), + $acceptor->getImpurePoints(), + $acceptor->getInvalidateExpressions(), + $acceptor->getUsedVariables(), + $acceptor->acceptsNamedArguments(), + ); + } + + return new ExtendedFunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_map(static fn (ParameterReflection $parameter): ExtendedParameterReflection => self::wrapParameter($parameter), $acceptor->getParameters()), + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor->getReturnType(), + new MixedType(), + TemplateTypeVarianceMap::createEmpty(), + ); + } + + private static function wrapParameter(ParameterReflection $parameter): ExtendedParameterReflection + { + return $parameter instanceof ExtendedParameterReflection ? $parameter : new ExtendedDummyParameter( + $parameter->getName(), + $parameter->getType(), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + new MixedType(), + $parameter->getType(), + null, + TrinaryLogic::createMaybe(), + null, + [], + ); + } + + private static function getCurlOptValueType(int $curlOpt): ?Type + { + if (defined('CURLOPT_SSL_VERIFYHOST') && $curlOpt === CURLOPT_SSL_VERIFYHOST) { + return new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(2)]); + } + + $boolConstants = [ + 'CURLOPT_AUTOREFERER', + 'CURLOPT_COOKIESESSION', + 'CURLOPT_CERTINFO', + 'CURLOPT_CONNECT_ONLY', + 'CURLOPT_CRLF', + 'CURLOPT_DISALLOW_USERNAME_IN_URL', + 'CURLOPT_DNS_SHUFFLE_ADDRESSES', + 'CURLOPT_HAPROXYPROTOCOL', + 'CURLOPT_SSH_COMPRESSION', + 'CURLOPT_DNS_USE_GLOBAL_CACHE', + 'CURLOPT_FAILONERROR', + 'CURLOPT_SSL_FALSESTART', + 'CURLOPT_FILETIME', + 'CURLOPT_FOLLOWLOCATION', + 'CURLOPT_FORBID_REUSE', + 'CURLOPT_FRESH_CONNECT', + 'CURLOPT_FTP_USE_EPRT', + 'CURLOPT_FTP_USE_EPSV', + 'CURLOPT_FTP_CREATE_MISSING_DIRS', + 'CURLOPT_FTPAPPEND', + 'CURLOPT_TCP_NODELAY', + 'CURLOPT_FTPASCII', + 'CURLOPT_FTPLISTONLY', + 'CURLOPT_HEADER', + 'CURLOPT_HTTP09_ALLOWED', + 'CURLOPT_HTTPGET', + 'CURLOPT_HTTPPROXYTUNNEL', + 'CURLOPT_HTTP_CONTENT_DECODING', + 'CURLOPT_KEEP_SENDING_ON_ERROR', + 'CURLOPT_MUTE', + 'CURLOPT_NETRC', + 'CURLOPT_NOBODY', + 'CURLOPT_NOPROGRESS', + 'CURLOPT_NOSIGNAL', + 'CURLOPT_PATH_AS_IS', + 'CURLOPT_PIPEWAIT', + 'CURLOPT_POST', + 'CURLOPT_PUT', + 'CURLOPT_RETURNTRANSFER', + 'CURLOPT_SASL_IR', + 'CURLOPT_SSL_ENABLE_ALPN', + 'CURLOPT_SSL_ENABLE_NPN', + 'CURLOPT_SSL_VERIFYPEER', + 'CURLOPT_SSL_VERIFYSTATUS', + 'CURLOPT_PROXY_SSL_VERIFYPEER', + 'CURLOPT_SUPPRESS_CONNECT_HEADERS', + 'CURLOPT_TCP_FASTOPEN', + 'CURLOPT_TFTP_NO_OPTIONS', + 'CURLOPT_TRANSFERTEXT', + 'CURLOPT_UNRESTRICTED_AUTH', + 'CURLOPT_UPLOAD', + 'CURLOPT_VERBOSE', + ]; + foreach ($boolConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new BooleanType(); + } + } + + $intConstants = [ + 'CURLOPT_BUFFERSIZE', + 'CURLOPT_CONNECTTIMEOUT', + 'CURLOPT_CONNECTTIMEOUT_MS', + 'CURLOPT_DNS_CACHE_TIMEOUT', + 'CURLOPT_EXPECT_100_TIMEOUT_MS', + 'CURLOPT_HAPPY_EYEBALLS_TIMEOUT_MS', + 'CURLOPT_FTPSSLAUTH', + 'CURLOPT_HEADEROPT', + 'CURLOPT_HTTP_VERSION', + 'CURLOPT_HTTPAUTH', + 'CURLOPT_INFILESIZE', + 'CURLOPT_LOW_SPEED_LIMIT', + 'CURLOPT_LOW_SPEED_TIME', + 'CURLOPT_MAXCONNECTS', + 'CURLOPT_MAXREDIRS', + 'CURLOPT_PORT', + 'CURLOPT_POSTREDIR', + 'CURLOPT_PROTOCOLS', + 'CURLOPT_PROXYAUTH', + 'CURLOPT_PROXYPORT', + 'CURLOPT_PROXYTYPE', + 'CURLOPT_REDIR_PROTOCOLS', + 'CURLOPT_RESUME_FROM', + 'CURLOPT_SOCKS5_AUTH', + 'CURLOPT_SSL_OPTIONS', + 'CURLOPT_SSL_VERIFYHOST', + 'CURLOPT_SSLVERSION', + 'CURLOPT_PROXY_SSL_OPTIONS', + 'CURLOPT_PROXY_SSL_VERIFYHOST', + 'CURLOPT_PROXY_SSLVERSION', + 'CURLOPT_STREAM_WEIGHT', + 'CURLOPT_TCP_KEEPALIVE', + 'CURLOPT_TCP_KEEPIDLE', + 'CURLOPT_TCP_KEEPINTVL', + 'CURLOPT_TIMECONDITION', + 'CURLOPT_TIMEOUT', + 'CURLOPT_TIMEOUT_MS', + 'CURLOPT_TIMEVALUE', + 'CURLOPT_TIMEVALUE_LARGE', + 'CURLOPT_MAX_RECV_SPEED_LARGE', + 'CURLOPT_SSH_AUTH_TYPES', + 'CURLOPT_IPRESOLVE', + 'CURLOPT_FTP_FILEMETHOD', + ]; + foreach ($intConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new IntegerType(); + } + } + + $nullableStringConstants = [ + 'CURLOPT_CUSTOMREQUEST', + 'CURLOPT_DNS_INTERFACE', + 'CURLOPT_DNS_LOCAL_IP4', + 'CURLOPT_DNS_LOCAL_IP6', + 'CURLOPT_DOH_URL', + 'CURLOPT_FTP_ACCOUNT', + 'CURLOPT_FTPPORT', + 'CURLOPT_HSTS', + 'CURLOPT_KRBLEVEL', + 'CURLOPT_RANGE', + 'CURLOPT_RTSP_SESSION_ID', + 'CURLOPT_UNIX_SOCKET_PATH', + 'CURLOPT_XOAUTH2_BEARER', + ]; + foreach ($nullableStringConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new UnionType([ + new NullType(), + TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ), + ]); + } + } + + $nonEmptyStringConstants = [ + 'CURLOPT_ABSTRACT_UNIX_SOCKET', + 'CURLOPT_ALTSVC', + 'CURLOPT_AWS_SIGV4', + 'CURLOPT_CAINFO', + 'CURLOPT_CAPATH', + 'CURLOPT_COOKIE', + 'CURLOPT_COOKIEJAR', + 'CURLOPT_COOKIELIST', + 'CURLOPT_DEFAULT_PROTOCOL', + 'CURLOPT_DNS_SERVERS', + 'CURLOPT_EGDSOCKET', + 'CURLOPT_FTP_ALTERNATIVE_TO_USER', + 'CURLOPT_INTERFACE', + 'CURLOPT_KEYPASSWD', + 'CURLOPT_KRB4LEVEL', + 'CURLOPT_LOGIN_OPTIONS', + 'CURLOPT_MAIL_AUTH', + 'CURLOPT_MAIL_FROM', + 'CURLOPT_NOPROXY', + 'CURLOPT_PASSWORD', + 'CURLOPT_PINNEDPUBLICKEY', + 'CURLOPT_PROTOCOLS_STR', + 'CURLOPT_PROXY_CAINFO', + 'CURLOPT_PROXY_CAPATH', + 'CURLOPT_PROXY_CRLFILE', + 'CURLOPT_PROXY_ISSUERCERT', + 'CURLOPT_PROXY_KEYPASSWD', + 'CURLOPT_PROXY_PINNEDPUBLICKEY', + 'CURLOPT_PROXY_SERVICE_NAME', + 'CURLOPT_PROXY_SSL_CIPHER_LIST', + 'CURLOPT_PROXY_SSLCERT', + 'CURLOPT_PROXY_SSLCERTTYPE', + 'CURLOPT_PROXY_SSLKEY', + 'CURLOPT_PROXY_SSLKEYTYPE', + 'CURLOPT_PROXY_TLS13_CIPHERS', + 'CURLOPT_PROXY_TLSAUTH_PASSWORD', + 'CURLOPT_PROXY_TLSAUTH_TYPE', + 'CURLOPT_PROXY_TLSAUTH_USERNAME', + 'CURLOPT_PROXYPASSWORD', + 'CURLOPT_PROXYUSERNAME', + 'CURLOPT_PROXYUSERPWD', + 'CURLOPT_RANDOM_FILE', + 'CURLOPT_REDIR_PROTOCOLS_STR', + 'CURLOPT_REFERER', + 'CURLOPT_REQUEST_TARGET', + 'CURLOPT_RTSP_STREAM_URI', + 'CURLOPT_RTSP_TRANSPORT', + 'CURLOPT_SASL_AUTHZID', + 'CURLOPT_SERVICE_NAME', + 'CURLOPT_SOCKS5_GSSAPI_SERVICE', + 'CURLOPT_SSH_HOST_PUBLIC_KEY_MD5', + 'CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256', + 'CURLOPT_SSH_PRIVATE_KEYFILE', + 'CURLOPT_SSH_PUBLIC_KEYFILE', + 'CURLOPT_SSL_CIPHER_LIST', + 'CURLOPT_SSL_EC_CURVES', + 'CURLOPT_SSLCERT', + 'CURLOPT_SSLCERTPASSWD', + 'CURLOPT_SSLCERTTYPE', + 'CURLOPT_SSLENGINE', + 'CURLOPT_SSLENGINE_DEFAULT', + 'CURLOPT_SSLKEY', + 'CURLOPT_SSLKEYPASSWD', + 'CURLOPT_SSLKEYTYPE', + 'CURLOPT_TLS13_CIPHERS', + 'CURLOPT_TLSAUTH_PASSWORD', + 'CURLOPT_TLSAUTH_TYPE', + 'CURLOPT_TLSAUTH_USERNAME', + 'CURLOPT_TRANSFER_ENCODING', + 'CURLOPT_URL', + 'CURLOPT_USERAGENT', + 'CURLOPT_USERNAME', + 'CURLOPT_USERPWD', + ]; + foreach ($nonEmptyStringConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ); + } + } + + $stringConstants = [ + 'CURLOPT_COOKIEFILE', + 'CURLOPT_ENCODING', // Alias: CURLOPT_ACCEPT_ENCODING + 'CURLOPT_PRE_PROXY', + 'CURLOPT_PRIVATE', + 'CURLOPT_PROXY', + ]; + foreach ($stringConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new StringType(); + } + } + + $intArrayStringKeysConstants = [ + 'CURLOPT_HTTPHEADER', + ]; + foreach ($intArrayStringKeysConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new ArrayType(new IntegerType(), new StringType()); + } + } + + $arrayConstants = [ + 'CURLOPT_CONNECT_TO', + 'CURLOPT_HTTP200ALIASES', + 'CURLOPT_POSTQUOTE', + 'CURLOPT_PROXYHEADER', + 'CURLOPT_QUOTE', + 'CURLOPT_RESOLVE', + ]; + foreach ($arrayConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new ArrayType(new MixedType(), new MixedType()); + } + } + + $arrayOrStringConstants = [ + 'CURLOPT_POSTFIELDS', + ]; + foreach ($arrayOrStringConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new UnionType([ + new StringType(), + new ArrayType(new MixedType(), new MixedType()), + ]); + } + } + + $resourceConstants = [ + 'CURLOPT_FILE', + 'CURLOPT_INFILE', + 'CURLOPT_STDERR', + 'CURLOPT_WRITEHEADER', + ]; + foreach ($resourceConstants as $constName) { + if (defined($constName) && constant($constName) === $curlOpt) { + return new ResourceType(); + } + } + + // unknown constant + return null; + } + +} diff --git a/src/Reflection/PassedByReference.php b/src/Reflection/PassedByReference.php new file mode 100644 index 00000000..919ad944 --- /dev/null +++ b/src/Reflection/PassedByReference.php @@ -0,0 +1,80 @@ +value === self::NO; + } + + public function yes(): bool + { + return !$this->no(); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function createsNewVariable(): bool + { + return $this->value === self::CREATES_NEW_VARIABLE; + } + + public function combine(self $other): self + { + if ($this->value > $other->value) { + return $this; + } elseif ($this->value < $other->value) { + return $other; + } + + return $this; + } + +} diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php new file mode 100644 index 00000000..ac4dbc9c --- /dev/null +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -0,0 +1,196 @@ +nativeMethodReflection->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->nativeMethodReflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->nativeMethodReflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->nativeMethodReflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->nativeMethodReflection->getDocComment(); + } + + public function getName(): string + { + return $this->nativeMethodReflection->getName(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this->nativeMethodReflection->getPrototype(); + } + + public function getVariants(): array + { + $parameters = $this->closureType->getParameters(); + $newThis = new NativeParameterReflection( + 'newThis', + false, + new ObjectWithoutClassType(), + PassedByReference::createNo(), + false, + null, + ); + + array_unshift($parameters, $newThis); + + return [ + new ExtendedFunctionVariant( + $this->closureType->getTemplateTypeMap(), + $this->closureType->getResolvedTemplateTypeMap(), + array_map(static fn (ParameterReflection $parameter): ExtendedParameterReflection => new ExtendedDummyParameter( + $parameter->getName(), + $parameter->getType(), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + new MixedType(), + $parameter->getType(), + null, + TrinaryLogic::createMaybe(), + null, + [], + ), $parameters), + $this->closureType->isVariadic(), + $this->closureType->getReturnType(), + $this->closureType->getReturnType(), + new MixedType(), + $this->closureType->getCallSiteVarianceMap(), + ), + ]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return $this->nativeMethodReflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->nativeMethodReflection->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return $this->nativeMethodReflection->isFinal(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return $this->nativeMethodReflection->isFinalByKeyword(); + } + + public function isInternal(): TrinaryLogic + { + return $this->nativeMethodReflection->isInternal(); + } + + public function getThrowType(): ?Type + { + return $this->nativeMethodReflection->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return $this->nativeMethodReflection->hasSideEffects(); + } + + public function getAsserts(): Assertions + { + return $this->nativeMethodReflection->getAsserts(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->nativeMethodReflection->acceptsNamedArguments(); + } + + public function getSelfOutType(): ?Type + { + return $this->nativeMethodReflection->getSelfOutType(); + } + + public function returnsByReference(): TrinaryLogic + { + return $this->nativeMethodReflection->returnsByReference(); + } + + public function isAbstract(): TrinaryLogic + { + $abstract = $this->nativeMethodReflection->isAbstract(); + if (is_bool($abstract)) { + return TrinaryLogic::createFromBoolean($abstract); + } + + return $abstract; + } + + public function isPure(): TrinaryLogic + { + return $this->nativeMethodReflection->isPure(); + } + + public function getAttributes(): array + { + return $this->nativeMethodReflection->getAttributes(); + } + +} diff --git a/src/Reflection/Php/ClosureCallUnresolvedMethodPrototypeReflection.php b/src/Reflection/Php/ClosureCallUnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..753a6821 --- /dev/null +++ b/src/Reflection/Php/ClosureCallUnresolvedMethodPrototypeReflection.php @@ -0,0 +1,38 @@ +prototype->doNotResolveTemplateTypeMapToBounds(), $this->closure); + } + + public function getNakedMethod(): ExtendedMethodReflection + { + return $this->getTransformedMethod(); + } + + public function getTransformedMethod(): ExtendedMethodReflection + { + return new ClosureCallMethodReflection($this->prototype->getTransformedMethod(), $this->closure); + } + + public function withCalledOnType(Type $type): UnresolvedMethodPrototypeReflection + { + return new self($this->prototype->withCalledOnType($type), $this->closure); + } + +} diff --git a/src/Reflection/Php/DummyParameter.php b/src/Reflection/Php/DummyParameter.php new file mode 100644 index 00000000..a1e9cebf --- /dev/null +++ b/src/Reflection/Php/DummyParameter.php @@ -0,0 +1,50 @@ +passedByReference = $passedByReference ?? PassedByReference::createNo(); + } + + public function getName(): string + { + return $this->name; + } + + public function isOptional(): bool + { + return $this->optional; + } + + public function getType(): Type + { + return $this->type; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + +} diff --git a/src/Reflection/Php/EnumAllowedSubTypesClassReflectionExtension.php b/src/Reflection/Php/EnumAllowedSubTypesClassReflectionExtension.php new file mode 100644 index 00000000..4f1bcb4c --- /dev/null +++ b/src/Reflection/Php/EnumAllowedSubTypesClassReflectionExtension.php @@ -0,0 +1,29 @@ +isEnum(); + } + + public function getAllowedSubTypes(ClassReflection $classReflection): array + { + $cases = []; + foreach (array_keys($classReflection->getEnumCases()) as $name) { + $cases[] = new EnumCaseObjectType($classReflection->getName(), $name); + } + + return $cases; + } + +} diff --git a/src/Reflection/Php/EnumCasesMethodReflection.php b/src/Reflection/Php/EnumCasesMethodReflection.php new file mode 100644 index 00000000..5004d9fb --- /dev/null +++ b/src/Reflection/Php/EnumCasesMethodReflection.php @@ -0,0 +1,160 @@ +declaringClass; + } + + public function isStatic(): bool + { + return true; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getName(): string + { + return 'cases'; + } + + public function getPrototype(): ClassMemberReflection + { + $unitEnum = $this->declaringClass->getAncestorWithClassName('UnitEnum'); + if ($unitEnum === null) { + throw new ShouldNotHappenException(); + } + + return $unitEnum->getNativeMethod('cases'); + } + + public function getVariants(): array + { + return [ + new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [], + false, + $this->returnType, + new MixedType(), + $this->returnType, + ), + ]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->declaringClass->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/EnumPropertyReflection.php b/src/Reflection/Php/EnumPropertyReflection.php new file mode 100644 index 00000000..f55fc14c --- /dev/null +++ b/src/Reflection/Php/EnumPropertyReflection.php @@ -0,0 +1,146 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): Type + { + return new MixedType(); + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->type; + } + + public function getWritableType(): Type + { + return $this->type; + } + + public function canChangeTypeAfterAssignment(): bool + { + return true; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return false; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/EnumUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Php/EnumUnresolvedPropertyPrototypeReflection.php new file mode 100644 index 00000000..4458ef00 --- /dev/null +++ b/src/Reflection/Php/EnumUnresolvedPropertyPrototypeReflection.php @@ -0,0 +1,37 @@ +property; + } + + public function getTransformedProperty(): ExtendedPropertyReflection + { + return $this->property; + } + + public function withFechedOnType(Type $type): UnresolvedPropertyPrototypeReflection + { + return $this; + } + +} diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php new file mode 100644 index 00000000..33d1b368 --- /dev/null +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -0,0 +1,147 @@ +name; + } + + public function getFileName(): ?string + { + return null; + } + + public function getVariants(): array + { + $parameterType = new UnionType([ + new StringType(), + new IntegerType(), + ]); + return [ + new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [ + new ExtendedDummyParameter( + 'status', + $parameterType, + true, + PassedByReference::createNo(), + false, + new ConstantIntegerType(0), + $parameterType, + new MixedType(), + null, + TrinaryLogic::createNo(), + null, + [], + ), + ], + false, + new NeverType(true), + new MixedType(), + new NeverType(true), + TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + /** + * @return list + */ + public function getNamedArgumentsVariants(): array + { + return $this->getVariants(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isBuiltin(): bool + { + return true; + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php new file mode 100644 index 00000000..ff3a4f91 --- /dev/null +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -0,0 +1,72 @@ + $attributes + */ + public function __construct( + string $name, + Type $type, + bool $optional, + ?PassedByReference $passedByReference, + bool $variadic, + ?Type $defaultValue, + private Type $nativeType, + private Type $phpDocType, + private ?Type $outType, + private TrinaryLogic $immediatelyInvokedCallable, + private ?Type $closureThisType, + private array $attributes, + ) + { + parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); + } + + public function getPhpDocType(): Type + { + return $this->phpDocType; + } + + public function hasNativeType(): bool + { + return !$this->nativeType instanceof MixedType || $this->nativeType->isExplicitMixed(); + } + + public function getNativeType(): Type + { + return $this->nativeType; + } + + public function getOutType(): ?Type + { + return $this->outType; + } + + public function isImmediatelyInvokedCallable(): TrinaryLogic + { + return $this->immediatelyInvokedCallable; + } + + public function getClosureThisType(): ?Type + { + return $this->closureThisType; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php new file mode 100644 index 00000000..01f5131d --- /dev/null +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -0,0 +1,1225 @@ +> */ + private array $propertyTypesCache = []; + + /** @var array */ + private array $inferClassConstructorPropertyTypesInProcess = []; + + public function __construct( + private ScopeFactory $scopeFactory, + private NodeScopeResolver $nodeScopeResolver, + private PhpMethodReflectionFactory $methodReflectionFactory, + private PhpDocInheritanceResolver $phpDocInheritanceResolver, + private AnnotationsMethodsClassReflectionExtension $annotationsMethodsClassReflectionExtension, + private AnnotationsPropertiesClassReflectionExtension $annotationsPropertiesClassReflectionExtension, + private SignatureMapProvider $signatureMapProvider, + private Parser $parser, + private StubPhpDocProvider $stubPhpDocProvider, + private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, + private FileTypeMapper $fileTypeMapper, + private AttributeReflectionFactory $attributeReflectionFactory, + private bool $inferPrivatePropertyTypeFromConstructor, + ) + { + } + + public function evictPrivateSymbols(string $classCacheKey): void + { + foreach ($this->propertiesIncludingAnnotations as $key => $properties) { + if ($key !== $classCacheKey) { + continue; + } + foreach ($properties as $name => $property) { + if (!$property->isPrivate()) { + continue; + } + unset($this->propertiesIncludingAnnotations[$key][$name]); + } + } + foreach ($this->nativeProperties as $key => $properties) { + if ($key !== $classCacheKey) { + continue; + } + foreach ($properties as $name => $property) { + if (!$property->isPrivate()) { + continue; + } + unset($this->nativeProperties[$key][$name]); + } + } + foreach ($this->methodsIncludingAnnotations as $key => $methods) { + if ($key !== $classCacheKey) { + continue; + } + foreach ($methods as $name => $method) { + if (!$method->isPrivate()) { + continue; + } + unset($this->methodsIncludingAnnotations[$key][$name]); + } + } + foreach ($this->nativeMethods as $key => $methods) { + if ($key !== $classCacheKey) { + continue; + } + foreach ($methods as $name => $method) { + if (!$method->isPrivate()) { + continue; + } + unset($this->nativeMethods[$key][$name]); + } + } + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return $classReflection->getNativeReflection()->hasProperty($propertyName); + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): ExtendedPropertyReflection + { + if (!isset($this->propertiesIncludingAnnotations[$classReflection->getCacheKey()][$propertyName])) { + $this->propertiesIncludingAnnotations[$classReflection->getCacheKey()][$propertyName] = $this->createProperty($classReflection, $propertyName, true); + } + + return $this->propertiesIncludingAnnotations[$classReflection->getCacheKey()][$propertyName]; + } + + public function getNativeProperty(ClassReflection $classReflection, string $propertyName): PhpPropertyReflection + { + if (!isset($this->nativeProperties[$classReflection->getCacheKey()][$propertyName])) { + /** @var PhpPropertyReflection $property */ + $property = $this->createProperty($classReflection, $propertyName, false); + $this->nativeProperties[$classReflection->getCacheKey()][$propertyName] = $property; + } + + return $this->nativeProperties[$classReflection->getCacheKey()][$propertyName]; + } + + private function createProperty( + ClassReflection $classReflection, + string $propertyName, + bool $includingAnnotations, + ): ExtendedPropertyReflection + { + $propertyReflection = $classReflection->getNativeReflection()->getProperty($propertyName); + $propertyName = $propertyReflection->getName(); + $declaringClassName = $propertyReflection->getDeclaringClass()->getName(); + $declaringClassReflection = $classReflection->getAncestorWithClassName($declaringClassName); + if ($declaringClassReflection === null) { + throw new ShouldNotHappenException(sprintf( + 'Internal error: Expected to find an ancestor with class name %s on %s, but none was found.', + $declaringClassName, + $classReflection->getName(), + )); + } + + if ($declaringClassReflection->isEnum()) { + if ( + $propertyName === 'name' + || ($declaringClassReflection->isBackedEnum() && $propertyName === 'value') + ) { + $types = []; + foreach (array_keys($classReflection->getEnumCases()) as $name) { + if ($propertyName === 'name') { + $types[] = new ConstantStringType($name); + continue; + } + + $case = $classReflection->getEnumCase($name); + $value = $case->getBackingValueType(); + if ($value === null) { + throw new ShouldNotHappenException(); + } + + $types[] = $value; + } + + return new PhpPropertyReflection($declaringClassReflection, null, null, TypeCombinator::union(...$types), $classReflection->getNativeReflection()->getProperty($propertyName), null, null, null, false, false, false, false, []); + } + } + + $deprecatedDescription = null; + $isDeprecated = false; + $isInternal = false; + $isReadOnlyByPhpDoc = $classReflection->isImmutable(); + $isAllowedPrivateMutation = false; + + if ( + $includingAnnotations + && !$declaringClassReflection->isEnum() + && $this->annotationsPropertiesClassReflectionExtension->hasProperty($classReflection, $propertyName) + ) { + $hierarchyDistances = $classReflection->getClassHierarchyDistances(); + $annotationProperty = $this->annotationsPropertiesClassReflectionExtension->getProperty($classReflection, $propertyName); + if (!isset($hierarchyDistances[$annotationProperty->getDeclaringClass()->getName()])) { + throw new ShouldNotHappenException(); + } + + $distanceDeclaringClass = $propertyReflection->getDeclaringClass()->getName(); + $propertyTrait = $this->findPropertyTrait($propertyReflection); + if ($propertyTrait !== null) { + $distanceDeclaringClass = $propertyTrait; + } + if (!isset($hierarchyDistances[$distanceDeclaringClass])) { + throw new ShouldNotHappenException(); + } + + if ($hierarchyDistances[$annotationProperty->getDeclaringClass()->getName()] <= $hierarchyDistances[$distanceDeclaringClass]) { + return $annotationProperty; + } + } + + $docComment = $propertyReflection->getDocComment() !== false + ? $propertyReflection->getDocComment() + : null; + + $phpDocType = null; + $resolvedPhpDoc = null; + $declaringTraitName = $this->findPropertyTrait($propertyReflection); + $constructorName = null; + if ($propertyReflection->isPromoted()) { + if ($declaringClassReflection->hasConstructor()) { + $constructorName = $declaringClassReflection->getConstructor()->getName(); + } + } + + if ($constructorName === null) { + $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForProperty( + $docComment, + $declaringClassReflection, + $declaringClassReflection->getFileName(), + $declaringTraitName, + $propertyName, + ); + } elseif ($docComment !== null) { + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $declaringClassReflection->getFileName(), + $declaringClassName, + $declaringTraitName, + $constructorName, + $docComment, + ); + } + $phpDocBlockClassReflection = $declaringClassReflection; + + if ($resolvedPhpDoc !== null) { + $varTags = $resolvedPhpDoc->getVarTags(); + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } elseif (isset($varTags[$propertyName])) { + $phpDocType = $varTags[$propertyName]->getType(); + } + + $phpDocType = $phpDocType !== null ? TemplateTypeHelper::resolveTemplateTypes( + $phpDocType, + $phpDocBlockClassReflection->getActiveTemplateTypeMap(), + $phpDocBlockClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createInvariant(), + ) : null; + $deprecatedDescription = $resolvedPhpDoc->getDeprecatedTag() !== null ? $resolvedPhpDoc->getDeprecatedTag()->getMessage() : null; + $isDeprecated = $resolvedPhpDoc->isDeprecated(); + $isInternal = $resolvedPhpDoc->isInternal(); + $isReadOnlyByPhpDoc = $isReadOnlyByPhpDoc || $resolvedPhpDoc->isReadOnly(); + $isAllowedPrivateMutation = $resolvedPhpDoc->isAllowedPrivateMutation(); + } + + if ($phpDocType === null) { + if (isset($constructorName)) { + $constructorDocComment = $declaringClassReflection->getConstructor()->getDocComment(); + $nativeClassReflection = $declaringClassReflection->getNativeReflection(); + $positionalParameterNames = []; + if ($nativeClassReflection->getConstructor() !== null) { + $positionalParameterNames = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $nativeClassReflection->getConstructor()->getParameters()); + } + $resolvedConstructorPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForMethod( + $constructorDocComment, + $declaringClassReflection->getFileName(), + $declaringClassReflection, + $declaringTraitName, + $constructorName, + $positionalParameterNames, + ); + $paramTags = $resolvedConstructorPhpDoc->getParamTags(); + if (isset($paramTags[$propertyReflection->getName()])) { + $phpDocType = $paramTags[$propertyReflection->getName()]->getType(); + } + } + } + + if ( + $phpDocType === null + && $this->inferPrivatePropertyTypeFromConstructor + && $declaringClassReflection->getFileName() !== null + && $propertyReflection->isPrivate() + && !$propertyReflection->isPromoted() + && !$propertyReflection->hasType() + && $declaringClassReflection->hasConstructor() + && $declaringClassReflection->getConstructor()->getDeclaringClass()->getName() === $declaringClassReflection->getName() + ) { + $phpDocType = $this->inferPrivatePropertyType( + $propertyReflection->getName(), + $declaringClassReflection->getConstructor(), + ); + } + + $nativeType = null; + if ($propertyReflection->getType() !== null) { + $nativeType = $propertyReflection->getType(); + } + + $declaringTrait = null; + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + if ( + $declaringTraitName !== null && $reflectionProvider->hasClass($declaringTraitName) + ) { + $declaringTrait = $reflectionProvider->getClass($declaringTraitName); + } + + $getHook = null; + $setHook = null; + + $betterReflection = $propertyReflection->getBetterReflection(); + if ($betterReflection->hasHook('get')) { + $betterReflectionGetHook = $betterReflection->getHook('get'); + if ($betterReflectionGetHook === null) { + throw new ShouldNotHappenException(); + } + $getHook = $this->createUserlandMethodReflection( + $declaringClassReflection, + $declaringClassReflection, + new ReflectionMethod($betterReflectionGetHook), + $declaringTraitName, + ); + + if ($phpDocType !== null) { + $getHookMethodReflectionVariant = $getHook->getOnlyVariant(); + $getHookMethodReflectionVariantPhpDocReturnType = $getHookMethodReflectionVariant->getPhpDocReturnType(); + if ( + $getHookMethodReflectionVariantPhpDocReturnType instanceof MixedType + && !$getHookMethodReflectionVariantPhpDocReturnType instanceof TemplateMixedType + && !$getHookMethodReflectionVariantPhpDocReturnType->isExplicitMixed() + ) { + $getHook = $getHook->changePropertyGetHookPhpDocType($phpDocType); + } + } + } + + if ($betterReflection->hasHook('set')) { + $betterReflectionSetHook = $betterReflection->getHook('set'); + if ($betterReflectionSetHook === null) { + throw new ShouldNotHappenException(); + } + $setHook = $this->createUserlandMethodReflection( + $declaringClassReflection, + $declaringClassReflection, + new ReflectionMethod($betterReflectionSetHook), + $declaringTraitName, + ); + + if ($phpDocType !== null) { + $setHookMethodReflectionVariant = $setHook->getOnlyVariant(); + $setHookMethodReflectionParameters = $setHookMethodReflectionVariant->getParameters(); + if (isset($setHookMethodReflectionParameters[0])) { + $setHookMethodReflectionParameter = $setHookMethodReflectionParameters[0]; + $setHookMethodReflectionParameterPhpDocType = $setHookMethodReflectionParameter->getPhpDocType(); + if ( + $setHookMethodReflectionParameterPhpDocType instanceof MixedType + && !$setHookMethodReflectionParameterPhpDocType instanceof TemplateMixedType + && !$setHookMethodReflectionParameterPhpDocType->isExplicitMixed() + ) { + $setHook = $setHook->changePropertySetHookPhpDocType($setHookMethodReflectionParameter->getName(), $phpDocType); + } + } + } + } + + return new PhpPropertyReflection( + $declaringClassReflection, + $declaringTrait, + $nativeType, + $phpDocType, + $propertyReflection, + $getHook, + $setHook, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isReadOnlyByPhpDoc, + $isAllowedPrivateMutation, + $this->attributeReflectionFactory->fromNativeReflection($propertyReflection->getAttributes(), InitializerExprContext::fromClass($declaringClassReflection->getName(), $declaringClassReflection->getFileName())), + ); + } + + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $classReflection->getNativeReflection()->hasMethod($methodName); + } + + public function getMethod(ClassReflection $classReflection, string $methodName): ExtendedMethodReflection + { + if (isset($this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$methodName])) { + return $this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$methodName]; + } + + $nativeMethodReflection = $classReflection->getNativeReflection()->getMethod($methodName); + if (!isset($this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$nativeMethodReflection->getName()])) { + $method = $this->createMethod($classReflection, $nativeMethodReflection, true); + $this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$nativeMethodReflection->getName()] = $method; + if ($nativeMethodReflection->getName() !== $methodName) { + $this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$methodName] = $method; + } + } + + return $this->methodsIncludingAnnotations[$classReflection->getCacheKey()][$nativeMethodReflection->getName()]; + } + + public function hasNativeMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->hasMethod($classReflection, $methodName); + } + + public function getNativeMethod(ClassReflection $classReflection, string $methodName): ExtendedMethodReflection + { + if (isset($this->nativeMethods[$classReflection->getCacheKey()][$methodName])) { + return $this->nativeMethods[$classReflection->getCacheKey()][$methodName]; + } + + if (!$classReflection->getNativeReflection()->hasMethod($methodName)) { + throw new ShouldNotHappenException(); + } + + $nativeMethodReflection = $classReflection->getNativeReflection()->getMethod($methodName); + + if (!isset($this->nativeMethods[$classReflection->getCacheKey()][$nativeMethodReflection->getName()])) { + $method = $this->createMethod($classReflection, $nativeMethodReflection, false); + $this->nativeMethods[$classReflection->getCacheKey()][$nativeMethodReflection->getName()] = $method; + } + + return $this->nativeMethods[$classReflection->getCacheKey()][$nativeMethodReflection->getName()]; + } + + private function createMethod( + ClassReflection $classReflection, + ReflectionMethod $methodReflection, + bool $includingAnnotations, + ): ExtendedMethodReflection + { + if ($includingAnnotations && $this->annotationsMethodsClassReflectionExtension->hasMethod($classReflection, $methodReflection->getName())) { + $hierarchyDistances = $classReflection->getClassHierarchyDistances(); + $annotationMethod = $this->annotationsMethodsClassReflectionExtension->getMethod($classReflection, $methodReflection->getName()); + if (!isset($hierarchyDistances[$annotationMethod->getDeclaringClass()->getName()])) { + throw new ShouldNotHappenException(); + } + + $distanceDeclaringClass = $methodReflection->getDeclaringClass()->getName(); + $methodTrait = $this->findMethodTrait($methodReflection); + if ($methodTrait !== null) { + $distanceDeclaringClass = $methodTrait; + } + if (!isset($hierarchyDistances[$distanceDeclaringClass])) { + throw new ShouldNotHappenException(); + } + + if ($hierarchyDistances[$annotationMethod->getDeclaringClass()->getName()] <= $hierarchyDistances[$distanceDeclaringClass]) { + return $annotationMethod; + } + } + $declaringClassName = $methodReflection->getDeclaringClass()->getName(); + $declaringClass = $classReflection->getAncestorWithClassName($declaringClassName); + + if ($declaringClass === null) { + throw new ShouldNotHappenException(sprintf( + 'Internal error: Expected to find an ancestor with class name %s on %s, but none was found.', + $declaringClassName, + $classReflection->getName(), + )); + } + + if ( + $declaringClass->isEnum() + && $declaringClass->getName() !== 'UnitEnum' + && strtolower($methodReflection->getName()) === 'cases' + ) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach (array_keys($classReflection->getEnumCases()) as $name) { + $arrayBuilder->setOffsetValueType(null, new EnumCaseObjectType($classReflection->getName(), $name)); + } + + return new EnumCasesMethodReflection($declaringClass, $arrayBuilder->getArray()); + } + + if ($this->signatureMapProvider->hasMethodSignature($declaringClassName, $methodReflection->getName())) { + $variantsByType = ['positional' => []]; + $throwType = null; + $asserts = Assertions::createEmpty(); + $acceptsNamedArguments = true; + $selfOutType = null; + $phpDocComment = null; + $methodSignaturesResult = $this->signatureMapProvider->getMethodSignatures($declaringClassName, $methodReflection->getName(), $methodReflection); + foreach ($methodSignaturesResult as $signatureType => $methodSignatures) { + if ($methodSignatures === null) { + continue; + } + + foreach ($methodSignatures as $methodSignature) { + $phpDocParameterNameMapping = []; + foreach ($methodSignature->getParameters() as $parameter) { + $phpDocParameterNameMapping[$parameter->getName()] = $parameter->getName(); + } + $stubPhpDocReturnType = null; + $stubPhpDocParameterTypes = []; + $stubPhpDocParameterVariadicity = []; + $phpDocParameterTypes = []; + $phpDocReturnType = null; + $stubPhpDocPair = null; + $stubPhpParameterOutTypes = []; + $phpDocParameterOutTypes = []; + $immediatelyInvokedCallableParameters = []; + $closureThisParameters = []; + $stubImmediatelyInvokedCallableParameters = []; + $stubClosureThisParameters = []; + if (count($methodSignatures) === 1) { + $stubPhpDocPair = $this->findMethodPhpDocIncludingAncestors($declaringClass, $declaringClass, $methodReflection->getName(), array_map(static fn (ParameterSignature $parameterSignature): string => $parameterSignature->getName(), $methodSignature->getParameters())); + if ($stubPhpDocPair !== null) { + [$stubPhpDoc, $stubDeclaringClass] = $stubPhpDocPair; + $templateTypeMap = $stubDeclaringClass->getActiveTemplateTypeMap(); + $callSiteVarianceMap = $stubDeclaringClass->getCallSiteVarianceMap(); + $returnTag = $stubPhpDoc->getReturnTag(); + $stubImmediatelyInvokedCallableParameters = array_map(static fn (bool $immediate) => TrinaryLogic::createFromBoolean($immediate), $stubPhpDoc->getParamsImmediatelyInvokedCallable()); + if ($returnTag !== null) { + $stubPhpDocReturnType = TemplateTypeHelper::resolveTemplateTypes( + $returnTag->getType(), + $templateTypeMap, + $callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ); + } + + $stubClosureThisParameters = array_map(static fn ($tag) => $tag->getType(), $stubPhpDoc->getParamClosureThisTags()); + foreach ($stubPhpDoc->getParamTags() as $name => $paramTag) { + $stubPhpDocParameterTypes[$name] = TemplateTypeHelper::resolveTemplateTypes( + $paramTag->getType(), + $templateTypeMap, + $callSiteVarianceMap, + TemplateTypeVariance::createContravariant(), + ); + $stubPhpDocParameterVariadicity[$name] = $paramTag->isVariadic(); + } + + $throwsTag = $stubPhpDoc->getThrowsTag(); + if ($throwsTag !== null) { + $throwType = $throwsTag->getType(); + } + + $asserts = Assertions::createFromResolvedPhpDocBlock($stubPhpDoc); + $acceptsNamedArguments = $stubPhpDoc->acceptsNamedArguments(); + + $selfOutTypeTag = $stubPhpDoc->getSelfOutTag(); + if ($selfOutTypeTag !== null) { + $selfOutType = $selfOutTypeTag->getType(); + } + + foreach ($stubPhpDoc->getParamOutTags() as $name => $paramOutTag) { + $stubPhpParameterOutTypes[$name] = TemplateTypeHelper::resolveTemplateTypes( + $paramOutTag->getType(), + $templateTypeMap, + $callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ); + } + + if ($declaringClassName === $stubDeclaringClass->getName() && $stubPhpDoc->hasPhpDocString()) { + $phpDocComment = $stubPhpDoc->getPhpDocString(); + } + } + } + if ($stubPhpDocPair === null && $methodReflection->getDocComment() !== false) { + $filename = $methodReflection->getFileName(); + if ($filename !== false) { + $phpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc( + $filename, + $declaringClassName, + null, + $methodReflection->getName(), + $methodReflection->getDocComment(), + ); + $throwsTag = $phpDocBlock->getThrowsTag(); + if ($throwsTag !== null) { + $throwType = $throwsTag->getType(); + } + $returnTag = $phpDocBlock->getReturnTag(); + if ($returnTag !== null && count($methodSignatures) === 1) { + $phpDocReturnType = $returnTag->getType(); + } + $immediatelyInvokedCallableParameters = array_map(static fn ($immediate) => TrinaryLogic::createFromBoolean($immediate), $phpDocBlock->getParamsImmediatelyInvokedCallable()); + $closureThisParameters = array_map(static fn ($tag) => $tag->getType(), $phpDocBlock->getParamClosureThisTags()); + foreach ($phpDocBlock->getParamTags() as $name => $paramTag) { + $phpDocParameterTypes[$name] = $paramTag->getType(); + } + $asserts = Assertions::createFromResolvedPhpDocBlock($phpDocBlock); + $acceptsNamedArguments = $phpDocBlock->acceptsNamedArguments(); + + $selfOutTypeTag = $phpDocBlock->getSelfOutTag(); + if ($selfOutTypeTag !== null) { + $selfOutType = $selfOutTypeTag->getType(); + } + + if ($phpDocBlock->hasPhpDocString()) { + $phpDocComment = $phpDocBlock->getPhpDocString(); + } + + foreach ($phpDocBlock->getParamOutTags() as $name => $paramOutTag) { + $phpDocParameterOutTypes[$name] = $paramOutTag->getType(); + } + + $signatureParameters = $methodSignature->getParameters(); + foreach ($methodReflection->getParameters() as $paramI => $reflectionParameter) { + if (!array_key_exists($paramI, $signatureParameters)) { + continue; + } + + $phpDocParameterNameMapping[$signatureParameters[$paramI]->getName()] = $reflectionParameter->getName(); + } + } + } + $variantsByType[$signatureType][] = $this->createNativeMethodVariant($methodSignature, $stubPhpDocParameterTypes, $stubPhpDocParameterVariadicity, $stubPhpDocReturnType, $phpDocParameterTypes, $phpDocReturnType, $phpDocParameterNameMapping, $stubPhpParameterOutTypes, $phpDocParameterOutTypes, $stubImmediatelyInvokedCallableParameters, $immediatelyInvokedCallableParameters, $stubClosureThisParameters, $closureThisParameters, $signatureType !== 'named'); + } + } + + if ($this->signatureMapProvider->hasMethodMetadata($declaringClassName, $methodReflection->getName())) { + $hasSideEffects = TrinaryLogic::createFromBoolean($this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName())['hasSideEffects']); + } else { + $hasSideEffects = TrinaryLogic::createMaybe(); + } + return new NativeMethodReflection( + $this->reflectionProviderProvider->getReflectionProvider(), + $declaringClass, + $methodReflection, + $variantsByType['positional'], + $variantsByType['named'] ?? null, + $hasSideEffects, + $throwType, + $asserts, + $acceptsNamedArguments, + $selfOutType, + $phpDocComment, + $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($declaringClassName, null, $methodReflection->getName(), null)), + ); + } + + return $this->createUserlandMethodReflection( + $declaringClass, + $declaringClass, + $methodReflection, + $this->findMethodTrait($methodReflection), + ); + } + + public function createUserlandMethodReflection(ClassReflection $fileDeclaringClass, ClassReflection $actualDeclaringClass, ReflectionMethod $methodReflection, ?string $declaringTraitName): PhpMethodReflection + { + $resolvedPhpDoc = null; + $stubPhpDocPair = $this->findMethodPhpDocIncludingAncestors($fileDeclaringClass, $fileDeclaringClass, $methodReflection->getName(), array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $methodReflection->getParameters())); + $phpDocBlockClassReflection = $fileDeclaringClass; + + $methodDeclaringClass = $methodReflection->getBetterReflection()->getDeclaringClass(); + + if ($stubPhpDocPair === null && $methodDeclaringClass->isTrait()) { + if (! $methodReflection->getDeclaringClass()->isTrait() || $methodDeclaringClass->getName() !== $methodReflection->getDeclaringClass()->getName()) { + $stubPhpDocPair = $this->findMethodPhpDocIncludingAncestors( + $this->reflectionProviderProvider->getReflectionProvider()->getClass($methodDeclaringClass->getName()), + $this->reflectionProviderProvider->getReflectionProvider()->getClass($methodReflection->getDeclaringClass()->getName()), + $methodReflection->getName(), + array_map( + static fn (ReflectionParameter $parameter): string => $parameter->getName(), + $methodReflection->getParameters(), + ), + ); + } + } + + if ($stubPhpDocPair !== null) { + [$resolvedPhpDoc, $phpDocBlockClassReflection] = $stubPhpDocPair; + } + + if ($resolvedPhpDoc === null) { + $docComment = $methodReflection->getDocComment() !== false ? $methodReflection->getDocComment() : null; + $positionalParameterNames = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $methodReflection->getParameters()); + + $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForMethod( + $docComment, + $actualDeclaringClass->getFileName(), + $actualDeclaringClass, + $declaringTraitName, + $methodReflection->getName(), + $positionalParameterNames, + ); + $phpDocBlockClassReflection = $fileDeclaringClass; + } + + $declaringTrait = null; + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + if ( + $declaringTraitName !== null && $reflectionProvider->hasClass($declaringTraitName) + ) { + $declaringTrait = $reflectionProvider->getClass($declaringTraitName); + } + + $phpDocParameterTypes = []; + if ($methodReflection->isConstructor()) { + foreach ($methodReflection->getParameters() as $parameter) { + if (!$parameter->isPromoted()) { + continue; + } + + if (!$methodReflection->getDeclaringClass()->hasProperty($parameter->getName())) { + continue; + } + + $parameterProperty = $methodReflection->getDeclaringClass()->getProperty($parameter->getName()); + if (!$parameterProperty->isPromoted()) { + continue; + } + if ($parameterProperty->getDocComment() === false) { + continue; + } + + $propertyDocblock = $this->fileTypeMapper->getResolvedPhpDoc( + $fileDeclaringClass->getFileName(), + $fileDeclaringClass->getName(), + $declaringTraitName, + $methodReflection->getName(), + $parameterProperty->getDocComment(), + ); + $varTags = $propertyDocblock->getVarTags(); + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } elseif (isset($varTags[$parameter->getName()])) { + $phpDocType = $varTags[$parameter->getName()]->getType(); + } else { + continue; + } + + $phpDocParameterTypes[$parameter->getName()] = $phpDocType; + } + } + + $templateTypeMap = $resolvedPhpDoc->getTemplateTypeMap(); + $immediatelyInvokedCallableParameters = array_map(static fn (bool $immediate) => TrinaryLogic::createFromBoolean($immediate), $resolvedPhpDoc->getParamsImmediatelyInvokedCallable()); + $closureThisParameters = array_map(static fn ($tag) => $tag->getType(), $resolvedPhpDoc->getParamClosureThisTags()); + + foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { + if (array_key_exists($paramName, $phpDocParameterTypes)) { + continue; + } + $phpDocParameterTypes[$paramName] = $paramTag->getType(); + } + foreach ($phpDocParameterTypes as $paramName => $paramType) { + $phpDocParameterTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( + $paramType, + $phpDocBlockClassReflection->getActiveTemplateTypeMap(), + $phpDocBlockClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createContravariant(), + ); + } + + $phpDocParameterOutTypes = []; + foreach ($resolvedPhpDoc->getParamOutTags() as $paramName => $paramOutTag) { + $phpDocParameterOutTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( + $paramOutTag->getType(), + $phpDocBlockClassReflection->getActiveTemplateTypeMap(), + $phpDocBlockClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createCovariant(), + ); + } + + $nativeReturnType = TypehintHelper::decideTypeFromReflection( + $methodReflection->getReturnType(), + null, + $actualDeclaringClass, + ); + $phpDocReturnType = $this->getPhpDocReturnType($phpDocBlockClassReflection, $resolvedPhpDoc, $nativeReturnType); + $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; + $deprecatedDescription = $resolvedPhpDoc->getDeprecatedTag() !== null ? $resolvedPhpDoc->getDeprecatedTag()->getMessage() : null; + $isDeprecated = $resolvedPhpDoc->isDeprecated(); + $isInternal = $resolvedPhpDoc->isInternal(); + $isFinal = $resolvedPhpDoc->isFinal(); + $isPure = $resolvedPhpDoc->isPure(); + $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); + $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); + $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; + $phpDocComment = null; + if ($resolvedPhpDoc->hasPhpDocString()) { + $phpDocComment = $resolvedPhpDoc->getPhpDocString(); + } + + return $this->methodReflectionFactory->create( + $actualDeclaringClass, + $declaringTrait, + $methodReflection, + $templateTypeMap, + $phpDocParameterTypes, + $phpDocReturnType, + $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isFinal, + $isPure, + $asserts, + $selfOutType, + $phpDocComment, + $phpDocParameterOutTypes, + $immediatelyInvokedCallableParameters, + $closureThisParameters, + $acceptsNamedArguments, + $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($actualDeclaringClass->getName(), $declaringTraitName, $methodReflection->getName(), $actualDeclaringClass->getFileName())), + ); + } + + /** + * @param array $stubPhpDocParameterTypes + * @param array $stubPhpDocParameterVariadicity + * @param array $phpDocParameterTypes + * @param array $phpDocParameterNameMapping + * @param array $stubPhpDocParameterOutTypes + * @param array $phpDocParameterOutTypes + * @param array $stubImmediatelyInvokedCallableParameters + * @param array $immediatelyInvokedCallableParameters + * @param array $stubClosureThisParameters + * @param array $closureThisParameters + */ + private function createNativeMethodVariant( + FunctionSignature $methodSignature, + array $stubPhpDocParameterTypes, + array $stubPhpDocParameterVariadicity, + ?Type $stubPhpDocReturnType, + array $phpDocParameterTypes, + ?Type $phpDocReturnType, + array $phpDocParameterNameMapping, + array $stubPhpDocParameterOutTypes, + array $phpDocParameterOutTypes, + array $stubImmediatelyInvokedCallableParameters, + array $immediatelyInvokedCallableParameters, + array $stubClosureThisParameters, + array $closureThisParameters, + bool $usePhpDocParameterNames, + ): ExtendedFunctionVariant + { + $parameters = []; + foreach ($methodSignature->getParameters() as $parameterSignature) { + $type = null; + $phpDocType = null; + $parameterOutType = null; + + $phpDocParameterName = $phpDocParameterNameMapping[$parameterSignature->getName()] ?? $parameterSignature->getName(); + + if (isset($stubPhpDocParameterTypes[$parameterSignature->getName()])) { + $type = $stubPhpDocParameterTypes[$parameterSignature->getName()]; + $phpDocType = $stubPhpDocParameterTypes[$parameterSignature->getName()]; + } elseif (isset($phpDocParameterTypes[$phpDocParameterName])) { + $phpDocType = $phpDocParameterTypes[$phpDocParameterName]; + } + + if (isset($stubPhpDocParameterOutTypes[$parameterSignature->getName()])) { + $parameterOutType = $stubPhpDocParameterOutTypes[$parameterSignature->getName()]; + } elseif (isset($phpDocParameterOutTypes[$phpDocParameterName])) { + $parameterOutType = $phpDocParameterOutTypes[$phpDocParameterName]; + } + + if (isset($stubImmediatelyInvokedCallableParameters[$parameterSignature->getName()])) { + $immediatelyInvoked = $stubImmediatelyInvokedCallableParameters[$parameterSignature->getName()]; + } elseif (isset($immediatelyInvokedCallableParameters[$phpDocParameterName])) { + $immediatelyInvoked = $immediatelyInvokedCallableParameters[$phpDocParameterName]; + } else { + $immediatelyInvoked = TrinaryLogic::createMaybe(); + } + + $closureThisType = null; + if (isset($stubClosureThisParameters[$parameterSignature->getName()])) { + $closureThisType = $stubClosureThisParameters[$parameterSignature->getName()]; + } elseif (isset($closureThisParameters[$phpDocParameterName])) { + $closureThisType = $closureThisParameters[$phpDocParameterName]; + } + + $parameters[] = new ExtendedNativeParameterReflection( + $usePhpDocParameterNames + ? $phpDocParameterName + : $parameterSignature->getName(), + $parameterSignature->isOptional(), + $type ?? $parameterSignature->getType(), + $phpDocType ?? new MixedType(), + $parameterSignature->getNativeType(), + $parameterSignature->passedByReference(), + $stubPhpDocParameterVariadicity[$parameterSignature->getName()] ?? $parameterSignature->isVariadic(), + $parameterSignature->getDefaultValue(), + $parameterOutType ?? $parameterSignature->getOutType(), + $immediatelyInvoked, + $closureThisType, + [], + ); + } + + if ($stubPhpDocReturnType !== null) { + $returnType = $stubPhpDocReturnType; + $phpDocReturnType = $stubPhpDocReturnType; + } else { + $returnType = TypehintHelper::decideType($methodSignature->getReturnType(), $phpDocReturnType); + } + + return new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + null, + $parameters, + $methodSignature->isVariadic(), + $returnType, + $phpDocReturnType ?? new MixedType(), + $methodSignature->getNativeReturnType(), + ); + } + + private function findPropertyTrait(ReflectionProperty $propertyReflection): ?string + { + $declaringClass = $propertyReflection->getBetterReflection()->getDeclaringClass(); + if ($declaringClass->isTrait()) { + if ($propertyReflection->getDeclaringClass()->isTrait() && $propertyReflection->getDeclaringClass()->getName() === $declaringClass->getName()) { + return null; + } + + return $declaringClass->getName(); + } + + return null; + } + + private function findMethodTrait( + ReflectionMethod $methodReflection, + ): ?string + { + $declaringClass = $methodReflection->getBetterReflection()->getDeclaringClass(); + if ($declaringClass->isTrait()) { + if ($methodReflection->getDeclaringClass()->isTrait() && $declaringClass->getName() === $methodReflection->getDeclaringClass()->getName()) { + return null; + } + + return $declaringClass->getName(); + } + + return null; + } + + private function inferPrivatePropertyType( + string $propertyName, + MethodReflection $constructor, + ): ?Type + { + $declaringClassName = $constructor->getDeclaringClass()->getName(); + if (isset($this->inferClassConstructorPropertyTypesInProcess[$declaringClassName])) { + return null; + } + $this->inferClassConstructorPropertyTypesInProcess[$declaringClassName] = true; + $propertyTypes = $this->inferAndCachePropertyTypes($constructor); + unset($this->inferClassConstructorPropertyTypesInProcess[$declaringClassName]); + if (array_key_exists($propertyName, $propertyTypes)) { + return $propertyTypes[$propertyName]; + } + + return null; + } + + /** + * @return array + */ + private function inferAndCachePropertyTypes( + MethodReflection $constructor, + ): array + { + $declaringClass = $constructor->getDeclaringClass(); + if (isset($this->propertyTypesCache[$declaringClass->getName()])) { + return $this->propertyTypesCache[$declaringClass->getName()]; + } + if ($declaringClass->getFileName() === null) { + return $this->propertyTypesCache[$declaringClass->getName()] = []; + } + + $fileName = $declaringClass->getFileName(); + $nodes = $this->parser->parseFile($fileName); + $classNode = $this->findClassNode($declaringClass->getName(), $nodes); + if ($classNode === null) { + return $this->propertyTypesCache[$declaringClass->getName()] = []; + } + + $methodNode = $this->findConstructorNode($constructor->getName(), $classNode->stmts); + if ($methodNode === null || $methodNode->stmts === null || count($methodNode->stmts) === 0) { + return $this->propertyTypesCache[$declaringClass->getName()] = []; + } + + $classNameParts = explode('\\', $declaringClass->getName()); + $namespace = null; + if (count($classNameParts) > 1) { + $namespace = implode('\\', array_slice($classNameParts, 0, -1)); + } + + $classScope = $this->scopeFactory->create(ScopeContext::create($fileName)); + if ($namespace !== null) { + $classScope = $classScope->enterNamespace($namespace); + } + $classScope = $classScope->enterClass($declaringClass); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); + $methodScope = $classScope->enterClassMethod( + $methodNode, + $templateTypeMap, + $phpDocParameterTypes, + $phpDocReturnType, + $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isFinal, + $isPure, + $acceptsNamedArguments, + $asserts, + $selfOutType, + $phpDocComment, + $phpDocParameterOutTypes, + $phpDocImmediatelyInvokedCallableParameters, + $phpDocClosureThisTypeParameters, + ); + + $propertyTypes = []; + foreach ($methodNode->stmts as $statement) { + if (!$statement instanceof Node\Stmt\Expression) { + continue; + } + + $expr = $statement->expr; + if (!$expr instanceof Node\Expr\Assign) { + continue; + } + + if (!$expr->var instanceof Node\Expr\PropertyFetch) { + continue; + } + + $propertyFetch = $expr->var; + if ( + !$propertyFetch->var instanceof Node\Expr\Variable + || $propertyFetch->var->name !== 'this' + || !$propertyFetch->name instanceof Node\Identifier + ) { + continue; + } + + $propertyType = $methodScope->getType($expr->expr); + if ($propertyType instanceof ErrorType || $propertyType instanceof NeverType) { + continue; + } + + $propertyType = $propertyType->generalize(GeneralizePrecision::lessSpecific()); + if ($propertyType->isConstantArray()->yes()) { + $propertyType = new ArrayType(new MixedType(true), new MixedType(true)); + } + + $propertyTypes[$propertyFetch->name->toString()] = $propertyType; + } + + return $this->propertyTypesCache[$declaringClass->getName()] = $propertyTypes; + } + + /** + * @param Node[] $nodes + */ + private function findClassNode(string $className, array $nodes): ?Class_ + { + foreach ($nodes as $node) { + if ( + $node instanceof Class_ + && $node->namespacedName !== null + && $node->namespacedName->toString() === $className + ) { + return $node; + } + if ( + !$node instanceof Namespace_ + && !$node instanceof Declare_ + ) { + continue; + } + $subNodeNames = $node->getSubNodeNames(); + foreach ($subNodeNames as $subNodeName) { + $subNode = $node->{$subNodeName}; + if (!is_array($subNode)) { + $subNode = [$subNode]; + } + $result = $this->findClassNode($className, $subNode); + if ($result === null) { + continue; + } + return $result; + } + } + return null; + } + + /** + * @param Node\Stmt[] $classStatements + */ + private function findConstructorNode(string $methodName, array $classStatements): ?ClassMethod + { + foreach ($classStatements as $statement) { + if ( + $statement instanceof ClassMethod + && $statement->name->toString() === $methodName + ) { + return $statement; + } + } + return null; + } + + private function getPhpDocReturnType(ClassReflection $phpDocBlockClassReflection, ResolvedPhpDocBlock $resolvedPhpDoc, Type $nativeReturnType): ?Type + { + $returnTag = $resolvedPhpDoc->getReturnTag(); + + if ($returnTag === null) { + return null; + } + + $phpDocReturnType = $returnTag->getType(); + $phpDocReturnType = TemplateTypeHelper::resolveTemplateTypes( + $phpDocReturnType, + $phpDocBlockClassReflection->getActiveTemplateTypeMap(), + $phpDocBlockClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createCovariant(), + ); + + if ($returnTag->isExplicit() || $nativeReturnType->isSuperTypeOf($phpDocReturnType)->yes()) { + return $phpDocReturnType; + } + + return null; + } + + /** + * @param array $positionalParameterNames + * @return array{ResolvedPhpDocBlock, ClassReflection}|null + */ + private function findMethodPhpDocIncludingAncestors( + ClassReflection $declaringClass, + ClassReflection $implementingClass, + string $methodName, + array $positionalParameterNames, + ): ?array + { + $declaringClassName = $declaringClass->getName(); + $resolved = $this->stubPhpDocProvider->findMethodPhpDoc($declaringClassName, $implementingClass->getName(), $methodName, $positionalParameterNames); + if ($resolved !== null) { + return [$resolved, $declaringClass]; + } + if (!$this->stubPhpDocProvider->isKnownClass($declaringClassName)) { + return null; + } + + $ancestors = $declaringClass->getAncestors(); + foreach ($ancestors as $ancestor) { + if ($ancestor->getName() === $declaringClassName) { + continue; + } + if (!$ancestor->hasNativeMethod($methodName)) { + continue; + } + + $resolved = $this->stubPhpDocProvider->findMethodPhpDoc($ancestor->getName(), $ancestor->getName(), $methodName, $positionalParameterNames); + if ($resolved === null) { + continue; + } + + return [$resolved, $ancestor]; + } + + return null; + } + +} diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php new file mode 100644 index 00000000..8ed20253 --- /dev/null +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -0,0 +1,338 @@ +|null */ + private ?array $variants = null; + + /** + * @param Function_|ClassMethod|Node\PropertyHook $functionLike + * @param Type[] $realParameterTypes + * @param Type[] $phpDocParameterTypes + * @param Type[] $realParameterDefaultValues + * @param array> $parameterAttributes + * @param Type[] $parameterOutTypes + * @param array $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + * @param list $attributes + */ + public function __construct( + FunctionLike $functionLike, + private string $fileName, + private TemplateTypeMap $templateTypeMap, + private array $realParameterTypes, + private array $phpDocParameterTypes, + private array $realParameterDefaultValues, + private array $parameterAttributes, + private Type $realReturnType, + private ?Type $phpDocReturnType, + private ?Type $throwType, + private ?string $deprecatedDescription, + private bool $isDeprecated, + private bool $isInternal, + protected ?bool $isPure, + private bool $acceptsNamedArguments, + private Assertions $assertions, + private ?string $phpDocComment, + private array $parameterOutTypes, + private array $immediatelyInvokedCallableParameters, + private array $phpDocClosureThisTypeParameters, + private array $attributes, + ) + { + $this->functionLike = $functionLike; + } + + protected function getFunctionLike(): FunctionLike + { + return $this->functionLike; + } + + public function getFileName(): string + { + return $this->fileName; + } + + public function getName(): string + { + if ($this->functionLike instanceof ClassMethod) { + return $this->functionLike->name->name; + } + + if (!$this->functionLike instanceof Function_) { + // PropertyHook is handled in PhpMethodFromParserNodeReflection subclass + throw new ShouldNotHappenException(); + } + + if ($this->functionLike->namespacedName === null) { + throw new ShouldNotHappenException(); + } + + return (string) $this->functionLike->namespacedName; + } + + public function getVariants(): array + { + if ($this->variants === null) { + $this->variants = [ + new ExtendedFunctionVariant( + $this->getTemplateTypeMap(), + $this->getResolvedTemplateTypeMap(), + $this->getParameters(), + $this->isVariadic(), + $this->getReturnType(), + $this->getPhpDocReturnType(), + $this->getNativeReturnType(), + ), + ]; + } + + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return TemplateTypeMap::createEmpty(); + } + + /** + * @return list + */ + public function getParameters(): array + { + $parameters = []; + $isOptional = true; + + /** @var Node\Param $parameter */ + foreach (array_reverse($this->functionLike->getParams()) as $parameter) { + if ($parameter->default === null && !$parameter->variadic) { + $isOptional = false; + } + + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + + if (isset($this->immediatelyInvokedCallableParameters[$parameter->var->name])) { + $immediatelyInvokedCallable = TrinaryLogic::createFromBoolean($this->immediatelyInvokedCallableParameters[$parameter->var->name]); + } else { + $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); + } + + if (isset($this->phpDocClosureThisTypeParameters[$parameter->var->name])) { + $closureThisType = $this->phpDocClosureThisTypeParameters[$parameter->var->name]; + } else { + $closureThisType = null; + } + + $parameters[] = new PhpParameterFromParserNodeReflection( + $parameter->var->name, + $isOptional, + $this->realParameterTypes[$parameter->var->name], + $this->phpDocParameterTypes[$parameter->var->name] ?? null, + $parameter->byRef + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $this->realParameterDefaultValues[$parameter->var->name] ?? null, + $parameter->variadic, + $this->parameterOutTypes[$parameter->var->name] ?? null, + $immediatelyInvokedCallable, + $closureThisType, + $this->parameterAttributes[$parameter->var->name] ?? [], + ); + } + + return array_reverse($parameters); + } + + public function isVariadic(): bool + { + foreach ($this->functionLike->getParams() as $parameter) { + if ($parameter->variadic) { + return true; + } + } + + return false; + } + + public function getReturnType(): Type + { + return TypehintHelper::decideType($this->realReturnType, $this->phpDocReturnType); + } + + public function getPhpDocReturnType(): Type + { + return $this->phpDocReturnType ?? new MixedType(); + } + + public function getNativeReturnType(): Type + { + return $this->realReturnType; + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return TemplateTypeVarianceMap::createEmpty(); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->isDeprecated) { + return $this->deprecatedDescription; + } + + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isDeprecated); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isInternal); + } + + public function getThrowType(): ?Type + { + return $this->throwType; + } + + public function hasSideEffects(): TrinaryLogic + { + if ($this->getReturnType()->isVoid()->yes()) { + return TrinaryLogic::createYes(); + } + if ($this->isPure !== null) { + return TrinaryLogic::createFromBoolean(!$this->isPure); + } + + return TrinaryLogic::createMaybe(); + } + + public function isBuiltin(): bool + { + return false; + } + + public function isGenerator(): bool + { + return $this->nodeIsOrContainsYield($this->functionLike); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->acceptsNamedArguments); + } + + private function nodeIsOrContainsYield(Node $node): bool + { + if ($node instanceof Node\Expr\Yield_) { + return true; + } + + if ($node instanceof Node\Expr\YieldFrom) { + return true; + } + + foreach ($node->getSubNodeNames() as $nodeName) { + $nodeProperty = $node->$nodeName; + + if ($nodeProperty instanceof Node && $this->nodeIsOrContainsYield($nodeProperty)) { + return true; + } + + if (!is_array($nodeProperty)) { + continue; + } + + foreach ($nodeProperty as $nodePropertyArrayItem) { + if ($nodePropertyArrayItem instanceof Node && $this->nodeIsOrContainsYield($nodePropertyArrayItem)) { + return true; + } + } + } + + return false; + } + + public function getAsserts(): Assertions + { + return $this->assertions; + } + + public function getDocComment(): ?string + { + return $this->phpDocComment; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->functionLike->returnsByRef()); + } + + public function isPure(): TrinaryLogic + { + if ($this->isPure === null) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($this->isPure); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php new file mode 100644 index 00000000..9d456b3c --- /dev/null +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -0,0 +1,278 @@ +|null */ + private ?array $variants = null; + + private ?bool $containsVariadicCalls = null; + + /** + * @param array $phpDocParameterTypes + * @param array $phpDocParameterOutTypes + * @param array $phpDocParameterImmediatelyInvokedCallable + * @param array $phpDocParameterClosureThisTypes + * @param list $attributes + */ + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ReflectionFunction $reflection, + private Parser $parser, + private AttributeReflectionFactory $attributeReflectionFactory, + private TemplateTypeMap $templateTypeMap, + private array $phpDocParameterTypes, + private ?Type $phpDocReturnType, + private ?Type $phpDocThrowType, + private ?string $deprecatedDescription, + private bool $isDeprecated, + private bool $isInternal, + private ?string $filename, + private ?bool $isPure, + private Assertions $asserts, + private bool $acceptsNamedArguments, + private ?string $phpDocComment, + private array $phpDocParameterOutTypes, + private array $phpDocParameterImmediatelyInvokedCallable, + private array $phpDocParameterClosureThisTypes, + private array $attributes, + ) + { + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getFileName(): ?string + { + if ($this->filename === null) { + return null; + } + + if (!is_file($this->filename)) { + return null; + } + + return $this->filename; + } + + public function getVariants(): array + { + if ($this->variants === null) { + $this->variants = [ + new ExtendedFunctionVariant( + $this->templateTypeMap, + null, + $this->getParameters(), + $this->isVariadic(), + $this->getReturnType(), + $this->getPhpDocReturnType(), + $this->getNativeReturnType(), + ), + ]; + } + + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + /** + * @return list + */ + private function getParameters(): array + { + return array_map(function (ReflectionParameter $reflection): PhpParameterReflection { + if (array_key_exists($reflection->getName(), $this->phpDocParameterImmediatelyInvokedCallable)) { + $immediatelyInvokedCallable = TrinaryLogic::createFromBoolean($this->phpDocParameterImmediatelyInvokedCallable[$reflection->getName()]); + } else { + $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); + } + return new PhpParameterReflection( + $this->initializerExprTypeResolver, + $reflection, + $this->phpDocParameterTypes[$reflection->getName()] ?? null, + null, + $this->phpDocParameterOutTypes[$reflection->getName()] ?? null, + $immediatelyInvokedCallable, + $this->phpDocParameterClosureThisTypes[$reflection->getName()] ?? null, + $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), + ); + }, $this->reflection->getParameters()); + } + + private function isVariadic(): bool + { + $isNativelyVariadic = $this->reflection->isVariadic(); + if (!$isNativelyVariadic && $this->reflection->getFileName() !== false && !$this->isBuiltin()) { + $filename = $this->reflection->getFileName(); + + if ($this->containsVariadicCalls !== null) { + return $this->containsVariadicCalls; + } + + if (array_key_exists($this->reflection->getName(), VariadicFunctionsVisitor::$cache)) { + return $this->containsVariadicCalls = VariadicFunctionsVisitor::$cache[$this->reflection->getName()]; + } + + $nodes = $this->parser->parseFile($filename); + if (count($nodes) > 0) { + $variadicFunctions = $nodes[0]->getAttribute(VariadicFunctionsVisitor::ATTRIBUTE_NAME); + + if ( + is_array($variadicFunctions) + && array_key_exists($this->reflection->getName(), $variadicFunctions) + ) { + return $this->containsVariadicCalls = $variadicFunctions[$this->reflection->getName()]; + } + } + + return $this->containsVariadicCalls = false; + } + + return $isNativelyVariadic; + } + + private function getReturnType(): Type + { + return TypehintHelper::decideTypeFromReflection( + $this->reflection->getReturnType(), + $this->phpDocReturnType, + ); + } + + private function getPhpDocReturnType(): Type + { + if ($this->phpDocReturnType !== null) { + return $this->phpDocReturnType; + } + + return new MixedType(); + } + + private function getNativeReturnType(): Type + { + return TypehintHelper::decideTypeFromReflection($this->reflection->getReturnType()); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->isDeprecated) { + return $this->deprecatedDescription; + } + + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean( + $this->isDeprecated || $this->reflection->isDeprecated(), + ); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isInternal); + } + + public function getThrowType(): ?Type + { + return $this->phpDocThrowType; + } + + public function hasSideEffects(): TrinaryLogic + { + if ($this->getReturnType()->isVoid()->yes()) { + return TrinaryLogic::createYes(); + } + if ($this->isPure !== null) { + return TrinaryLogic::createFromBoolean(!$this->isPure); + } + + return TrinaryLogic::createMaybe(); + } + + public function isPure(): TrinaryLogic + { + if ($this->isPure === null) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($this->isPure); + } + + public function isBuiltin(): bool + { + return $this->reflection->isInternal(); + } + + public function getAsserts(): Assertions + { + return $this->asserts; + } + + public function getDocComment(): ?string + { + return $this->phpDocComment; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->returnsReference()); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->acceptsNamedArguments); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php new file mode 100644 index 00000000..846726e1 --- /dev/null +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -0,0 +1,300 @@ +> $parameterAttributes + * @param array $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + * @param list $attributes + */ + public function __construct( + private ClassReflection $declaringClass, + private ClassMethod|Node\PropertyHook $classMethod, + private ?string $hookForProperty, + string $fileName, + TemplateTypeMap $templateTypeMap, + array $realParameterTypes, + array $phpDocParameterTypes, + array $realParameterDefaultValues, + array $parameterAttributes, + Type $realReturnType, + ?Type $phpDocReturnType, + ?Type $throwType, + ?string $deprecatedDescription, + bool $isDeprecated, + bool $isInternal, + private bool $isFinal, + ?bool $isPure, + bool $acceptsNamedArguments, + Assertions $assertions, + private ?Type $selfOutType, + ?string $phpDocComment, + array $parameterOutTypes, + array $immediatelyInvokedCallableParameters, + array $phpDocClosureThisTypeParameters, + private bool $isConstructor, + array $attributes, + ) + { + if ($this->classMethod instanceof Node\PropertyHook) { + if ($this->hookForProperty === null) { + throw new ShouldNotHappenException('Hook was provided but property was not'); + } + } elseif ($this->hookForProperty !== null) { + throw new ShouldNotHappenException('Hooked property was provided but hook was not'); + } + + $name = strtolower($classMethod->name->name); + if ($this->isConstructor) { + $realReturnType = new VoidType(); + } + if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { + $realReturnType = new VoidType(); + } + if ($name === '__tostring') { + $realReturnType = new StringType(); + } + if ($name === '__isset') { + $realReturnType = new BooleanType(); + } + if ($name === '__sleep') { + $realReturnType = new ArrayType(new IntegerType(), new StringType()); + } + if ($name === '__set_state') { + $realReturnType = TypeCombinator::intersect(new ObjectWithoutClassType(), $realReturnType); + } + if ($name === '__set') { + $realReturnType = new VoidType(); + } + + if ($name === '__debuginfo') { + $realReturnType = TypeCombinator::intersect(TypeCombinator::addNull( + new ArrayType(new MixedType(true), new MixedType(true)), + ), $realReturnType); + } + + if ($name === '__unserialize') { + $realReturnType = new VoidType(); + } + if ($name === '__serialize') { + $realReturnType = new ArrayType(new MixedType(true), new MixedType(true)); + } + + parent::__construct( + $classMethod, + $fileName, + $templateTypeMap, + $realParameterTypes, + $phpDocParameterTypes, + $realParameterDefaultValues, + $parameterAttributes, + $realReturnType, + $phpDocReturnType, + $throwType, + $deprecatedDescription, + $isDeprecated, + $isInternal, + $isPure, + $acceptsNamedArguments, + $assertions, + $phpDocComment, + $parameterOutTypes, + $immediatelyInvokedCallableParameters, + $phpDocClosureThisTypeParameters, + $attributes, + ); + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function getPrototype(): ClassMemberReflection + { + try { + return $this->declaringClass->getNativeMethod($this->getClassMethod()->name->name)->getPrototype(); + } catch (MissingMethodFromReflectionException) { + return $this; + } + } + + private function getClassMethod(): ClassMethod|Node\PropertyHook + { + /** @var Node\Stmt\ClassMethod|Node\PropertyHook $functionLike */ + $functionLike = $this->getFunctionLike(); + return $functionLike; + } + + public function getName(): string + { + $function = $this->getFunctionLike(); + if (!$function instanceof Node\PropertyHook) { + return parent::getName(); + } + + if ($this->hookForProperty === null) { + throw new ShouldNotHappenException('Hook was provided but property was not'); + } + + return sprintf('$%s::%s', $this->hookForProperty, $function->name->toString()); + } + + /** + * @phpstan-assert-if-true !null $this->getHookedPropertyName() + * @phpstan-assert-if-true !null $this->getPropertyHookName() + */ + public function isPropertyHook(): bool + { + return $this->hookForProperty !== null; + } + + public function getHookedPropertyName(): ?string + { + return $this->hookForProperty; + } + + /** + * @return 'get'|'set'|null + */ + public function getPropertyHookName(): ?string + { + $function = $this->getFunctionLike(); + if (!$function instanceof Node\PropertyHook) { + return null; + } + + $name = $function->name->toLowerString(); + if (!in_array($name, ['get', 'set'], true)) { + throw new ShouldNotHappenException(sprintf('Unknown property hook: %s', $name)); + } + + return $name; + } + + public function isStatic(): bool + { + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return false; + } + + return $method->isStatic(); + } + + public function isPrivate(): bool + { + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return false; + } + + return $method->isPrivate(); + } + + public function isPublic(): bool + { + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return true; + } + + return $method->isPublic(); + } + + public function isFinal(): TrinaryLogic + { + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return TrinaryLogic::createFromBoolean($method->isFinal()); + } + + return TrinaryLogic::createFromBoolean($method->isFinal() || $this->isFinal); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getClassMethod()->isFinal()); + } + + public function isBuiltin(): bool + { + return false; + } + + public function getSelfOutType(): ?Type + { + return $this->selfOutType; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getClassMethod()->returnsByRef()); + } + + public function isAbstract(): TrinaryLogic + { + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return TrinaryLogic::createFromBoolean($method->body === null); + } + + return TrinaryLogic::createFromBoolean($method->isAbstract()); + } + + public function isConstructor(): bool + { + return $this->isConstructor; + } + + public function hasSideEffects(): TrinaryLogic + { + if ( + strtolower($this->getName()) !== '__construct' + && $this->getReturnType()->isVoid()->yes() + ) { + return TrinaryLogic::createYes(); + } + if ($this->isPure !== null) { + return TrinaryLogic::createFromBoolean(!$this->isPure); + } + + return TrinaryLogic::createMaybe(); + } + +} diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php new file mode 100644 index 00000000..d9a1008f --- /dev/null +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -0,0 +1,529 @@ +|null */ + private ?array $parameters = null; + + private ?Type $returnType = null; + + private ?Type $nativeReturnType = null; + + /** @var list|null */ + private ?array $variants = null; + + private ?bool $containsVariadicCalls = null; + + /** + * @param Type[] $phpDocParameterTypes + * @param Type[] $phpDocParameterOutTypes + * @param array $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + * @param list $attributes + */ + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ClassReflection $declaringClass, + private ?ClassReflection $declaringTrait, + private ReflectionMethod $reflection, + private ReflectionProvider $reflectionProvider, + private AttributeReflectionFactory $attributeReflectionFactory, + private Parser $parser, + private TemplateTypeMap $templateTypeMap, + private array $phpDocParameterTypes, + private ?Type $phpDocReturnType, + private ?Type $phpDocThrowType, + private ?string $deprecatedDescription, + private bool $isDeprecated, + private bool $isInternal, + private bool $isFinal, + private ?bool $isPure, + private Assertions $asserts, + private bool $acceptsNamedArguments, + private ?Type $selfOutType, + private ?string $phpDocComment, + private array $phpDocParameterOutTypes, + private array $immediatelyInvokedCallableParameters, + private array $phpDocClosureThisTypeParameters, + private array $attributes, + ) + { + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function getDeclaringTrait(): ?ClassReflection + { + return $this->declaringTrait; + } + + /** + * @return self|MethodPrototypeReflection + */ + public function getPrototype(): ClassMemberReflection + { + try { + $prototypeMethod = $this->reflection->getPrototype(); + $prototypeDeclaringClass = $this->declaringClass->getAncestorWithClassName($prototypeMethod->getDeclaringClass()->getName()); + if ($prototypeDeclaringClass === null) { + $prototypeDeclaringClass = $this->reflectionProvider->getClass($prototypeMethod->getDeclaringClass()->getName()); + } + + if (!$prototypeDeclaringClass->hasNativeMethod($prototypeMethod->getName())) { + return $this; + } + + $tentativeReturnType = null; + if ($prototypeMethod->getTentativeReturnType() !== null) { + $tentativeReturnType = TypehintHelper::decideTypeFromReflection($prototypeMethod->getTentativeReturnType(), null, $prototypeDeclaringClass); + } + + return new MethodPrototypeReflection( + $prototypeMethod->getName(), + $prototypeDeclaringClass, + $prototypeMethod->isStatic(), + $prototypeMethod->isPrivate(), + $prototypeMethod->isPublic(), + $prototypeMethod->isAbstract(), + $prototypeMethod->isFinal(), + $prototypeMethod->isInternal(), + $prototypeDeclaringClass->getNativeMethod($prototypeMethod->getName())->getVariants(), + $tentativeReturnType, + ); + } catch (ReflectionException) { + return $this; + } + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function getName(): string + { + $name = $this->reflection->getName(); + $lowercaseName = strtolower($name); + if ($lowercaseName === $name) { + if (PHP_VERSION_ID >= 80000) { + return $name; + } + + // fix for https://bugs.php.net/bug.php?id=74939 + foreach ($this->getDeclaringClass()->getNativeReflection()->getTraitAliases() as $traitTarget) { + $correctName = $this->getMethodNameWithCorrectCase($name, $traitTarget); + if ($correctName !== null) { + $name = $correctName; + break; + } + } + } + + return $name; + } + + private function getMethodNameWithCorrectCase(string $lowercaseMethodName, string $traitTarget): ?string + { + $trait = explode('::', $traitTarget)[0]; + $traitReflection = $this->reflectionProvider->getClass($trait)->getNativeReflection(); + foreach ($traitReflection->getTraitAliases() as $methodAlias => $aliasTraitTarget) { + if ($lowercaseMethodName === strtolower($methodAlias)) { + return $methodAlias; + } + + $correctName = $this->getMethodNameWithCorrectCase($lowercaseMethodName, $aliasTraitTarget); + if ($correctName !== null) { + return $correctName; + } + } + + return null; + } + + /** + * @return list + */ + public function getVariants(): array + { + if ($this->variants === null) { + $this->variants = [ + new ExtendedFunctionVariant( + $this->templateTypeMap, + null, + $this->getParameters(), + $this->isVariadic(), + $this->getReturnType(), + $this->getPhpDocReturnType(), + $this->getNativeReturnType(), + ), + ]; + } + + return $this->variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + /** + * @return list + */ + private function getParameters(): array + { + if ($this->parameters === null) { + $this->parameters = array_map(fn (ReflectionParameter $reflection): PhpParameterReflection => new PhpParameterReflection( + $this->initializerExprTypeResolver, + $reflection, + $this->phpDocParameterTypes[$reflection->getName()] ?? null, + $this->getDeclaringClass(), + $this->phpDocParameterOutTypes[$reflection->getName()] ?? null, + $this->immediatelyInvokedCallableParameters[$reflection->getName()] ?? TrinaryLogic::createMaybe(), + $this->phpDocClosureThisTypeParameters[$reflection->getName()] ?? null, + $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), + ), $this->reflection->getParameters()); + } + + return $this->parameters; + } + + private function isVariadic(): bool + { + $isNativelyVariadic = $this->reflection->isVariadic(); + $declaringClass = $this->declaringClass; + $filename = $this->declaringClass->getFileName(); + if ($this->declaringTrait !== null) { + $declaringClass = $this->declaringTrait; + $filename = $this->declaringTrait->getFileName(); + } + + if (!$isNativelyVariadic && $filename !== null && !$this->declaringClass->isBuiltin()) { + if ($this->containsVariadicCalls !== null) { + return $this->containsVariadicCalls; + } + + $className = $declaringClass->getName(); + if ($declaringClass->isAnonymous()) { + $className = sprintf('%s:%s:%s', VariadicMethodsVisitor::ANONYMOUS_CLASS_PREFIX, $declaringClass->getNativeReflection()->getStartLine(), $declaringClass->getNativeReflection()->getEndLine()); + } + if (array_key_exists($className, VariadicMethodsVisitor::$cache)) { + if (array_key_exists($this->reflection->getName(), VariadicMethodsVisitor::$cache[$className])) { + return $this->containsVariadicCalls = VariadicMethodsVisitor::$cache[$className][$this->reflection->getName()]; + } + + return $this->containsVariadicCalls = false; + } + + $nodes = $this->parser->parseFile($filename); + if (count($nodes) > 0) { + $variadicMethods = $nodes[0]->getAttribute(VariadicMethodsVisitor::ATTRIBUTE_NAME); + + if ( + is_array($variadicMethods) + && array_key_exists($className, $variadicMethods) + && array_key_exists($this->reflection->getName(), $variadicMethods[$className]) + ) { + return $this->containsVariadicCalls = $variadicMethods[$className][$this->reflection->getName()]; + } + } + + return $this->containsVariadicCalls = false; + } + + return $isNativelyVariadic; + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + private function getReturnType(): Type + { + if ($this->returnType === null) { + $name = strtolower($this->getName()); + $returnType = $this->reflection->getReturnType(); + if ($returnType === null) { + if (in_array($name, ['__construct', '__destruct', '__unset', '__wakeup', '__clone'], true)) { + return $this->returnType = TypehintHelper::decideType(new VoidType(), $this->phpDocReturnType); + } + if ($name === '__tostring') { + return $this->returnType = TypehintHelper::decideType(new StringType(), $this->phpDocReturnType); + } + if ($name === '__isset') { + return $this->returnType = TypehintHelper::decideType(new BooleanType(), $this->phpDocReturnType); + } + if ($name === '__sleep') { + return $this->returnType = TypehintHelper::decideType(new ArrayType(new IntegerType(), new StringType()), $this->phpDocReturnType); + } + if ($name === '__set_state') { + return $this->returnType = TypehintHelper::decideType(new ObjectWithoutClassType(), $this->phpDocReturnType); + } + } + + $this->returnType = TypehintHelper::decideTypeFromReflection( + $returnType, + $this->phpDocReturnType, + $this->declaringClass, + ); + } + + return $this->returnType; + } + + private function getPhpDocReturnType(): Type + { + if ($this->phpDocReturnType !== null) { + return $this->phpDocReturnType; + } + + return new MixedType(); + } + + private function getNativeReturnType(): Type + { + if ($this->nativeReturnType === null) { + $this->nativeReturnType = TypehintHelper::decideTypeFromReflection( + $this->reflection->getReturnType(), + null, + $this->declaringClass, + ); + } + + return $this->nativeReturnType; + } + + public function getDeprecatedDescription(): ?string + { + if ($this->isDeprecated) { + return $this->deprecatedDescription; + } + + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + + return null; + } + + public function isDeprecated(): TrinaryLogic + { + if ($this->isDeprecated) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createFromBoolean($this->reflection->isDeprecated()); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isInternal || $this->reflection->isInternal()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isFinal || $this->reflection->isFinal()); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isFinal()); + } + + public function isAbstract(): bool + { + return $this->reflection->isAbstract(); + } + + public function getThrowType(): ?Type + { + return $this->phpDocThrowType; + } + + public function hasSideEffects(): TrinaryLogic + { + if ( + strtolower($this->getName()) !== '__construct' + && $this->getReturnType()->isVoid()->yes() + ) { + return TrinaryLogic::createYes(); + } + if ($this->isPure !== null) { + return TrinaryLogic::createFromBoolean(!$this->isPure); + } + + if ((new ThisType($this->declaringClass))->isSuperTypeOf($this->getReturnType())->yes()) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getAsserts(): Assertions + { + return $this->asserts; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean( + $this->declaringClass->acceptsNamedArguments() && $this->acceptsNamedArguments, + ); + } + + public function getSelfOutType(): ?Type + { + return $this->selfOutType; + } + + public function getDocComment(): ?string + { + return $this->phpDocComment; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->returnsReference()); + } + + public function isPure(): TrinaryLogic + { + if ($this->isPure === null) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($this->isPure); + } + + public function changePropertyGetHookPhpDocType(Type $phpDocType): self + { + return new self( + $this->initializerExprTypeResolver, + $this->declaringClass, + $this->declaringTrait, + $this->reflection, + $this->reflectionProvider, + $this->attributeReflectionFactory, + $this->parser, + $this->templateTypeMap, + $this->phpDocParameterTypes, + $phpDocType, + $this->phpDocThrowType, + $this->deprecatedDescription, + $this->isDeprecated, + $this->isInternal, + $this->isFinal, + $this->isPure, + $this->asserts, + $this->acceptsNamedArguments, + $this->selfOutType, + $this->phpDocComment, + $this->phpDocParameterOutTypes, + $this->immediatelyInvokedCallableParameters, + $this->phpDocClosureThisTypeParameters, + $this->attributes, + ); + } + + public function changePropertySetHookPhpDocType(string $parameterName, Type $phpDocType): self + { + $phpDocParameterTypes = $this->phpDocParameterTypes; + $phpDocParameterTypes[$parameterName] = $phpDocType; + + return new self( + $this->initializerExprTypeResolver, + $this->declaringClass, + $this->declaringTrait, + $this->reflection, + $this->reflectionProvider, + $this->attributeReflectionFactory, + $this->parser, + $this->templateTypeMap, + $phpDocParameterTypes, + $this->phpDocReturnType, + $this->phpDocThrowType, + $this->deprecatedDescription, + $this->isDeprecated, + $this->isInternal, + $this->isFinal, + $this->isPure, + $this->asserts, + $this->acceptsNamedArguments, + $this->selfOutType, + $this->phpDocComment, + $this->phpDocParameterOutTypes, + $this->immediatelyInvokedCallableParameters, + $this->phpDocClosureThisTypeParameters, + $this->attributes, + ); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpMethodReflectionFactory.php b/src/Reflection/Php/PhpMethodReflectionFactory.php new file mode 100644 index 00000000..f98d7f35 --- /dev/null +++ b/src/Reflection/Php/PhpMethodReflectionFactory.php @@ -0,0 +1,47 @@ + $immediatelyInvokedCallableParameters + * @param array $phpDocClosureThisTypeParameters + * @param list $attributes + */ + public function create( + ClassReflection $declaringClass, + ?ClassReflection $declaringTrait, + ReflectionMethod $reflection, + TemplateTypeMap $templateTypeMap, + array $phpDocParameterTypes, + ?Type $phpDocReturnType, + ?Type $phpDocThrowType, + ?string $deprecatedDescription, + bool $isDeprecated, + bool $isInternal, + bool $isFinal, + ?bool $isPure, + Assertions $asserts, + ?Type $selfOutType, + ?string $phpDocComment, + array $phpDocParameterOutTypes, + array $immediatelyInvokedCallableParameters, + array $phpDocClosureThisTypeParameters, + bool $acceptsNamedArguments, + array $attributes, + ): PhpMethodReflection; + +} diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php new file mode 100644 index 00000000..87378a48 --- /dev/null +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -0,0 +1,117 @@ + $attributes + */ + public function __construct( + private string $name, + private bool $optional, + private Type $realType, + private ?Type $phpDocType, + private PassedByReference $passedByReference, + private ?Type $defaultValue, + private bool $variadic, + private ?Type $outType, + private TrinaryLogic $immediatelyInvokedCallable, + private ?Type $closureThisType, + private array $attributes, + ) + { + } + + public function getName(): string + { + return $this->name; + } + + public function isOptional(): bool + { + return $this->optional; + } + + public function getType(): Type + { + if ($this->type === null) { + $phpDocType = $this->phpDocType; + if ($phpDocType !== null && $this->defaultValue !== null) { + if ($this->defaultValue->isNull()->yes()) { + $inferred = $phpDocType->inferTemplateTypes($this->defaultValue); + if ($inferred->isEmpty()) { + $phpDocType = TypeCombinator::addNull($phpDocType); + } + } + } + $this->type = TypehintHelper::decideType($this->realType, $phpDocType); + } + + return $this->type; + } + + public function getPhpDocType(): Type + { + return $this->phpDocType ?? new MixedType(); + } + + public function hasNativeType(): bool + { + return !$this->realType instanceof MixedType || $this->realType->isExplicitMixed(); + } + + public function getNativeType(): Type + { + return $this->realType; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + + public function getOutType(): ?Type + { + return $this->outType; + } + + public function isImmediatelyInvokedCallable(): TrinaryLogic + { + return $this->immediatelyInvokedCallable; + } + + public function getClosureThisType(): ?Type + { + return $this->closureThisType; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php new file mode 100644 index 00000000..f9117531 --- /dev/null +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -0,0 +1,152 @@ + $attributes + */ + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ReflectionParameter $reflection, + private ?Type $phpDocType, + private ?ClassReflection $declaringClass, + private ?Type $outType, + private TrinaryLogic $immediatelyInvokedCallable, + private ?Type $closureThisType, + private array $attributes, + ) + { + } + + public function isOptional(): bool + { + return $this->reflection->isOptional(); + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getType(): Type + { + if ($this->type === null) { + $phpDocType = $this->phpDocType; + if ( + $phpDocType !== null + && $this->reflection->isDefaultValueAvailable() + ) { + $defaultValueType = $this->initializerExprTypeResolver->getType( + $this->reflection->getDefaultValueExpression(), + InitializerExprContext::fromReflectionParameter($this->reflection), + ); + if ($defaultValueType->isNull()->yes()) { + $phpDocType = TypeCombinator::addNull($phpDocType); + } + } + + $this->type = TypehintHelper::decideTypeFromReflection( + $this->reflection->getType(), + $phpDocType, + $this->declaringClass, + $this->isVariadic(), + ); + } + + return $this->type; + } + + public function passedByReference(): PassedByReference + { + return $this->reflection->isPassedByReference() + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(); + } + + public function isVariadic(): bool + { + return $this->reflection->isVariadic(); + } + + public function getPhpDocType(): Type + { + if ($this->phpDocType !== null) { + return $this->phpDocType; + } + + return new MixedType(); + } + + public function hasNativeType(): bool + { + return $this->reflection->getType() !== null; + } + + public function getNativeType(): Type + { + if ($this->nativeType === null) { + $this->nativeType = TypehintHelper::decideTypeFromReflection( + $this->reflection->getType(), + null, + $this->declaringClass, + $this->isVariadic(), + ); + } + + return $this->nativeType; + } + + public function getDefaultValue(): ?Type + { + if ($this->reflection->isDefaultValueAvailable()) { + return $this->initializerExprTypeResolver->getType( + $this->reflection->getDefaultValueExpression(), + InitializerExprContext::fromReflectionParameter($this->reflection), + ); + } + + return null; + } + + public function getOutType(): ?Type + { + return $this->outType; + } + + public function isImmediatelyInvokedCallable(): TrinaryLogic + { + return $this->immediatelyInvokedCallable; + } + + public function getClosureThisType(): ?Type + { + return $this->closureThisType; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php new file mode 100644 index 00000000..d3fe5a06 --- /dev/null +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -0,0 +1,292 @@ + $attributes + */ + public function __construct( + private ClassReflection $declaringClass, + private ?ClassReflection $declaringTrait, + private ReflectionUnionType|ReflectionNamedType|ReflectionIntersectionType|null $nativeType, + private ?Type $phpDocType, + private ReflectionProperty $reflection, + private ?ExtendedMethodReflection $getHook, + private ?ExtendedMethodReflection $setHook, + private ?string $deprecatedDescription, + private bool $isDeprecated, + private bool $isInternal, + private bool $isReadOnlyByPhpDoc, + private bool $isAllowedPrivateMutation, + private array $attributes, + ) + { + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function getDeclaringTrait(): ?ClassReflection + { + return $this->declaringTrait; + } + + public function getDocComment(): ?string + { + $docComment = $this->reflection->getDocComment(); + if ($docComment === false) { + return null; + } + + return $docComment; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function isReadOnly(): bool + { + return $this->reflection->isReadOnly(); + } + + public function isReadOnlyByPhpDoc(): bool + { + return $this->isReadOnlyByPhpDoc; + } + + public function getReadableType(): Type + { + if ($this->type === null) { + $this->type = TypehintHelper::decideTypeFromReflection( + $this->nativeType, + $this->phpDocType, + $this->declaringClass, + ); + } + + return $this->type; + } + + public function getWritableType(): Type + { + if ($this->hasHook('set')) { + $setHookVariant = $this->getHook('set')->getOnlyVariant(); + $parameters = $setHookVariant->getParameters(); + if (isset($parameters[0])) { + return $parameters[0]->getType(); + } + } + + return $this->getReadableType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + if ($this->isStatic()) { + return true; + } + + if ($this->isVirtual()->yes()) { + return false; + } + + if ($this->hasHook('get')) { + return false; + } + + if ($this->hasHook('set')) { + return false; + } + + return true; + } + + public function isPromoted(): bool + { + return $this->reflection->isPromoted(); + } + + public function hasPhpDocType(): bool + { + return $this->phpDocType !== null; + } + + public function getPhpDocType(): Type + { + if ($this->phpDocType !== null) { + return $this->phpDocType; + } + + return new MixedType(); + } + + public function hasNativeType(): bool + { + return $this->nativeType !== null; + } + + public function getNativeType(): Type + { + if ($this->finalNativeType === null) { + $this->finalNativeType = TypehintHelper::decideTypeFromReflection( + $this->nativeType, + null, + $this->declaringClass, + ); + } + + return $this->finalNativeType; + } + + public function isReadable(): bool + { + if ($this->isStatic()) { + return true; + } + + if (!$this->isVirtual()->yes()) { + return true; + } + + return $this->hasHook('get'); + } + + public function isWritable(): bool + { + if ($this->isStatic()) { + return true; + } + + if (!$this->isVirtual()->yes()) { + return true; + } + + return $this->hasHook('set'); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->isDeprecated) { + return $this->deprecatedDescription; + } + + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isDeprecated); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isInternal); + } + + public function isAllowedPrivateMutation(): bool + { + return $this->isAllowedPrivateMutation; + } + + public function getNativeReflection(): ReflectionProperty + { + return $this->reflection; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + if ($hookType === 'get') { + return $this->getHook !== null; + } + + return $this->setHook !== null; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + if ($hookType === 'get') { + if ($this->getHook === null) { + throw new MissingMethodFromReflectionException($this->declaringClass->getName(), sprintf('$%s::get', $this->reflection->getName())); + } + + return $this->getHook; + } + + if ($this->setHook === null) { + throw new MissingMethodFromReflectionException($this->declaringClass->getName(), sprintf('$%s::set', $this->reflection->getName())); + } + + return $this->setHook; + } + + public function isProtectedSet(): bool + { + return $this->reflection->isProtectedSet(); + } + + public function isPrivateSet(): bool + { + return $this->reflection->isPrivateSet(); + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/Php/SimpleXMLElementProperty.php b/src/Reflection/Php/SimpleXMLElementProperty.php new file mode 100644 index 00000000..2eba011f --- /dev/null +++ b/src/Reflection/Php/SimpleXMLElementProperty.php @@ -0,0 +1,160 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): Type + { + return new MixedType(); + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->type; + } + + public function getWritableType(): Type + { + return TypeCombinator::union( + $this->type, + new IntegerType(), + new FloatType(), + new StringType(), + new BooleanType(), + ); + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return true; + } + + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/Soap/SoapClientMethodReflection.php b/src/Reflection/Php/Soap/SoapClientMethodReflection.php new file mode 100644 index 00000000..0ce28703 --- /dev/null +++ b/src/Reflection/Php/Soap/SoapClientMethodReflection.php @@ -0,0 +1,101 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getName(): string + { + return $this->name; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [], + true, + new MixedType(true), + ), + ]; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): Type + { + return new ObjectType('SoapFault'); + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + +} diff --git a/src/Reflection/Php/Soap/SoapClientMethodsClassReflectionExtension.php b/src/Reflection/Php/Soap/SoapClientMethodsClassReflectionExtension.php new file mode 100644 index 00000000..ce0e6ee2 --- /dev/null +++ b/src/Reflection/Php/Soap/SoapClientMethodsClassReflectionExtension.php @@ -0,0 +1,23 @@ +getName() === 'SoapClient' || $classReflection->isSubclassOf('SoapClient'); + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return new SoapClientMethodReflection($classReflection, $methodName); + } + +} diff --git a/src/Reflection/Php/UniversalObjectCrateProperty.php b/src/Reflection/Php/UniversalObjectCrateProperty.php new file mode 100644 index 00000000..f63ded02 --- /dev/null +++ b/src/Reflection/Php/UniversalObjectCrateProperty.php @@ -0,0 +1,150 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): Type + { + return new MixedType(); + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->readableType; + } + + public function getWritableType(): Type + { + return $this->writableType; + } + + public function canChangeTypeAfterAssignment(): bool + { + return true; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return true; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDocComment(): ?string + { + return null; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/UniversalObjectCratesClassReflectionExtension.php b/src/Reflection/Php/UniversalObjectCratesClassReflectionExtension.php new file mode 100644 index 00000000..9dd7c3b2 --- /dev/null +++ b/src/Reflection/Php/UniversalObjectCratesClassReflectionExtension.php @@ -0,0 +1,95 @@ + $classes + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private array $classes, + private AnnotationsPropertiesClassReflectionExtension $annotationClassReflection, + ) + { + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return self::isUniversalObjectCrateImplementation( + $this->reflectionProvider, + $this->classes, + $classReflection, + ); + } + + public static function isUniversalObjectCrate( + ReflectionProvider $reflectionProvider, + ClassReflection $classReflection, + ): bool + { + return self::isUniversalObjectCrateImplementation( + $reflectionProvider, + $reflectionProvider->getUniversalObjectCratesClasses(), + $classReflection, + ); + } + + /** + * @param list $classes + */ + private static function isUniversalObjectCrateImplementation( + ReflectionProvider $reflectionProvider, + array $classes, + ClassReflection $classReflection, + ): bool + { + foreach ($classes as $className) { + if (!$reflectionProvider->hasClass($className)) { + continue; + } + + if ( + $classReflection->getName() === $className + || $classReflection->isSubclassOf($className) + ) { + return true; + } + } + + return false; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + if ($this->annotationClassReflection->hasProperty($classReflection, $propertyName)) { + return $this->annotationClassReflection->getProperty($classReflection, $propertyName); + } + + if ($classReflection->hasNativeMethod('__get')) { + $readableType = $classReflection->getNativeMethod('__get')->getOnlyVariant()->getReturnType(); + } else { + $readableType = new MixedType(); + } + + if ($classReflection->hasNativeMethod('__set')) { + $writableType = $classReflection->getNativeMethod('__set')->getOnlyVariant()->getParameters()[1]->getType(); + } else { + $writableType = new MixedType(); + } + + return new UniversalObjectCrateProperty($classReflection, $readableType, $writableType); + } + +} diff --git a/src/Reflection/PhpVersionStaticAccessor.php b/src/Reflection/PhpVersionStaticAccessor.php new file mode 100644 index 00000000..e21e6cc9 --- /dev/null +++ b/src/Reflection/PhpVersionStaticAccessor.php @@ -0,0 +1,31 @@ + $attributes + */ + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ClassReflection $declaringClass, + private ReflectionClassConstant $reflection, + private ?Type $nativeType, + private ?Type $phpDocType, + private ?string $deprecatedDescription, + private bool $isDeprecated, + private bool $isInternal, + private bool $isFinal, + private array $attributes, + ) + { + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getFileName(): ?string + { + return $this->declaringClass->getFileName(); + } + + public function getValueExpr(): Expr + { + return $this->reflection->getValueExpression(); + } + + public function hasPhpDocType(): bool + { + return $this->phpDocType !== null; + } + + public function getPhpDocType(): ?Type + { + return $this->phpDocType; + } + + public function hasNativeType(): bool + { + return $this->nativeType !== null; + } + + public function getNativeType(): ?Type + { + return $this->nativeType; + } + + public function getValueType(): Type + { + if ($this->valueType === null) { + if ($this->phpDocType !== null) { + if ($this->nativeType !== null) { + return $this->valueType = TypehintHelper::decideType( + $this->nativeType, + $this->phpDocType, + ); + } + + return $this->phpDocType; + } elseif ($this->nativeType !== null) { + return $this->nativeType; + } + + $this->valueType = $this->initializerExprTypeResolver->getType($this->getValueExpr(), InitializerExprContext::fromClassReflection($this->declaringClass)); + } + + return $this->valueType; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function isStatic(): bool + { + return true; + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function isFinal(): bool + { + return $this->isFinal || $this->reflection->isFinal(); + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isDeprecated || $this->reflection->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->isDeprecated) { + return $this->deprecatedDescription; + } + + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->isInternal); + } + + public function getDocComment(): ?string + { + $docComment = $this->reflection->getDocComment(); + if ($docComment === false) { + return null; + } + + return $docComment; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Reflection/ReflectionProvider.php b/src/Reflection/ReflectionProvider.php new file mode 100644 index 00000000..9515f89d --- /dev/null +++ b/src/Reflection/ReflectionProvider.php @@ -0,0 +1,40 @@ + */ + public function getUniversalObjectCratesClasses(): array; + + public function hasFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool; + + public function getFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): FunctionReflection; + + public function resolveFunctionName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string; + + public function hasConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool; + + public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ConstantReflection; + + public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string; + +} diff --git a/src/Reflection/ReflectionProvider/DirectReflectionProviderProvider.php b/src/Reflection/ReflectionProvider/DirectReflectionProviderProvider.php new file mode 100644 index 00000000..13680819 --- /dev/null +++ b/src/Reflection/ReflectionProvider/DirectReflectionProviderProvider.php @@ -0,0 +1,20 @@ +reflectionProvider; + } + +} diff --git a/src/Reflection/ReflectionProvider/DummyReflectionProvider.php b/src/Reflection/ReflectionProvider/DummyReflectionProvider.php new file mode 100644 index 00000000..0eaafc78 --- /dev/null +++ b/src/Reflection/ReflectionProvider/DummyReflectionProvider.php @@ -0,0 +1,73 @@ +container->getByType(ReflectionProvider::class); + } + +} diff --git a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php new file mode 100644 index 00000000..807d7b0e --- /dev/null +++ b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php @@ -0,0 +1,100 @@ + */ + private array $hasClasses = []; + + /** @var array */ + private array $classes = []; + + /** @var array */ + private array $classNames = []; + + public function __construct(private ReflectionProvider $provider) + { + } + + public function hasClass(string $className): bool + { + if (isset($this->hasClasses[$className])) { + return $this->hasClasses[$className]; + } + + return $this->hasClasses[$className] = $this->provider->hasClass($className); + } + + public function getClass(string $className): ClassReflection + { + $lowerClassName = strtolower($className); + if (isset($this->classes[$lowerClassName])) { + return $this->classes[$lowerClassName]; + } + + return $this->classes[$lowerClassName] = $this->provider->getClass($className); + } + + public function getClassName(string $className): string + { + $lowerClassName = strtolower($className); + if (isset($this->classNames[$lowerClassName])) { + return $this->classNames[$lowerClassName]; + } + + return $this->classNames[$lowerClassName] = $this->provider->getClassName($className); + } + + public function getAnonymousClassReflection(Node\Stmt\Class_ $classNode, Scope $scope): ClassReflection + { + return $this->provider->getAnonymousClassReflection($classNode, $scope); + } + + public function getUniversalObjectCratesClasses(): array + { + return $this->provider->getUniversalObjectCratesClasses(); + } + + public function hasFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool + { + return $this->provider->hasFunction($nameNode, $namespaceAnswerer); + } + + public function getFunction(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): FunctionReflection + { + return $this->provider->getFunction($nameNode, $namespaceAnswerer); + } + + public function resolveFunctionName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string + { + return $this->provider->resolveFunctionName($nameNode, $namespaceAnswerer); + } + + public function hasConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool + { + return $this->provider->hasConstant($nameNode, $namespaceAnswerer); + } + + public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ConstantReflection + { + return $this->provider->getConstant($nameNode, $namespaceAnswerer); + } + + public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string + { + return $this->provider->resolveConstantName($nameNode, $namespaceAnswerer); + } + +} diff --git a/src/Reflection/ReflectionProvider/ReflectionProviderFactory.php b/src/Reflection/ReflectionProvider/ReflectionProviderFactory.php new file mode 100644 index 00000000..4c345309 --- /dev/null +++ b/src/Reflection/ReflectionProvider/ReflectionProviderFactory.php @@ -0,0 +1,22 @@ +staticReflectionProvider); + } + +} diff --git a/src/Reflection/ReflectionProvider/ReflectionProviderProvider.php b/src/Reflection/ReflectionProvider/ReflectionProviderProvider.php new file mode 100644 index 00000000..a79918e2 --- /dev/null +++ b/src/Reflection/ReflectionProvider/ReflectionProviderProvider.php @@ -0,0 +1,13 @@ +reflectionProvider = $reflectionProvider; + } + + public function getReflectionProvider(): ReflectionProvider + { + return $this->reflectionProvider; + } + +} diff --git a/src/Reflection/ReflectionProviderStaticAccessor.php b/src/Reflection/ReflectionProviderStaticAccessor.php new file mode 100644 index 00000000..3bf5a994 --- /dev/null +++ b/src/Reflection/ReflectionProviderStaticAccessor.php @@ -0,0 +1,30 @@ +findMethod($classReflection, $methodName) !== null; + } + + public function getMethod(ClassReflection $classReflection, string $methodName): ExtendedMethodReflection + { + $method = $this->findMethod($classReflection, $methodName); + if ($method === null) { + throw new ShouldNotHappenException(); + } + + return $method; + } + + private function findMethod(ClassReflection $classReflection, string $methodName): ?ExtendedMethodReflection + { + if (!$classReflection->isInterface()) { + return null; + } + + $extendsTags = $classReflection->getRequireExtendsTags(); + foreach ($extendsTags as $extendsTag) { + $type = $extendsTag->getType(); + + if (!$type->hasMethod($methodName)->yes()) { + continue; + } + + return $type->getMethod($methodName, new OutOfClassScope()); + } + + $interfaces = $classReflection->getInterfaces(); + foreach ($interfaces as $interface) { + $method = $this->findMethod($interface, $methodName); + if ($method !== null) { + return $method; + } + } + + return null; + } + +} diff --git a/src/Reflection/RequireExtension/RequireExtendsPropertiesClassReflectionExtension.php b/src/Reflection/RequireExtension/RequireExtendsPropertiesClassReflectionExtension.php new file mode 100644 index 00000000..9d13391b --- /dev/null +++ b/src/Reflection/RequireExtension/RequireExtendsPropertiesClassReflectionExtension.php @@ -0,0 +1,58 @@ +findProperty($classReflection, $propertyName) !== null; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): ExtendedPropertyReflection + { + $property = $this->findProperty($classReflection, $propertyName); + if ($property === null) { + throw new ShouldNotHappenException(); + } + + return $property; + } + + private function findProperty(ClassReflection $classReflection, string $propertyName): ?ExtendedPropertyReflection + { + if (!$classReflection->isInterface()) { + return null; + } + + $requireExtendsTags = $classReflection->getRequireExtendsTags(); + foreach ($requireExtendsTags as $requireExtendsTag) { + $type = $requireExtendsTag->getType(); + + if (!$type->hasProperty($propertyName)->yes()) { + continue; + } + + return $type->getProperty($propertyName, new OutOfClassScope()); + } + + $interfaces = $classReflection->getInterfaces(); + foreach ($interfaces as $interface) { + $property = $this->findProperty($interface, $propertyName); + if ($property !== null) { + return $property; + } + } + + return null; + } + +} diff --git a/src/Reflection/ResolvedFunctionVariant.php b/src/Reflection/ResolvedFunctionVariant.php new file mode 100644 index 00000000..76168505 --- /dev/null +++ b/src/Reflection/ResolvedFunctionVariant.php @@ -0,0 +1,17 @@ +parametersAcceptor->getOriginalParametersAcceptor(); + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->parametersAcceptor->getTemplateTypeMap(); + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->parametersAcceptor->getResolvedTemplateTypeMap(); + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->parametersAcceptor->getCallSiteVarianceMap(); + } + + public function getParameters(): array + { + return $this->parametersAcceptor->getParameters(); + } + + public function isVariadic(): bool + { + return $this->parametersAcceptor->isVariadic(); + } + + public function getReturnTypeWithUnresolvableTemplateTypes(): Type + { + return $this->parametersAcceptor->getReturnTypeWithUnresolvableTemplateTypes(); + } + + public function getPhpDocReturnTypeWithUnresolvableTemplateTypes(): Type + { + return $this->parametersAcceptor->getPhpDocReturnTypeWithUnresolvableTemplateTypes(); + } + + public function getReturnType(): Type + { + return $this->parametersAcceptor->getReturnType(); + } + + public function getPhpDocReturnType(): Type + { + return $this->parametersAcceptor->getPhpDocReturnType(); + } + + public function getNativeReturnType(): Type + { + return $this->parametersAcceptor->getNativeReturnType(); + } + + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + public function isPure(): TrinaryLogic + { + return $this->isPure; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + + public function getUsedVariables(): array + { + return $this->usedVariables; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->acceptsNamedArguments; + } + +} diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php new file mode 100644 index 00000000..0681bbac --- /dev/null +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -0,0 +1,301 @@ +|null */ + private ?array $parameters = null; + + private ?Type $returnTypeWithUnresolvableTemplateTypes = null; + + private ?Type $phpDocReturnTypeWithUnresolvableTemplateTypes = null; + + private ?Type $returnType = null; + + private ?Type $phpDocReturnType = null; + + /** + * @param array $passedArgs + */ + public function __construct( + private ExtendedParametersAcceptor $parametersAcceptor, + private TemplateTypeMap $resolvedTemplateTypeMap, + private TemplateTypeVarianceMap $callSiteVarianceMap, + private array $passedArgs, + ) + { + } + + public function getOriginalParametersAcceptor(): ParametersAcceptor + { + return $this->parametersAcceptor; + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->parametersAcceptor->getTemplateTypeMap(); + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->resolvedTemplateTypeMap; + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->callSiteVarianceMap; + } + + public function getParameters(): array + { + $parameters = $this->parameters; + + if ($parameters === null) { + $parameters = array_map( + function (ExtendedParameterReflection $param): ExtendedParameterReflection { + $paramType = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->resolveConditionalTypesForParameter($param->getType()), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createContravariant(), + ), + false, + ); + + $paramOutType = $param->getOutType(); + if ($paramOutType !== null) { + $paramOutType = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->resolveConditionalTypesForParameter($paramOutType), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + } + + $closureThisType = $param->getClosureThisType(); + if ($closureThisType !== null) { + $closureThisType = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->resolveConditionalTypesForParameter($closureThisType), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + } + + return new ExtendedDummyParameter( + $param->getName(), + $paramType, + $param->isOptional(), + $param->passedByReference(), + $param->isVariadic(), + $param->getDefaultValue(), + $param->getNativeType(), + $param->getPhpDocType(), + $paramOutType, + $param->isImmediatelyInvokedCallable(), + $closureThisType, + $param->getAttributes(), + ); + }, + $this->parametersAcceptor->getParameters(), + ); + + $this->parameters = $parameters; + } + + return $parameters; + } + + public function isVariadic(): bool + { + return $this->parametersAcceptor->isVariadic(); + } + + public function getReturnTypeWithUnresolvableTemplateTypes(): Type + { + return $this->returnTypeWithUnresolvableTemplateTypes ??= + $this->resolveConditionalTypesForParameter( + $this->resolveResolvableTemplateTypes($this->parametersAcceptor->getReturnType(), TemplateTypeVariance::createCovariant()), + ); + } + + public function getPhpDocReturnTypeWithUnresolvableTemplateTypes(): Type + { + return $this->phpDocReturnTypeWithUnresolvableTemplateTypes ??= + $this->resolveConditionalTypesForParameter( + $this->resolveResolvableTemplateTypes($this->parametersAcceptor->getPhpDocReturnType(), TemplateTypeVariance::createCovariant()), + ); + } + + public function getReturnType(): Type + { + $type = $this->returnType; + + if ($type === null) { + $type = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->getReturnTypeWithUnresolvableTemplateTypes(), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + + $this->returnType = $type; + } + + return $type; + } + + public function getPhpDocReturnType(): Type + { + $type = $this->phpDocReturnType; + + if ($type === null) { + $type = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->getPhpDocReturnTypeWithUnresolvableTemplateTypes(), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + + $this->phpDocReturnType = $type; + } + + return $type; + } + + public function getNativeReturnType(): Type + { + return $this->parametersAcceptor->getNativeReturnType(); + } + + private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance $positionVariance): Type + { + $references = $type->getReferencedTemplateTypes($positionVariance); + + $objectCb = function (Type $type, callable $traverse) use ($references): Type { + if ( + $type instanceof TemplateType + && !$type->isArgument() + && $type->getScope()->getFunctionName() !== null + ) { + $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); + if ($newType === null || $newType instanceof ErrorType) { + return $traverse($type); + } + + $newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + $variance = TemplateTypeVariance::createInvariant(); + foreach ($references as $reference) { + // this uses identity to distinguish between different occurrences of the same template type + // see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details + if ($reference->getType() === $type) { + $variance = $reference->getPositionVariance(); + break; + } + } + + $callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName()); + if ($callSiteVariance === null || $callSiteVariance->invariant()) { + return $newType; + } + + if (!$callSiteVariance->covariant() && $variance->covariant()) { + return $traverse($type->getBound()); + } + + if (!$callSiteVariance->contravariant() && $variance->contravariant()) { + return new NonAcceptingNeverType(); + } + + return $newType; + } + + return $traverse($type); + }; + + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type { + if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { + return TypeTraverser::map($type, $objectCb); + } + + if ($type instanceof TemplateType && !$type->isArgument()) { + $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); + if ($newType === null || $newType instanceof ErrorType) { + return $traverse($type); + } + + $variance = TemplateTypeVariance::createInvariant(); + foreach ($references as $reference) { + // this uses identity to distinguish between different occurrences of the same template type + // see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details + if ($reference->getType() === $type) { + $variance = $reference->getPositionVariance(); + break; + } + } + + $callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName()); + if ($callSiteVariance === null || $callSiteVariance->invariant()) { + return $newType; + } + + if (!$callSiteVariance->covariant() && $variance->covariant()) { + return $traverse($type->getBound()); + } + + if (!$callSiteVariance->contravariant() && $variance->contravariant()) { + return new NonAcceptingNeverType(); + } + + return $newType; + } + + return $traverse($type); + }); + } + + private function resolveConditionalTypesForParameter(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $this->passedArgs)) { + $type = $type->toConditional($this->passedArgs[$type->getParameterName()]); + } + + return $traverse($type); + }); + } + +} diff --git a/src/Reflection/ResolvedMethodReflection.php b/src/Reflection/ResolvedMethodReflection.php new file mode 100644 index 00000000..2a87a423 --- /dev/null +++ b/src/Reflection/ResolvedMethodReflection.php @@ -0,0 +1,223 @@ +|null */ + private ?array $variants = null; + + /** @var list|null */ + private ?array $namedArgumentVariants = null; + + private ?Assertions $asserts = null; + + private Type|false|null $selfOutType = false; + + public function __construct( + private ExtendedMethodReflection $reflection, + private TemplateTypeMap $resolvedTemplateTypeMap, + private TemplateTypeVarianceMap $callSiteVarianceMap, + ) + { + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this->reflection->getPrototype(); + } + + public function getVariants(): array + { + $variants = $this->variants; + if ($variants !== null) { + return $variants; + } + + return $this->variants = $this->resolveVariants($this->reflection->getVariants()); + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + $variants = $this->namedArgumentVariants; + if ($variants !== null) { + return $variants; + } + + $innerVariants = $this->reflection->getNamedArgumentsVariants(); + if ($innerVariants === null) { + return null; + } + + return $this->namedArgumentVariants = $this->resolveVariants($innerVariants); + } + + /** + * @param ExtendedParametersAcceptor[] $variants + * @return list + */ + private function resolveVariants(array $variants): array + { + $result = []; + foreach ($variants as $variant) { + $result[] = new ResolvedFunctionVariantWithOriginal( + $variant, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + [], + ); + } + + return $result; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->reflection->getDeclaringClass(); + } + + public function getDeclaringTrait(): ?ClassReflection + { + if ($this->reflection instanceof PhpMethodReflection) { + return $this->reflection->getDeclaringTrait(); + } + + return null; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->reflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->reflection->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return $this->reflection->isFinalByKeyword(); + } + + public function isInternal(): TrinaryLogic + { + return $this->reflection->isInternal(); + } + + public function getThrowType(): ?Type + { + return $this->reflection->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return $this->reflection->hasSideEffects(); + } + + public function isPure(): TrinaryLogic + { + return $this->reflection->isPure(); + } + + public function getAsserts(): Assertions + { + return $this->asserts ??= $this->reflection->getAsserts()->mapTypes(fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createInvariant(), + )); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->reflection->acceptsNamedArguments(); + } + + public function getSelfOutType(): ?Type + { + if ($this->selfOutType === false) { + $selfOutType = $this->reflection->getSelfOutType(); + if ($selfOutType !== null) { + $selfOutType = TemplateTypeHelper::resolveTemplateTypes( + $selfOutType, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createInvariant(), + ); + } + + $this->selfOutType = $selfOutType; + } + + return $this->selfOutType; + } + + public function returnsByReference(): TrinaryLogic + { + return $this->reflection->returnsByReference(); + } + + public function isAbstract(): TrinaryLogic + { + $abstract = $this->reflection->isAbstract(); + if (is_bool($abstract)) { + return TrinaryLogic::createFromBoolean($abstract); + } + + return $abstract; + } + + public function getAttributes(): array + { + return $this->reflection->getAttributes(); + } + +} diff --git a/src/Reflection/ResolvedPropertyReflection.php b/src/Reflection/ResolvedPropertyReflection.php new file mode 100644 index 00000000..47361040 --- /dev/null +++ b/src/Reflection/ResolvedPropertyReflection.php @@ -0,0 +1,212 @@ +reflection; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->reflection->getDeclaringClass(); + } + + public function getDeclaringTrait(): ?ClassReflection + { + if ($this->reflection instanceof PhpPropertyReflection) { + return $this->reflection->getDeclaringTrait(); + } + + return null; + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->reflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->reflection->isPublic(); + } + + public function hasPhpDocType(): bool + { + return $this->reflection->hasPhpDocType(); + } + + public function getPhpDocType(): Type + { + return $this->reflection->getPhpDocType(); + } + + public function hasNativeType(): bool + { + return $this->reflection->hasNativeType(); + } + + public function getNativeType(): Type + { + return $this->reflection->getNativeType(); + } + + public function getReadableType(): Type + { + $type = $this->readableType; + if ($type !== null) { + return $type; + } + + $type = TemplateTypeHelper::resolveTemplateTypes( + $this->reflection->getReadableType(), + $this->templateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ); + $type = TemplateTypeHelper::resolveTemplateTypes( + $type, + $this->templateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ); + + $this->readableType = $type; + + return $type; + } + + public function getWritableType(): Type + { + $type = $this->writableType; + if ($type !== null) { + return $type; + } + + $type = TemplateTypeHelper::resolveTemplateTypes( + $this->reflection->getWritableType(), + $this->templateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createContravariant(), + ); + $type = TemplateTypeHelper::resolveTemplateTypes( + $type, + $this->templateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createContravariant(), + ); + + $this->writableType = $type; + + return $type; + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->reflection->canChangeTypeAfterAssignment(); + } + + public function isReadable(): bool + { + return $this->reflection->isReadable(); + } + + public function isWritable(): bool + { + return $this->reflection->isWritable(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->reflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->reflection->getDeprecatedDescription(); + } + + public function isInternal(): TrinaryLogic + { + return $this->reflection->isInternal(); + } + + public function isAbstract(): TrinaryLogic + { + return $this->reflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->reflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->reflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return new ResolvedMethodReflection( + $this->reflection->getHook($hookType), + $this->templateTypeMap, + $this->callSiteVarianceMap, + ); + } + + public function isProtectedSet(): bool + { + return $this->reflection->isProtectedSet(); + } + + public function isPrivateSet(): bool + { + return $this->reflection->isPrivateSet(); + } + + public function getAttributes(): array + { + return $this->reflection->getAttributes(); + } + +} diff --git a/src/Reflection/SignatureMap/FunctionSignature.php b/src/Reflection/SignatureMap/FunctionSignature.php new file mode 100644 index 00000000..2ba48142 --- /dev/null +++ b/src/Reflection/SignatureMap/FunctionSignature.php @@ -0,0 +1,46 @@ + $parameters + */ + public function __construct( + private array $parameters, + private Type $returnType, + private Type $nativeReturnType, + private bool $variadic, + ) + { + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function getReturnType(): Type + { + return $this->returnType; + } + + public function getNativeReturnType(): Type + { + return $this->nativeReturnType; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + +} diff --git a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php new file mode 100644 index 00000000..29618e21 --- /dev/null +++ b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php @@ -0,0 +1,263 @@ + */ + private static array $signatureMaps = []; + + /** @var array|null */ + private static ?array $functionMetadata = null; + + public function __construct( + private SignatureMapParser $parser, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private PhpVersion $phpVersion, + private bool $stricterFunctionMap, + ) + { + } + + public function hasMethodSignature(string $className, string $methodName): bool + { + return $this->hasFunctionSignature(sprintf('%s::%s', $className, $methodName)); + } + + public function hasFunctionSignature(string $name): bool + { + return array_key_exists(strtolower($name), $this->getSignatureMap()); + } + + public function getMethodSignatures(string $className, string $methodName, ?ReflectionMethod $reflectionMethod): array + { + return $this->getFunctionSignatures(sprintf('%s::%s', $className, $methodName), $className, $reflectionMethod); + } + + public function getFunctionSignatures(string $functionName, ?string $className, ?ReflectionFunctionAbstract $reflectionFunction): array + { + $functionName = strtolower($functionName); + + $signatures = [$this->createSignature($functionName, $className, $reflectionFunction)]; + $i = 1; + $variantFunctionName = $functionName . '\'' . $i; + while ($this->hasFunctionSignature($variantFunctionName)) { + $signatures[] = $this->createSignature($variantFunctionName, $className, $reflectionFunction); + $i++; + $variantFunctionName = $functionName . '\'' . $i; + } + + return ['positional' => $signatures, 'named' => null]; + } + + private function createSignature(string $functionName, ?string $className, ?ReflectionFunctionAbstract $reflectionFunction): FunctionSignature + { + if (!$reflectionFunction instanceof ReflectionMethod && !$reflectionFunction instanceof ReflectionFunction && $reflectionFunction !== null) { + throw new ShouldNotHappenException(); + } + $signatureMap = self::getSignatureMap(); + $signature = $this->parser->getFunctionSignature( + $signatureMap[$functionName], + $className, + ); + $parameters = []; + foreach ($signature->getParameters() as $i => $parameter) { + if ($reflectionFunction === null) { + $parameters[] = $parameter; + continue; + } + $nativeParameters = $reflectionFunction->getParameters(); + if (!array_key_exists($i, $nativeParameters)) { + $parameters[] = $parameter; + continue; + } + + $parameters[] = new ParameterSignature( + $parameter->getName(), + $parameter->isOptional(), + $parameter->getType(), + TypehintHelper::decideTypeFromReflection($nativeParameters[$i]->getType()), + $parameter->passedByReference(), + $parameter->isVariadic(), + $nativeParameters[$i]->isDefaultValueAvailable() ? $this->initializerExprTypeResolver->getType( + $nativeParameters[$i]->getDefaultValueExpression(), + InitializerExprContext::fromReflectionParameter($nativeParameters[$i]), + ) : null, + $parameter->getOutType(), + ); + } + + if ($reflectionFunction === null) { + $nativeReturnType = new MixedType(); + } else { + $nativeReturnType = TypehintHelper::decideTypeFromReflection($reflectionFunction->getReturnType()); + } + + return new FunctionSignature( + $parameters, + $signature->getReturnType(), + $nativeReturnType, + $signature->isVariadic(), + ); + } + + public function hasMethodMetadata(string $className, string $methodName): bool + { + return $this->hasFunctionMetadata(sprintf('%s::%s', $className, $methodName)); + } + + public function hasFunctionMetadata(string $name): bool + { + $signatureMap = self::getFunctionMetadataMap(); + return array_key_exists(strtolower($name), $signatureMap); + } + + /** + * @return array{hasSideEffects: bool} + */ + public function getMethodMetadata(string $className, string $methodName): array + { + return $this->getFunctionMetadata(sprintf('%s::%s', $className, $methodName)); + } + + /** + * @return array{hasSideEffects: bool} + */ + public function getFunctionMetadata(string $functionName): array + { + $functionName = strtolower($functionName); + + if (!$this->hasFunctionMetadata($functionName)) { + throw new ShouldNotHappenException(); + } + + return self::getFunctionMetadataMap()[$functionName]; + } + + /** + * @return array + */ + private static function getFunctionMetadataMap(): array + { + if (self::$functionMetadata === null) { + /** @var array $metadata */ + $metadata = require __DIR__ . '/../../../resources/functionMetadata.php'; + self::$functionMetadata = array_change_key_case($metadata, CASE_LOWER); + } + + return self::$functionMetadata; + } + + /** + * @return mixed[] + */ + public function getSignatureMap(): array + { + $cacheKey = sprintf('%d-%d', $this->phpVersion->getVersionId(), $this->stricterFunctionMap ? 1 : 0); + if (array_key_exists($cacheKey, self::$signatureMaps)) { + return self::$signatureMaps[$cacheKey]; + } + + $signatureMap = require __DIR__ . '/../../../resources/functionMap.php'; + if (!is_array($signatureMap)) { + throw new ShouldNotHappenException('Signature map could not be loaded.'); + } + + $signatureMap = array_change_key_case($signatureMap, CASE_LOWER); + + if ($this->stricterFunctionMap) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_bleedingEdge.php'); + } + + if ($this->phpVersion->getVersionId() >= 70400) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php74delta.php'); + } + + if ($this->phpVersion->getVersionId() >= 80000) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php80delta.php'); + + if ($this->stricterFunctionMap) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php80delta_bleedingEdge.php'); + } + } + + if ($this->phpVersion->getVersionId() >= 80100) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php81delta.php'); + } + + if ($this->phpVersion->getVersionId() >= 80200) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php82delta.php'); + } + + if ($this->phpVersion->getVersionId() >= 80300) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php83delta.php'); + } + + if ($this->phpVersion->getVersionId() >= 80400) { + $signatureMap = $this->computeSignatureMapFile($signatureMap, __DIR__ . '/../../../resources/functionMap_php84delta.php'); + } + + return self::$signatureMaps[$cacheKey] = $signatureMap; + } + + /** + * @param array $signatureMap + * @return array + */ + private function computeSignatureMapFile(array $signatureMap, string $file): array + { + $signatureMapDelta = include $file; + if (!is_array($signatureMapDelta)) { + throw new ShouldNotHappenException(sprintf('Signature map file "%s" could not be loaded.', $file)); + } + + return $this->computeSignatureMap($signatureMap, $signatureMapDelta); + } + + /** + * @param array $signatureMap + * @param array> $delta + * @return array + */ + private function computeSignatureMap(array $signatureMap, array $delta): array + { + foreach (array_keys($delta['old']) as $key) { + unset($signatureMap[strtolower($key)]); + } + foreach ($delta['new'] as $key => $signature) { + $signatureMap[strtolower($key)] = $signature; + } + + return $signatureMap; + } + + public function hasClassConstantMetadata(string $className, string $constantName): bool + { + return false; + } + + public function getClassConstantMetadata(string $className, string $constantName): array + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php new file mode 100644 index 00000000..507a3eb0 --- /dev/null +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -0,0 +1,195 @@ +functionMap[$lowerCasedFunctionName])) { + return $this->functionMap[$lowerCasedFunctionName]; + } + + if (!$this->signatureMapProvider->hasFunctionSignature($lowerCasedFunctionName)) { + return null; + } + + $throwType = null; + $reflectionFunctionAdapter = null; + $isDeprecated = false; + $phpDocReturnType = null; + $asserts = Assertions::createEmpty(); + $docComment = null; + $returnsByReference = TrinaryLogic::createMaybe(); + $acceptsNamedArguments = true; + $fileName = null; + $attributes = []; + try { + $reflectionFunction = $this->reflector->reflectFunction($functionName); + $reflectionFunctionAdapter = new ReflectionFunction($reflectionFunction); + $attributes = $reflectionFunctionAdapter->getAttributes(); + $returnsByReference = TrinaryLogic::createFromBoolean($reflectionFunctionAdapter->returnsReference()); + $realFunctionName = $reflectionFunction->getName(); + $isDeprecated = $reflectionFunction->isDeprecated(); + if ($reflectionFunction->getFileName() !== null) { + $fileName = $reflectionFunction->getFileName(); + $docComment = $reflectionFunction->getDocComment(); + if ($docComment !== null) { + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc($fileName, null, null, $reflectionFunction->getName(), $docComment); + $throwsTag = $resolvedPhpDoc->getThrowsTag(); + if ($throwsTag !== null) { + $throwType = $throwsTag->getType(); + } + } + } + } catch (IdentifierNotFound | InvalidIdentifierName) { + // pass + } + + $functionSignaturesResult = $this->signatureMapProvider->getFunctionSignatures($lowerCasedFunctionName, null, $reflectionFunctionAdapter); + + $phpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($lowerCasedFunctionName, array_map(static fn (ParameterSignature $parameter): string => $parameter->getName(), $functionSignaturesResult['positional'][0]->getParameters())); + if ($phpDoc !== null) { + if ($phpDoc->hasPhpDocString()) { + $docComment = $phpDoc->getPhpDocString(); + } + if ($phpDoc->getThrowsTag() !== null) { + $throwType = $phpDoc->getThrowsTag()->getType(); + } + $asserts = Assertions::createFromResolvedPhpDocBlock($phpDoc); + $phpDocReturnType = $this->getReturnTypeFromPhpDoc($phpDoc); + $acceptsNamedArguments = $phpDoc->acceptsNamedArguments(); + } + + $variantsByType = ['positional' => []]; + foreach ($functionSignaturesResult as $signatureType => $functionSignatures) { + foreach ($functionSignatures ?? [] as $functionSignature) { + $variantsByType[$signatureType][] = new ExtendedFunctionVariant( + TemplateTypeMap::createEmpty(), + null, + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc): ExtendedNativeParameterReflection { + $type = $parameterSignature->getType(); + + $phpDocType = null; + $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); + $closureThisType = null; + if ($phpDoc !== null) { + if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { + $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); + } + if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamsImmediatelyInvokedCallable())) { + $immediatelyInvokedCallable = TrinaryLogic::createFromBoolean($phpDoc->getParamsImmediatelyInvokedCallable()[$parameterSignature->getName()]); + } + if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamClosureThisTags())) { + $closureThisType = $phpDoc->getParamClosureThisTags()[$parameterSignature->getName()]->getType(); + } + } + + return new ExtendedNativeParameterReflection( + $parameterSignature->getName(), + $parameterSignature->isOptional(), + TypehintHelper::decideType($type, $phpDocType), + $phpDocType ?? new MixedType(), + $type, + $parameterSignature->passedByReference(), + $parameterSignature->isVariadic(), + $parameterSignature->getDefaultValue(), + $phpDoc !== null ? NativeFunctionReflectionProvider::getParamOutTypeFromPhpDoc($parameterSignature->getName(), $phpDoc) : null, + $immediatelyInvokedCallable, + $closureThisType, + [], + ); + }, $functionSignature->getParameters()), + $functionSignature->isVariadic(), + TypehintHelper::decideType($functionSignature->getReturnType(), $phpDocReturnType), + $phpDocReturnType ?? new MixedType(), + $functionSignature->getReturnType(), + ); + } + } + + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $hasSideEffects = TrinaryLogic::createFromBoolean($this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']); + } else { + $hasSideEffects = TrinaryLogic::createMaybe(); + } + + $functionReflection = new NativeFunctionReflection( + $realFunctionName, + $variantsByType['positional'], + $variantsByType['named'] ?? null, + $throwType, + $hasSideEffects, + $isDeprecated, + $asserts, + $docComment, + $returnsByReference, + $acceptsNamedArguments, + $this->attributeReflectionFactory->fromNativeReflection($attributes, InitializerExprContext::fromFunction($realFunctionName, $fileName)), + ); + $this->functionMap[$lowerCasedFunctionName] = $functionReflection; + + return $functionReflection; + } + + private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type + { + $returnTag = $phpDoc->getReturnTag(); + if ($returnTag === null) { + return null; + } + + return $returnTag->getType(); + } + + private static function getParamOutTypeFromPhpDoc(string $paramName, ResolvedPhpDocBlock $stubPhpDoc): ?Type + { + $paramOutTags = $stubPhpDoc->getParamOutTags(); + + if (array_key_exists($paramName, $paramOutTags)) { + return $paramOutTags[$paramName]->getType(); + } + + return null; + } + +} diff --git a/src/Reflection/SignatureMap/ParameterSignature.php b/src/Reflection/SignatureMap/ParameterSignature.php new file mode 100644 index 00000000..13072c4a --- /dev/null +++ b/src/Reflection/SignatureMap/ParameterSignature.php @@ -0,0 +1,65 @@ +name; + } + + public function isOptional(): bool + { + return $this->optional; + } + + public function getType(): Type + { + return $this->type; + } + + public function getNativeType(): Type + { + return $this->nativeType; + } + + public function passedByReference(): PassedByReference + { + return $this->passedByReference; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getDefaultValue(): ?Type + { + return $this->defaultValue; + } + + public function getOutType(): ?Type + { + return $this->outType; + } + +} diff --git a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php new file mode 100644 index 00000000..00a2423f --- /dev/null +++ b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php @@ -0,0 +1,512 @@ +> */ + private array $methodNodes = []; + + /** @var array> */ + private array $constantTypes = []; + + private Php8StubsMap $map; + + public function __construct( + private FunctionSignatureMapProvider $functionSignatureMapProvider, + private FileNodesFetcher $fileNodesFetcher, + private FileTypeMapper $fileTypeMapper, + private PhpVersion $phpVersion, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ReflectionProviderProvider $reflectionProviderProvider, + ) + { + $this->map = new Php8StubsMap($phpVersion->getVersionId()); + } + + public function hasMethodSignature(string $className, string $methodName): bool + { + $lowerClassName = strtolower($className); + if ($lowerClassName === 'backedenum') { + return false; + } + if (!array_key_exists($lowerClassName, $this->map->classes)) { + return $this->functionSignatureMapProvider->hasMethodSignature($className, $methodName); + } + + if ($this->findMethodNode($className, $methodName) === null) { + return $this->functionSignatureMapProvider->hasMethodSignature($className, $methodName); + } + + return true; + } + + /** + * @return array{ClassMethod, string}|null + */ + private function findMethodNode(string $className, string $methodName): ?array + { + $lowerClassName = strtolower($className); + $lowerMethodName = strtolower($methodName); + if (isset($this->methodNodes[$lowerClassName][$lowerMethodName])) { + return $this->methodNodes[$lowerClassName][$lowerMethodName]; + } + + $stubFile = self::DIRECTORY . '/' . $this->map->classes[$lowerClassName]; + $nodes = $this->fileNodesFetcher->fetchNodes($stubFile); + $classes = $nodes->getClassNodes(); + if (count($classes) !== 1) { + throw new ShouldNotHappenException(sprintf('Class %s stub not found in %s.', $className, $stubFile)); + } + + $class = $classes[$lowerClassName]; + if (count($class) !== 1) { + throw new ShouldNotHappenException(sprintf('Class %s stub not found in %s.', $className, $stubFile)); + } + + foreach ($class[0]->getNode()->stmts as $stmt) { + if (!$stmt instanceof ClassMethod) { + continue; + } + + if ($stmt->name->toLowerString() === $lowerMethodName) { + if (!$this->isForCurrentVersion($stmt->attrGroups)) { + continue; + } + return $this->methodNodes[$lowerClassName][$lowerMethodName] = [$stmt, $stubFile]; + } + } + + return null; + } + + /** + * @param AttributeGroup[] $attrGroups + */ + private function isForCurrentVersion(array $attrGroups): bool + { + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toString() === 'Until') { + $arg = $attr->args[0]->value; + if (!$arg instanceof String_) { + throw new ShouldNotHappenException(); + } + $parts = explode('.', $arg->value); + $versionId = (int) $parts[0] * 10000 + (int) ($parts[1] ?? 0) * 100 + (int) ($parts[2] ?? 0); + if ($this->phpVersion->getVersionId() >= $versionId) { + return false; + } + } + if ($attr->name->toString() !== 'Since') { + continue; + } + + $arg = $attr->args[0]->value; + if (!$arg instanceof String_) { + throw new ShouldNotHappenException(); + } + $parts = explode('.', $arg->value); + $versionId = (int) $parts[0] * 10000 + (int) ($parts[1] ?? 0) * 100 + (int) ($parts[2] ?? 0); + if ($this->phpVersion->getVersionId() < $versionId) { + return false; + } + } + } + + return true; + } + + public function hasFunctionSignature(string $name): bool + { + $lowerName = strtolower($name); + if (!array_key_exists($lowerName, $this->map->functions)) { + return $this->functionSignatureMapProvider->hasFunctionSignature($name); + } + + return true; + } + + public function getMethodSignatures(string $className, string $methodName, ?ReflectionMethod $reflectionMethod): array + { + $lowerClassName = strtolower($className); + if (!array_key_exists($lowerClassName, $this->map->classes)) { + return $this->functionSignatureMapProvider->getMethodSignatures($className, $methodName, $reflectionMethod); + } + + $methodNode = $this->findMethodNode($className, $methodName); + if ($methodNode === null) { + return $this->functionSignatureMapProvider->getMethodSignatures($className, $methodName, $reflectionMethod); + } + + [$methodNode, $stubFile] = $methodNode; + + $signature = $this->getSignature($methodNode, $className, $stubFile); + if ($this->functionSignatureMapProvider->hasMethodSignature($className, $methodName)) { + $functionMapSignatures = $this->functionSignatureMapProvider->getMethodSignatures($className, $methodName, $reflectionMethod); + + return $this->getMergedSignatures($signature, $functionMapSignatures); + } + + return ['positional' => [$signature], 'named' => null]; + } + + public function getFunctionSignatures(string $functionName, ?string $className, ReflectionFunctionAbstract|null $reflectionFunction): array + { + $lowerName = strtolower($functionName); + if (!array_key_exists($lowerName, $this->map->functions)) { + return $this->functionSignatureMapProvider->getFunctionSignatures($functionName, $className, $reflectionFunction); + } + + $stubFile = self::DIRECTORY . '/' . $this->map->functions[$lowerName]; + $nodes = $this->fileNodesFetcher->fetchNodes($stubFile); + $functions = $nodes->getFunctionNodes(); + if (!array_key_exists($lowerName, $functions)) { + throw new ShouldNotHappenException(sprintf('Function %s stub not found in %s.', $functionName, $stubFile)); + } + foreach ($functions[$lowerName] as $functionNode) { + if (!$this->isForCurrentVersion($functionNode->getNode()->getAttrGroups())) { + continue; + } + + $signature = $this->getSignature($functionNode->getNode(), null, $stubFile); + if ($this->functionSignatureMapProvider->hasFunctionSignature($functionName)) { + $functionMapSignatures = $this->functionSignatureMapProvider->getFunctionSignatures($functionName, $className, $reflectionFunction); + + return $this->getMergedSignatures($signature, $functionMapSignatures); + } + + return ['positional' => [$signature], 'named' => null]; + } + + throw new ShouldNotHappenException(sprintf('Function %s stub not found in %s.', $functionName, $stubFile)); + } + + /** + * @param array{positional: array, named: ?array} $functionMapSignatures + * @return array{positional: array, named: ?array} + */ + private function getMergedSignatures(FunctionSignature $nativeSignature, array $functionMapSignatures): array + { + if (count($functionMapSignatures['positional']) === 1) { + return ['positional' => [$this->mergeSignatures($nativeSignature, $functionMapSignatures['positional'][0])], 'named' => null]; + } + + if (count($functionMapSignatures['positional']) === 0) { + return ['positional' => [], 'named' => null]; + } + + $nativeParams = $nativeSignature->getParameters(); + $namedArgumentsVariants = []; + $allParamNamesMatchNative = true; + foreach ($functionMapSignatures['positional'] as $functionMapSignature) { + $isPrevParamVariadic = false; + $hasMiddleVariadicParam = false; + // avoid weird functions like array_diff_uassoc + foreach ($functionMapSignature->getParameters() as $i => $functionParam) { + $nativeParam = $nativeParams[$i] ?? null; + $allParamNamesMatchNative = $allParamNamesMatchNative && $nativeParam !== null && $functionParam->getName() === $nativeParam->getName(); + $hasMiddleVariadicParam = $hasMiddleVariadicParam || $isPrevParamVariadic; + $isPrevParamVariadic = $functionParam->isVariadic() || ( + $nativeParam !== null + ? $nativeParam->isVariadic() + : false + ); + } + + if ($hasMiddleVariadicParam) { + continue; + } + + $parameters = []; + foreach ($functionMapSignature->getParameters() as $i => $functionParam) { + if (!array_key_exists($i, $nativeParams)) { + continue 2; + } + + // it seems that variadic parameters cannot be named in native functions/methods. + $nativeParam = $nativeParams[$i]; + if ($nativeParam->isVariadic()) { + break; + } + + $parameters[] = new ParameterSignature( + $nativeParam->getName(), + $functionParam->isOptional(), + $functionParam->getType(), + $functionParam->getNativeType(), + $functionParam->passedByReference(), + $functionParam->isVariadic(), + $functionParam->getDefaultValue(), + $functionParam->getOutType(), + ); + } + + $namedArgumentsVariants[] = new FunctionSignature( + $parameters, + $functionMapSignature->getReturnType(), + $functionMapSignature->getNativeReturnType(), + $functionMapSignature->isVariadic(), + ); + } + + if ($allParamNamesMatchNative || count($namedArgumentsVariants) === 0) { + $namedArgumentsVariants = null; + } + + return ['positional' => $functionMapSignatures['positional'], 'named' => $namedArgumentsVariants]; + } + + private function mergeSignatures(FunctionSignature $nativeSignature, FunctionSignature $functionMapSignature): FunctionSignature + { + $parameters = []; + foreach ($nativeSignature->getParameters() as $i => $nativeParameter) { + if (!array_key_exists($i, $functionMapSignature->getParameters())) { + $parameters[] = $nativeParameter; + continue; + } + + $functionMapParameter = $functionMapSignature->getParameters()[$i]; + $nativeParameterType = $nativeParameter->getNativeType(); + $parameters[] = new ParameterSignature( + $nativeParameter->getName(), + $nativeParameter->isOptional(), + TypehintHelper::decideType( + $nativeParameterType, + TypehintHelper::decideType( + $nativeParameter->getType(), + $functionMapParameter->getType(), + ), + ), + $nativeParameterType, + $nativeParameter->passedByReference()->yes() ? $functionMapParameter->passedByReference() : $nativeParameter->passedByReference(), + $nativeParameter->isVariadic(), + $nativeParameter->getDefaultValue(), + $nativeParameter->getOutType(), + ); + } + + $nativeReturnType = $nativeSignature->getNativeReturnType(); + if ($nativeReturnType instanceof MixedType && !$nativeReturnType->isExplicitMixed()) { + $returnType = $functionMapSignature->getReturnType(); + } else { + $returnType = TypehintHelper::decideType( + $nativeReturnType, + TypehintHelper::decideType( + $nativeSignature->getReturnType(), + $functionMapSignature->getReturnType(), + ), + ); + } + + return new FunctionSignature( + $parameters, + $returnType, + $nativeReturnType, + $nativeSignature->isVariadic(), + ); + } + + public function hasMethodMetadata(string $className, string $methodName): bool + { + return $this->functionSignatureMapProvider->hasMethodMetadata($className, $methodName); + } + + public function hasFunctionMetadata(string $name): bool + { + return $this->functionSignatureMapProvider->hasFunctionMetadata($name); + } + + /** + * @return array{hasSideEffects: bool} + */ + public function getMethodMetadata(string $className, string $methodName): array + { + return $this->functionSignatureMapProvider->getMethodMetadata($className, $methodName); + } + + /** + * @return array{hasSideEffects: bool} + */ + public function getFunctionMetadata(string $functionName): array + { + return $this->functionSignatureMapProvider->getFunctionMetadata($functionName); + } + + private function getSignature( + ClassMethod|Function_ $function, + ?string $className, + string $stubFile, + ): FunctionSignature + { + $phpDocParameterTypes = null; + $phpDocReturnType = null; + if ($function->getDocComment() !== null) { + if ($function instanceof ClassMethod) { + $functionName = $function->name->toString(); + } elseif ($function->namespacedName !== null) { + $functionName = $function->namespacedName->toString(); + } else { + throw new ShouldNotHappenException(); + } + $phpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $stubFile, + $className, + null, + $functionName, + $function->getDocComment()->getText(), + ); + $phpDocParameterTypes = array_map(static fn (ParamTag $param): Type => $param->getType(), $phpDoc->getParamTags()); + if ($phpDoc->getReturnTag() !== null) { + $phpDocReturnType = $phpDoc->getReturnTag()->getType(); + } + } + + $classReflection = null; + if ($className !== null) { + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + $classReflection = $reflectionProvider->getClass($className); + } + + $parameters = []; + $variadic = false; + foreach ($function->getParams() as $param) { + $name = $param->var; + if (!$name instanceof Variable || !is_string($name->name)) { + throw new ShouldNotHappenException(); + } + $parameterType = ParserNodeTypeToPHPStanType::resolve($param->type, $classReflection); + $parameters[] = new ParameterSignature( + $name->name, + $param->default !== null || $param->variadic, + TypehintHelper::decideType($parameterType, $phpDocParameterTypes[$name->name] ?? null), + $parameterType, + $param->byRef ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), + $param->variadic, + $param->default !== null ? $this->initializerExprTypeResolver->getType( + $param->default, + InitializerExprContext::fromStubParameter($className, $stubFile, $function), + ) : null, + null, + ); + + $variadic = $variadic || $param->variadic; + } + + $returnType = ParserNodeTypeToPHPStanType::resolve($function->getReturnType(), $classReflection); + + return new FunctionSignature( + $parameters, + TypehintHelper::decideType($returnType, $phpDocReturnType ?? null), + $returnType, + $variadic, + ); + } + + public function hasClassConstantMetadata(string $className, string $constantName): bool + { + $lowerClassName = strtolower($className); + if (!array_key_exists($lowerClassName, $this->map->classes)) { + return false; + } + + return $this->findConstantType($className, $constantName) !== null; + } + + public function getClassConstantMetadata(string $className, string $constantName): array + { + $lowerClassName = strtolower($className); + if (!array_key_exists($lowerClassName, $this->map->classes)) { + throw new ShouldNotHappenException(); + } + + $type = $this->findConstantType($className, $constantName); + if ($type === null) { + throw new ShouldNotHappenException(); + } + + return [ + 'nativeType' => $type, + ]; + } + + private function findConstantType(string $className, string $constantName): ?Type + { + $lowerClassName = strtolower($className); + $lowerConstantName = strtolower($constantName); + if (isset($this->constantTypes[$lowerClassName][$lowerConstantName])) { + return $this->constantTypes[$lowerClassName][$lowerConstantName]; + } + + $stubFile = self::DIRECTORY . '/' . $this->map->classes[$lowerClassName]; + $nodes = $this->fileNodesFetcher->fetchNodes($stubFile); + $classes = $nodes->getClassNodes(); + if (count($classes) !== 1) { + throw new ShouldNotHappenException(sprintf('Class %s stub not found in %s.', $className, $stubFile)); + } + + $class = $classes[$lowerClassName]; + if (count($class) !== 1) { + throw new ShouldNotHappenException(sprintf('Class %s stub not found in %s.', $className, $stubFile)); + } + + foreach ($class[0]->getNode()->stmts as $stmt) { + if (!$stmt instanceof ClassConst) { + continue; + } + + foreach ($stmt->consts as $const) { + if ($const->name->toString() !== $constantName) { + continue; + } + + if (!$this->isForCurrentVersion($stmt->attrGroups)) { + continue; + } + + if ($stmt->type === null) { + return null; + } + + return $this->constantTypes[$lowerClassName][$lowerConstantName] = ParserNodeTypeToPHPStanType::resolve($stmt->type, null); + } + } + + return null; + } + +} diff --git a/src/Reflection/SignatureMap/SignatureMapParser.php b/src/Reflection/SignatureMap/SignatureMapParser.php new file mode 100644 index 00000000..3ee43777 --- /dev/null +++ b/src/Reflection/SignatureMap/SignatureMapParser.php @@ -0,0 +1,118 @@ +typeStringResolver = $typeNodeResolver; + } + + /** + * @param mixed[] $map + */ + public function getFunctionSignature(array $map, ?string $className): FunctionSignature + { + $parameterSignatures = $this->getParameters(array_slice($map, 1)); + $hasVariadic = false; + foreach ($parameterSignatures as $parameterSignature) { + if ($parameterSignature->isVariadic()) { + $hasVariadic = true; + break; + } + } + return new FunctionSignature( + $parameterSignatures, + $this->getTypeFromString($map[0], $className), + new MixedType(), + $hasVariadic, + ); + } + + private function getTypeFromString(string $typeString, ?string $className): Type + { + if ($typeString === '') { + return new MixedType(true); + } + + return $this->typeStringResolver->resolve($typeString, new NameScope(null, [], $className)); + } + + /** + * @param array $parameterMap + * @return list + */ + private function getParameters(array $parameterMap): array + { + $parameterSignatures = []; + foreach ($parameterMap as $parameterName => $typeString) { + [$name, $isOptional, $passedByReference, $isVariadic] = $this->getParameterInfoFromName($parameterName); + $parameterSignatures[] = new ParameterSignature( + $name, + $isOptional, + $this->getTypeFromString($typeString, null), + new MixedType(), + $passedByReference, + $isVariadic, + null, + null, + ); + } + + return $parameterSignatures; + } + + /** + * @return mixed[] + */ + private function getParameterInfoFromName(string $parameterNameString): array + { + $matches = Strings::match( + $parameterNameString, + '#^(?P&(?:\.\.\.)?r?w?_?)?(?P\.\.\.)?(?P[^=]+)?(?P=)?($)#', + ); + if ($matches === null || !isset($matches['optional'])) { + throw new ShouldNotHappenException(); + } + + $isVariadic = $matches['variadic'] !== ''; + + $reference = $matches['reference']; + if (str_starts_with($reference, '&...')) { + $reference = '&' . substr($reference, 4); + $isVariadic = true; + } + if (str_starts_with($reference, '&rw')) { + $passedByReference = PassedByReference::createReadsArgument(); + } elseif (str_starts_with($reference, '&')) { + $passedByReference = PassedByReference::createCreatesNewVariable(); + } else { + $passedByReference = PassedByReference::createNo(); + } + + $isOptional = $isVariadic || $matches['optional'] !== ''; + + $name = $matches['name'] !== '' ? $matches['name'] : '...'; + + return [$name, $isOptional, $passedByReference, $isVariadic]; + } + +} diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php new file mode 100644 index 00000000..4eb30a01 --- /dev/null +++ b/src/Reflection/SignatureMap/SignatureMapProvider.php @@ -0,0 +1,44 @@ +, named: ?array} */ + public function getMethodSignatures(string $className, string $methodName, ?ReflectionMethod $reflectionMethod): array; + + /** @return array{positional: array, named: ?array} */ + public function getFunctionSignatures(string $functionName, ?string $className, ?ReflectionFunctionAbstract $reflectionFunction): array; + + public function hasMethodMetadata(string $className, string $methodName): bool; + + public function hasFunctionMetadata(string $name): bool; + + /** + * @return array{hasSideEffects: bool} + */ + public function getMethodMetadata(string $className, string $methodName): array; + + /** + * @return array{hasSideEffects: bool} + */ + public function getFunctionMetadata(string $functionName): array; + + public function hasClassConstantMetadata(string $className, string $constantName): bool; + + /** + * @return array{nativeType: Type} + */ + public function getClassConstantMetadata(string $className, string $constantName): array; + +} diff --git a/src/Reflection/SignatureMap/SignatureMapProviderFactory.php b/src/Reflection/SignatureMap/SignatureMapProviderFactory.php new file mode 100644 index 00000000..a2dfab33 --- /dev/null +++ b/src/Reflection/SignatureMap/SignatureMapProviderFactory.php @@ -0,0 +1,28 @@ +phpVersion->getVersionId() < 80000) { + return $this->functionSignatureMapProvider; + } + + return $this->php8SignatureMapProvider; + } + +} diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php new file mode 100644 index 00000000..f3ae9b19 --- /dev/null +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -0,0 +1,102 @@ +callableName), + false, + ), + ]; + } + + public function getInvalidateExpressions(): array + { + return []; + } + + public function getUsedVariables(): array + { + return []; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + +} diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..98b63545 --- /dev/null +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -0,0 +1,145 @@ +transformStaticTypeCallback = $transformStaticTypeCallback; + } + + public function doNotResolveTemplateTypeMapToBounds(): UnresolvedMethodPrototypeReflection + { + if ($this->cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self( + $this->methodReflection, + $this->resolvedDeclaringClass, + false, + $this->transformStaticTypeCallback, + ); + } + + public function getNakedMethod(): ExtendedMethodReflection + { + return $this->methodReflection; + } + + public function getTransformedMethod(): ExtendedMethodReflection + { + if ($this->transformedMethod !== null) { + return $this->transformedMethod; + } + $templateTypeMap = $this->resolvedDeclaringClass->getActiveTemplateTypeMap(); + $callSiteVarianceMap = $this->resolvedDeclaringClass->getCallSiteVarianceMap(); + + return $this->transformedMethod = new ResolvedMethodReflection( + $this->transformMethodWithStaticType($this->resolvedDeclaringClass, $this->methodReflection), + $this->resolveTemplateTypeMapToBounds ? $templateTypeMap->resolveToBounds() : $templateTypeMap, + $callSiteVarianceMap, + ); + } + + public function withCalledOnType(Type $type): UnresolvedMethodPrototypeReflection + { + return new CalledOnTypeUnresolvedMethodPrototypeReflection( + $this->methodReflection, + $this->resolvedDeclaringClass, + $this->resolveTemplateTypeMapToBounds, + $type, + ); + } + + private function transformMethodWithStaticType(ClassReflection $declaringClass, ExtendedMethodReflection $method): ExtendedMethodReflection + { + $selfOutType = $method->getSelfOutType() !== null ? $this->transformStaticType($method->getSelfOutType()) : null; + $variantFn = function (ExtendedParametersAcceptor $acceptor) use (&$selfOutType): ExtendedParametersAcceptor { + $originalReturnType = $acceptor->getReturnType(); + if ($originalReturnType instanceof ThisType && $selfOutType !== null) { + $returnType = TypeCombinator::intersect($selfOutType, $this->transformStaticType($originalReturnType)); + $selfOutType = $returnType; + } else { + $returnType = $this->transformStaticType($originalReturnType); + } + return new ExtendedFunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_map( + fn (ExtendedParameterReflection $parameter): ExtendedParameterReflection => new ExtendedDummyParameter( + $parameter->getName(), + $this->transformStaticType($parameter->getType()), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + $parameter->getNativeType(), + $this->transformStaticType($parameter->getPhpDocType()), + $parameter->getOutType() !== null ? $this->transformStaticType($parameter->getOutType()) : null, + $parameter->isImmediatelyInvokedCallable(), + $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, + $parameter->getAttributes(), + ), + $acceptor->getParameters(), + ), + $acceptor->isVariadic(), + $returnType, + $this->transformStaticType($acceptor->getPhpDocReturnType()), + $this->transformStaticType($acceptor->getNativeReturnType()), + $acceptor->getCallSiteVarianceMap(), + ); + }; + $variants = array_map($variantFn, $method->getVariants()); + $namedArgumentVariants = $method->getNamedArgumentsVariants(); + $namedArgumentVariants = $namedArgumentVariants !== null + ? array_map($variantFn, $namedArgumentVariants) + : null; + + return new ChangedTypeMethodReflection( + $declaringClass, + $method, + $variants, + $namedArgumentVariants, + $selfOutType, + ); + } + + private function transformStaticType(Type $type): Type + { + $callback = $this->transformStaticTypeCallback; + return $callback($type); + } + +} diff --git a/src/Reflection/Type/CallbackUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedPropertyPrototypeReflection.php new file mode 100644 index 00000000..69b06327 --- /dev/null +++ b/src/Reflection/Type/CallbackUnresolvedPropertyPrototypeReflection.php @@ -0,0 +1,95 @@ +transformStaticTypeCallback = $transformStaticTypeCallback; + } + + public function doNotResolveTemplateTypeMapToBounds(): UnresolvedPropertyPrototypeReflection + { + if ($this->cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self( + $this->propertyReflection, + $this->resolvedDeclaringClass, + false, + $this->transformStaticTypeCallback, + ); + } + + public function getNakedProperty(): ExtendedPropertyReflection + { + return $this->propertyReflection; + } + + public function getTransformedProperty(): ExtendedPropertyReflection + { + if ($this->transformedProperty !== null) { + return $this->transformedProperty; + } + $templateTypeMap = $this->resolvedDeclaringClass->getActiveTemplateTypeMap(); + $callSiteVarianceMap = $this->resolvedDeclaringClass->getCallSiteVarianceMap(); + + return $this->transformedProperty = new ResolvedPropertyReflection( + $this->transformPropertyWithStaticType($this->resolvedDeclaringClass, $this->propertyReflection), + $this->resolveTemplateTypeMapToBounds ? $templateTypeMap->resolveToBounds() : $templateTypeMap, + $callSiteVarianceMap, + ); + } + + public function withFechedOnType(Type $type): UnresolvedPropertyPrototypeReflection + { + return new CalledOnTypeUnresolvedPropertyPrototypeReflection( + $this->propertyReflection, + $this->resolvedDeclaringClass, + $this->resolveTemplateTypeMapToBounds, + $type, + ); + } + + private function transformPropertyWithStaticType(ClassReflection $declaringClass, ExtendedPropertyReflection $property): ExtendedPropertyReflection + { + $readableType = $this->transformStaticType($property->getReadableType()); + $writableType = $this->transformStaticType($property->getWritableType()); + $phpDocType = $this->transformStaticType($property->getPhpDocType()); + $nativeType = $this->transformStaticType($property->getNativeType()); + + return new ChangedTypePropertyReflection($declaringClass, $property, $readableType, $writableType, $phpDocType, $nativeType); + } + + private function transformStaticType(Type $type): Type + { + $callback = $this->transformStaticTypeCallback; + return $callback($type); + } + +} diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..c2f81f41 --- /dev/null +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -0,0 +1,155 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self( + $this->methodReflection, + $this->resolvedDeclaringClass, + false, + $this->calledOnType, + ); + } + + public function getNakedMethod(): ExtendedMethodReflection + { + return $this->methodReflection; + } + + public function getTransformedMethod(): ExtendedMethodReflection + { + if ($this->transformedMethod !== null) { + return $this->transformedMethod; + } + $templateTypeMap = $this->resolvedDeclaringClass->getActiveTemplateTypeMap(); + $callSiteVarianceMap = $this->resolvedDeclaringClass->getCallSiteVarianceMap(); + + return $this->transformedMethod = new ResolvedMethodReflection( + $this->transformMethodWithStaticType($this->resolvedDeclaringClass, $this->methodReflection), + $this->resolveTemplateTypeMapToBounds ? $templateTypeMap->resolveToBounds() : $templateTypeMap, + $callSiteVarianceMap, + ); + } + + public function withCalledOnType(Type $type): UnresolvedMethodPrototypeReflection + { + return new self( + $this->methodReflection, + $this->resolvedDeclaringClass, + $this->resolveTemplateTypeMapToBounds, + $type, + ); + } + + private function transformMethodWithStaticType(ClassReflection $declaringClass, ExtendedMethodReflection $method): ExtendedMethodReflection + { + $selfOutType = $method->getSelfOutType() !== null ? $this->transformStaticType($method->getSelfOutType()) : null; + $variantFn = function (ExtendedParametersAcceptor $acceptor) use ($selfOutType): ExtendedParametersAcceptor { + $originalReturnType = $acceptor->getReturnType(); + if ($originalReturnType instanceof ThisType && $selfOutType !== null) { + $returnType = $selfOutType; + } else { + $returnType = $this->transformStaticType($originalReturnType); + } + return new ExtendedFunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + array_map( + fn (ExtendedParameterReflection $parameter): ExtendedParameterReflection => new ExtendedDummyParameter( + $parameter->getName(), + $this->transformStaticType($parameter->getType()), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + $parameter->getNativeType(), + $this->transformStaticType($parameter->getPhpDocType()), + $parameter->getOutType() !== null ? $this->transformStaticType($parameter->getOutType()) : null, + $parameter->isImmediatelyInvokedCallable(), + $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, + $parameter->getAttributes(), + ), + $acceptor->getParameters(), + ), + $acceptor->isVariadic(), + $returnType, + $this->transformStaticType($acceptor->getPhpDocReturnType()), + $this->transformStaticType($acceptor->getNativeReturnType()), + $acceptor->getCallSiteVarianceMap(), + ); + }; + $variants = array_map($variantFn, $method->getVariants()); + $namedArgumentsVariants = $method->getNamedArgumentsVariants(); + $namedArgumentsVariants = $namedArgumentsVariants !== null + ? array_map($variantFn, $namedArgumentsVariants) + : null; + + return new ChangedTypeMethodReflection( + $declaringClass, + $method, + $variants, + $namedArgumentsVariants, + $selfOutType, + ); + } + + private function transformStaticType(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof GenericStaticType) { + $calledOnTypeReflections = $this->calledOnType->getObjectClassReflections(); + if (count($calledOnTypeReflections) === 1) { + $calledOnTypeReflection = $calledOnTypeReflections[0]; + + return $traverse($type->changeBaseClass($calledOnTypeReflection)->getStaticObjectType()); + } + + return $this->calledOnType; + } + if ($type instanceof StaticType) { + return $this->calledOnType; + } + + return $traverse($type); + }); + } + +} diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php new file mode 100644 index 00000000..95f0ba05 --- /dev/null +++ b/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php @@ -0,0 +1,95 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self( + $this->propertyReflection, + $this->resolvedDeclaringClass, + false, + $this->fetchedOnType, + ); + } + + public function getNakedProperty(): ExtendedPropertyReflection + { + return $this->propertyReflection; + } + + public function getTransformedProperty(): ExtendedPropertyReflection + { + if ($this->transformedProperty !== null) { + return $this->transformedProperty; + } + $templateTypeMap = $this->resolvedDeclaringClass->getActiveTemplateTypeMap(); + $callSiteVarianceMap = $this->resolvedDeclaringClass->getCallSiteVarianceMap(); + + return $this->transformedProperty = new ResolvedPropertyReflection( + $this->transformPropertyWithStaticType($this->resolvedDeclaringClass, $this->propertyReflection), + $this->resolveTemplateTypeMapToBounds ? $templateTypeMap->resolveToBounds() : $templateTypeMap, + $callSiteVarianceMap, + ); + } + + public function withFechedOnType(Type $type): UnresolvedPropertyPrototypeReflection + { + return new self( + $this->propertyReflection, + $this->resolvedDeclaringClass, + $this->resolveTemplateTypeMapToBounds, + $type, + ); + } + + private function transformPropertyWithStaticType(ClassReflection $declaringClass, ExtendedPropertyReflection $property): ExtendedPropertyReflection + { + $readableType = $this->transformStaticType($property->getReadableType()); + $writableType = $this->transformStaticType($property->getWritableType()); + $phpDocType = $this->transformStaticType($property->getPhpDocType()); + $nativeType = $this->transformStaticType($property->getNativeType()); + + return new ChangedTypePropertyReflection($declaringClass, $property, $readableType, $writableType, $phpDocType, $nativeType); + } + + private function transformStaticType(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof StaticType) { + return $this->fetchedOnType; + } + + return $traverse($type); + }); + } + +} diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php new file mode 100644 index 00000000..b64a778b --- /dev/null +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -0,0 +1,227 @@ +methods[0]->getDeclaringClass(); + } + + public function isStatic(): bool + { + foreach ($this->methods as $method) { + if ($method->isStatic()) { + return true; + } + } + + return false; + } + + public function isPrivate(): bool + { + foreach ($this->methods as $method) { + if (!$method->isPrivate()) { + return false; + } + } + + return true; + } + + public function isPublic(): bool + { + foreach ($this->methods as $method) { + if ($method->isPublic()) { + return true; + } + } + + return false; + } + + public function getName(): string + { + return $this->methodName; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + $returnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getReturnType(), $method->getVariants())), $this->methods)); + $phpDocReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getPhpDocReturnType(), $method->getVariants())), $this->methods)); + $nativeReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getNativeReturnType(), $method->getVariants())), $this->methods)); + + return array_map(static fn (ExtendedParametersAcceptor $acceptor): ExtendedParametersAcceptor => new ExtendedFunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + $acceptor->getParameters(), + $acceptor->isVariadic(), + $returnType, + $phpDocReturnType, + $nativeReturnType, + $acceptor->getCallSiteVarianceMap(), + ), $this->methods[0]->getVariants()); + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + $variants = $this->getVariants(); + if (count($variants) !== 1) { + throw new ShouldNotHappenException(); + } + + return $variants[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + $descriptions = []; + foreach ($this->methods as $method) { + if (!$method->isDeprecated()->yes()) { + continue; + } + $description = $method->getDeprecatedDescription(); + if ($description === null) { + continue; + } + + $descriptions[] = $description; + } + + if (count($descriptions) === 0) { + return null; + } + + return implode(' ', $descriptions); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isFinal()); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isFinalByKeyword()); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isInternal()); + } + + public function getThrowType(): ?Type + { + $types = []; + + foreach ($this->methods as $method) { + $type = $method->getThrowType(); + if ($type === null) { + continue; + } + + $types[] = $type; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::intersect(...$types); + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->hasSideEffects()); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getAsserts(): Assertions + { + $assertions = Assertions::createEmpty(); + + foreach ($this->methods as $method) { + $assertions = $assertions->intersectWith($method->getAsserts()); + } + + return $assertions; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->returnsByReference()); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => is_bool($method->isAbstract()) ? TrinaryLogic::createFromBoolean($method->isAbstract()) : $method->isAbstract()); + } + + public function getAttributes(): array + { + return $this->methods[0]->getAttributes(); + } + +} diff --git a/src/Reflection/Type/IntersectionTypePropertyReflection.php b/src/Reflection/Type/IntersectionTypePropertyReflection.php new file mode 100644 index 00000000..7fc42795 --- /dev/null +++ b/src/Reflection/Type/IntersectionTypePropertyReflection.php @@ -0,0 +1,199 @@ +properties[0]->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isStatic()); + } + + public function isPrivate(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivate()); + } + + public function isPublic(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPublic()); + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + $descriptions = []; + foreach ($this->properties as $property) { + if (!$property->isDeprecated()->yes()) { + continue; + } + $description = $property->getDeprecatedDescription(); + if ($description === null) { + continue; + } + + $descriptions[] = $description; + } + + if (count($descriptions) === 0) { + return null; + } + + return implode(' ', $descriptions); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); + } + + public function getDocComment(): ?string + { + return null; + } + + public function hasPhpDocType(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasPhpDocType()); + } + + public function getPhpDocType(): Type + { + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getPhpDocType(), $this->properties)); + } + + public function hasNativeType(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasNativeType()); + } + + public function getNativeType(): Type + { + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getNativeType(), $this->properties)); + } + + public function getReadableType(): Type + { + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getReadableType(), $this->properties)); + } + + public function getWritableType(): Type + { + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getWritableType(), $this->properties)); + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->canChangeTypeAfterAssignment()); + } + + public function isReadable(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isReadable()); + } + + public function isWritable(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isWritable()); + } + + /** + * @param callable(ExtendedPropertyReflection): bool $cb + */ + private function computeResult(callable $cb): bool + { + $result = false; + foreach ($this->properties as $property) { + $result = $result || $cb($property); + } + + return $result; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasHook($hookType)); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + $hooks = []; + foreach ($this->properties as $property) { + if (!$property->hasHook($hookType)) { + continue; + } + + $hooks[] = $property->getHook($hookType); + } + + if (count($hooks) === 0) { + throw new ShouldNotHappenException(); + } + + if (count($hooks) === 1) { + return $hooks[0]; + } + + return new IntersectionTypeMethodReflection($hooks[0]->getName(), $hooks); + } + + public function isProtectedSet(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isProtectedSet()); + } + + public function isPrivateSet(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivateSet()); + } + + public function getAttributes(): array + { + return $this->properties[0]->getAttributes(); + } + +} diff --git a/src/Reflection/Type/IntersectionTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/IntersectionTypeUnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..b999cb29 --- /dev/null +++ b/src/Reflection/Type/IntersectionTypeUnresolvedMethodPrototypeReflection.php @@ -0,0 +1,57 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self($this->methodName, array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): UnresolvedMethodPrototypeReflection => $prototype->doNotResolveTemplateTypeMapToBounds(), $this->methodPrototypes)); + } + + public function getNakedMethod(): ExtendedMethodReflection + { + return $this->getTransformedMethod(); + } + + public function getTransformedMethod(): ExtendedMethodReflection + { + if ($this->transformedMethod !== null) { + return $this->transformedMethod; + } + $methods = array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): MethodReflection => $prototype->getTransformedMethod(), $this->methodPrototypes); + + return $this->transformedMethod = new IntersectionTypeMethodReflection($this->methodName, $methods); + } + + public function withCalledOnType(Type $type): UnresolvedMethodPrototypeReflection + { + return new self($this->methodName, array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): UnresolvedMethodPrototypeReflection => $prototype->withCalledOnType($type), $this->methodPrototypes)); + } + +} diff --git a/src/Reflection/Type/IntersectionTypeUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Type/IntersectionTypeUnresolvedPropertyPrototypeReflection.php new file mode 100644 index 00000000..d3a0f490 --- /dev/null +++ b/src/Reflection/Type/IntersectionTypeUnresolvedPropertyPrototypeReflection.php @@ -0,0 +1,57 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self($this->propertyName, array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection => $prototype->doNotResolveTemplateTypeMapToBounds(), $this->propertyPrototypes)); + } + + public function getNakedProperty(): ExtendedPropertyReflection + { + return $this->getTransformedProperty(); + } + + public function getTransformedProperty(): ExtendedPropertyReflection + { + if ($this->transformedProperty !== null) { + return $this->transformedProperty; + } + $properties = array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): PropertyReflection => $prototype->getTransformedProperty(), $this->propertyPrototypes); + + return $this->transformedProperty = new IntersectionTypePropertyReflection($properties); + } + + public function withFechedOnType(Type $type): UnresolvedPropertyPrototypeReflection + { + return new self($this->propertyName, array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection => $prototype->withFechedOnType($type), $this->propertyPrototypes)); + } + +} diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php new file mode 100644 index 00000000..58edd1e0 --- /dev/null +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -0,0 +1,204 @@ +methods[0]->getDeclaringClass(); + } + + public function isStatic(): bool + { + foreach ($this->methods as $method) { + if (!$method->isStatic()) { + return false; + } + } + + return true; + } + + public function isPrivate(): bool + { + foreach ($this->methods as $method) { + if ($method->isPrivate()) { + return true; + } + } + + return false; + } + + public function isPublic(): bool + { + foreach ($this->methods as $method) { + if (!$method->isPublic()) { + return false; + } + } + + return true; + } + + public function getName(): string + { + return $this->methodName; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + $variants = array_merge(...array_map(static fn (MethodReflection $method) => $method->getVariants(), $this->methods)); + + return [ParametersAcceptorSelector::combineAcceptors($variants)]; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + $descriptions = []; + foreach ($this->methods as $method) { + if (!$method->isDeprecated()->yes()) { + continue; + } + $description = $method->getDeprecatedDescription(); + if ($description === null) { + continue; + } + + $descriptions[] = $description; + } + + if (count($descriptions) === 0) { + return null; + } + + return implode(' ', $descriptions); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isFinal()); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isFinalByKeyword()); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->isInternal()); + } + + public function getThrowType(): ?Type + { + $types = []; + + foreach ($this->methods as $method) { + $type = $method->getThrowType(); + if ($type === null) { + continue; + } + + $types[] = $type; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (MethodReflection $method): TrinaryLogic => $method->hasSideEffects()); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->returnsByReference()); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => is_bool($method->isAbstract()) ? TrinaryLogic::createFromBoolean($method->isAbstract()) : $method->isAbstract()); + } + + public function getAttributes(): array + { + return $this->methods[0]->getAttributes(); + } + +} diff --git a/src/Reflection/Type/UnionTypePropertyReflection.php b/src/Reflection/Type/UnionTypePropertyReflection.php new file mode 100644 index 00000000..3f7a435e --- /dev/null +++ b/src/Reflection/Type/UnionTypePropertyReflection.php @@ -0,0 +1,199 @@ +properties[0]->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isStatic()); + } + + public function isPrivate(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivate()); + } + + public function isPublic(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPublic()); + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + $descriptions = []; + foreach ($this->properties as $property) { + if (!$property->isDeprecated()->yes()) { + continue; + } + $description = $property->getDeprecatedDescription(); + if ($description === null) { + continue; + } + + $descriptions[] = $description; + } + + if (count($descriptions) === 0) { + return null; + } + + return implode(' ', $descriptions); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); + } + + public function getDocComment(): ?string + { + return null; + } + + public function hasPhpDocType(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasPhpDocType()); + } + + public function getPhpDocType(): Type + { + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getPhpDocType(), $this->properties)); + } + + public function hasNativeType(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasNativeType()); + } + + public function getNativeType(): Type + { + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getNativeType(), $this->properties)); + } + + public function getReadableType(): Type + { + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getReadableType(), $this->properties)); + } + + public function getWritableType(): Type + { + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getWritableType(), $this->properties)); + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->canChangeTypeAfterAssignment()); + } + + public function isReadable(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isReadable()); + } + + public function isWritable(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isWritable()); + } + + /** + * @param callable(ExtendedPropertyReflection): bool $cb + */ + private function computeResult(callable $cb): bool + { + $result = true; + foreach ($this->properties as $property) { + $result = $result && $cb($property); + } + + return $result; + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasHook($hookType)); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + $hooks = []; + foreach ($this->properties as $property) { + if (!$property->hasHook($hookType)) { + continue; + } + + $hooks[] = $property->getHook($hookType); + } + + if (count($hooks) === 0) { + throw new ShouldNotHappenException(); + } + + if (count($hooks) === 1) { + return $hooks[0]; + } + + return new UnionTypeMethodReflection($hooks[0]->getName(), $hooks); + } + + public function isProtectedSet(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isProtectedSet()); + } + + public function isPrivateSet(): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivateSet()); + } + + public function getAttributes(): array + { + return $this->properties[0]->getAttributes(); + } + +} diff --git a/src/Reflection/Type/UnionTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/UnionTypeUnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..c1905241 --- /dev/null +++ b/src/Reflection/Type/UnionTypeUnresolvedMethodPrototypeReflection.php @@ -0,0 +1,58 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self($this->methodName, array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): UnresolvedMethodPrototypeReflection => $prototype->doNotResolveTemplateTypeMapToBounds(), $this->methodPrototypes)); + } + + public function getNakedMethod(): ExtendedMethodReflection + { + return $this->getTransformedMethod(); + } + + public function getTransformedMethod(): ExtendedMethodReflection + { + if ($this->transformedMethod !== null) { + return $this->transformedMethod; + } + + $methods = array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): MethodReflection => $prototype->getTransformedMethod(), $this->methodPrototypes); + + return $this->transformedMethod = new UnionTypeMethodReflection($this->methodName, $methods); + } + + public function withCalledOnType(Type $type): UnresolvedMethodPrototypeReflection + { + return new self($this->methodName, array_map(static fn (UnresolvedMethodPrototypeReflection $prototype): UnresolvedMethodPrototypeReflection => $prototype->withCalledOnType($type), $this->methodPrototypes)); + } + +} diff --git a/src/Reflection/Type/UnionTypeUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Type/UnionTypeUnresolvedPropertyPrototypeReflection.php new file mode 100644 index 00000000..7fc3a404 --- /dev/null +++ b/src/Reflection/Type/UnionTypeUnresolvedPropertyPrototypeReflection.php @@ -0,0 +1,57 @@ +cachedDoNotResolveTemplateTypeMapToBounds !== null) { + return $this->cachedDoNotResolveTemplateTypeMapToBounds; + } + return $this->cachedDoNotResolveTemplateTypeMapToBounds = new self($this->propertyName, array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection => $prototype->doNotResolveTemplateTypeMapToBounds(), $this->propertyPrototypes)); + } + + public function getNakedProperty(): ExtendedPropertyReflection + { + return $this->getTransformedProperty(); + } + + public function getTransformedProperty(): ExtendedPropertyReflection + { + if ($this->transformedProperty !== null) { + return $this->transformedProperty; + } + + $methods = array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): PropertyReflection => $prototype->getTransformedProperty(), $this->propertyPrototypes); + + return $this->transformedProperty = new UnionTypePropertyReflection($methods); + } + + public function withFechedOnType(Type $type): UnresolvedPropertyPrototypeReflection + { + return new self($this->propertyName, array_map(static fn (UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection => $prototype->withFechedOnType($type), $this->propertyPrototypes)); + } + +} diff --git a/src/Reflection/Type/UnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/UnresolvedMethodPrototypeReflection.php new file mode 100644 index 00000000..2959836a --- /dev/null +++ b/src/Reflection/Type/UnresolvedMethodPrototypeReflection.php @@ -0,0 +1,20 @@ +method->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->method->isStatic(); + } + + public function isPrivate(): bool + { + return $this->method->isPrivate(); + } + + public function isPublic(): bool + { + return $this->method->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->method->getDocComment(); + } + + public function getName(): string + { + return $this->method->getName(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this->method->getPrototype(); + } + + public function getVariants(): array + { + $variants = []; + foreach ($this->method->getVariants() as $variant) { + if ($variant instanceof ExtendedParametersAcceptor) { + $variants[] = $variant; + continue; + } + + $variants[] = new ExtendedFunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + array_map(static fn (ParameterReflection $parameter): ExtendedParameterReflection => $parameter instanceof ExtendedParameterReflection ? $parameter : new ExtendedDummyParameter( + $parameter->getName(), + $parameter->getType(), + $parameter->isOptional(), + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + new MixedType(), + $parameter->getType(), + null, + TrinaryLogic::createMaybe(), + null, + [], + ), $variant->getParameters()), + $variant->isVariadic(), + $variant->getReturnType(), + $variant->getReturnType(), + new MixedType(), + TemplateTypeVarianceMap::createEmpty(), + ); + } + + return $variants; + } + + public function getOnlyVariant(): ExtendedParametersAcceptor + { + return $this->getVariants()[0]; + } + + public function getNamedArgumentsVariants(): ?array + { + return null; + } + + public function isDeprecated(): TrinaryLogic + { + return $this->method->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->method->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return $this->method->isFinal(); + } + + public function isFinalByKeyword(): TrinaryLogic + { + return $this->isFinal(); + } + + public function isInternal(): TrinaryLogic + { + return $this->method->isInternal(); + } + + public function getThrowType(): ?Type + { + return $this->method->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return $this->method->hasSideEffects(); + } + + public function isPure(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getDeclaringClass()->acceptsNamedArguments()); + } + + public function getSelfOutType(): ?Type + { + return null; + } + + public function returnsByReference(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/WrappedExtendedPropertyReflection.php b/src/Reflection/WrappedExtendedPropertyReflection.php new file mode 100644 index 00000000..e9143f71 --- /dev/null +++ b/src/Reflection/WrappedExtendedPropertyReflection.php @@ -0,0 +1,143 @@ +property->getDeclaringClass(); + } + + public function isStatic(): bool + { + return $this->property->isStatic(); + } + + public function isPrivate(): bool + { + return $this->property->isPrivate(); + } + + public function isPublic(): bool + { + return $this->property->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->property->getDocComment(); + } + + public function hasPhpDocType(): bool + { + return false; + } + + public function getPhpDocType(): Type + { + return new MixedType(); + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->property->getReadableType(); + } + + public function getWritableType(): Type + { + return $this->property->getWritableType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->property->canChangeTypeAfterAssignment(); + } + + public function isReadable(): bool + { + return $this->property->isReadable(); + } + + public function isWritable(): bool + { + return $this->property->isWritable(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->property->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->property->getDeprecatedDescription(); + } + + public function isInternal(): TrinaryLogic + { + return $this->property->isInternal(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Reflection/WrapperPropertyReflection.php b/src/Reflection/WrapperPropertyReflection.php new file mode 100644 index 00000000..383aaa80 --- /dev/null +++ b/src/Reflection/WrapperPropertyReflection.php @@ -0,0 +1,11 @@ + + */ +final class ApiClassConstFetchRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\ClassConstFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + if (!$node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $classReflection->getName(), $classReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Accessing %s::%s is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', + $classReflection->getDisplayName(), + $node->name->toString(), + ))->identifier('phpstanApi.classConstant')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + $docBlock = $classReflection->getResolvedPhpDoc(); + if ($docBlock !== null) { + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + } + + if ($node->name->toLowerString() === 'class') { + foreach ($classReflection->getNativeReflection()->getMethods() as $methodReflection) { + $methodDocComment = $methodReflection->getDocComment(); + if ($methodDocComment === false) { + continue; + } + + if (!str_contains($methodDocComment, '@api')) { + continue; + } + + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/src/Rules/Api/ApiClassExtendsRule.php b/src/Rules/Api/ApiClassExtendsRule.php new file mode 100644 index 00000000..0b4818e2 --- /dev/null +++ b/src/Rules/Api/ApiClassExtendsRule.php @@ -0,0 +1,77 @@ + + */ +final class ApiClassExtendsRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->extends === null) { + return []; + } + + $extendedClassName = (string) $node->extends; + if (!$this->reflectionProvider->hasClass($extendedClassName)) { + return []; + } + + $extendedClassReflection = $this->reflectionProvider->getClass($extendedClassName); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $extendedClassReflection->getName(), $extendedClassReflection->getFileName())) { + return []; + } + + if ($extendedClassReflection->getName() === MutatingScope::class) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Extending %s is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', + $extendedClassReflection->getDisplayName(), + ))->identifier('phpstanApi.class')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + $docBlock = $extendedClassReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/src/Rules/Api/ApiClassImplementsRule.php b/src/Rules/Api/ApiClassImplementsRule.php new file mode 100644 index 00000000..9762469a --- /dev/null +++ b/src/Rules/Api/ApiClassImplementsRule.php @@ -0,0 +1,88 @@ + + */ +final class ApiClassImplementsRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->implements as $implements) { + $errors = array_merge($errors, $this->checkName($scope, $implements)); + } + + return $errors; + } + + /** + * @return list + */ + private function checkName(Scope $scope, Node\Name $name): array + { + $implementedClassName = (string) $name; + if (!$this->reflectionProvider->hasClass($implementedClassName)) { + return []; + } + + $implementedClassReflection = $this->reflectionProvider->getClass($implementedClassName); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $implementedClassReflection->getName(), $implementedClassReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Implementing %s is not covered by backward compatibility promise. The interface might change in a minor PHPStan version.', + $implementedClassReflection->getDisplayName(), + ))->identifier('phpstanApi.interface')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + if (in_array($implementedClassReflection->getName(), BcUncoveredInterface::CLASSES, true)) { + return [$ruleError]; + } + + $docBlock = $implementedClassReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/src/Rules/Api/ApiInstanceofRule.php b/src/Rules/Api/ApiInstanceofRule.php new file mode 100644 index 00000000..5635b3c9 --- /dev/null +++ b/src/Rules/Api/ApiInstanceofRule.php @@ -0,0 +1,119 @@ + + */ +final class ApiInstanceofRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Instanceof_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $classReflection->getName(), $classReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Asking about instanceof %s is not covered by backward compatibility promise. The %s might change in a minor PHPStan version.', + $classReflection->getDisplayName(), + strtolower($classReflection->getClassTypeDescription()), + )) + ->identifier(sprintf('phpstanApi.%s', strtolower($classReflection->getClassTypeDescription()))) + ->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + $docBlock = $classReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return $this->processCoveredClass($node, $scope, $classReflection); + } + } + + return [$ruleError]; + } + + /** + * @return list + */ + private function processCoveredClass(Node\Expr\Instanceof_ $node, Scope $scope, ClassReflection $classReflection): array + { + if ($classReflection->getName() === Type::class || $classReflection->isSubclassOf(Type::class)) { + return []; + } + if ($classReflection->isInterface()) { + return []; + } + + $instanceofType = $scope->getType($node); + if ($instanceofType->isTrue()->or($instanceofType->isFalse())->yes()) { + return []; + } + + $classType = new ObjectType($classReflection->getName(), null, $classReflection); + + $exprType = $scope->getType($node->expr); + if ($exprType instanceof UnionType) { + foreach ($exprType->getTypes() as $innerType) { + if ($innerType->getObjectClassNames() !== [] && $classType->isSuperTypeOf($innerType)->yes()) { + return []; + } + } + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Although %s is covered by backward compatibility promise, this instanceof assumption might break because it\'s not guaranteed to always stay the same.', + $classReflection->getDisplayName(), + ))->identifier('phpstanApi.instanceofAssumption')->tip(sprintf( + "In case of questions how to solve this correctly, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(), + ]; + } + +} diff --git a/src/Rules/Api/ApiInstanceofTypeRule.php b/src/Rules/Api/ApiInstanceofTypeRule.php new file mode 100644 index 00000000..2115a2e3 --- /dev/null +++ b/src/Rules/Api/ApiInstanceofTypeRule.php @@ -0,0 +1,158 @@ + + */ +final class ApiInstanceofTypeRule implements Rule +{ + + private const MAP = [ + TypeWithClassName::class => 'Type::getObjectClassNames() or Type::getObjectClassReflections()', + EnumCaseObjectType::class => 'Type::getEnumCases()', + ConstantArrayType::class => 'Type::getConstantArrays()', + ArrayType::class => 'Type::isArray() or Type::getArrays()', + ConstantStringType::class => 'Type::getConstantStrings()', + StringType::class => 'Type::isString()', + ClassStringType::class => 'Type::isClassStringType()', + IntegerType::class => 'Type::isInteger()', + FloatType::class => 'Type::isFloat()', + NullType::class => 'Type::isNull()', + VoidType::class => 'Type::isVoid()', + BooleanType::class => 'Type::isBoolean()', + ConstantBooleanType::class => 'Type::isTrue() or Type::isFalse()', + CallableType::class => 'Type::isCallable() and Type::getCallableParametersAcceptors()', + IterableType::class => 'Type::isIterable()', + ObjectWithoutClassType::class => 'Type::isObject()', + ObjectType::class => 'Type::isObject() or Type::getObjectClassNames()', + GenericClassStringType::class => 'Type::isClassStringType() and Type::getClassStringObjectType()', + GenericObjectType::class => null, + IntersectionType::class => null, + ConstantScalarType::class => 'Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues()', + ObjectShapeType::class => 'Type::isObject() and Type::hasProperty()', + + // accessory types + NonEmptyArrayType::class => 'Type::isIterableAtLeastOnce()', + OversizedArrayType::class => 'Type::isOversizedArray()', + AccessoryArrayListType::class => 'Type::isList()', + AccessoryNumericStringType::class => 'Type::isNumericString()', + AccessoryLiteralStringType::class => 'Type::isLiteralString()', + AccessoryLowercaseStringType::class => 'Type::isLowercaseString()', + AccessoryUppercaseStringType::class => 'Type::isUppercaseString()', + AccessoryNonEmptyStringType::class => 'Type::isNonEmptyString()', + AccessoryNonFalsyStringType::class => 'Type::isNonFalsyString()', + HasMethodType::class => 'Type::hasMethod()', + HasPropertyType::class => 'Type::hasProperty()', + HasOffsetType::class => 'Type::hasOffsetValueType()', + AccessoryType::class => 'methods on PHPStan\\Type\\Type', + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Instanceof_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + if ($node->getAttribute(TypeTraverserInstanceofVisitor::ATTRIBUTE_NAME, false) === true) { + return []; + } + + $lowerMap = []; + foreach (self::MAP as $className => $method) { + $lowerMap[strtolower($className)] = $method; + } + + $className = $scope->resolveName($node->class); + $lowerClassName = strtolower($className); + if (!array_key_exists($lowerClassName, $lowerMap)) { + return []; + } + + if ($this->reflectionProvider->hasClass($className)) { + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->isSubclassOf(AccessoryType::class)) { + if ($className === $classReflection->getName()) { + return []; + } + } + } + + $tip = 'Learn more: https://phpstan.org/blog/why-is-instanceof-type-wrong-and-getting-deprecated'; + if ($lowerMap[$lowerClassName] === null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Doing instanceof %s is error-prone and deprecated.', + $className, + ))->identifier('phpstanApi.instanceofType')->tip($tip)->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Doing instanceof %s is error-prone and deprecated. Use %s instead.', + $className, + $lowerMap[$lowerClassName], + ))->identifier('phpstanApi.instanceofType')->tip($tip)->build(), + ]; + } + +} diff --git a/src/Rules/Api/ApiInstantiationRule.php b/src/Rules/Api/ApiInstantiationRule.php new file mode 100644 index 00000000..3ec072ec --- /dev/null +++ b/src/Rules/Api/ApiInstantiationRule.php @@ -0,0 +1,77 @@ + + */ +final class ApiInstantiationRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $classReflection->getName(), $classReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Creating new %s is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', + $classReflection->getDisplayName(), + ))->identifier('phpstanApi.constructor')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + if (!$classReflection->hasConstructor()) { + return [$ruleError]; + } + + $constructor = $classReflection->getConstructor(); + $docComment = $constructor->getDocComment(); + if ($docComment === null) { + return [$ruleError]; + } + + if (!str_contains($docComment, '@api')) { + return [$ruleError]; + } + + if ($constructor->getDeclaringClass()->getName() !== $classReflection->getName()) { + return [$ruleError]; + } + + return []; + } + +} diff --git a/src/Rules/Api/ApiInterfaceExtendsRule.php b/src/Rules/Api/ApiInterfaceExtendsRule.php new file mode 100644 index 00000000..66334ffd --- /dev/null +++ b/src/Rules/Api/ApiInterfaceExtendsRule.php @@ -0,0 +1,88 @@ + + */ +final class ApiInterfaceExtendsRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Interface_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->extends as $extends) { + $errors = array_merge($errors, $this->checkName($scope, $extends)); + } + + return $errors; + } + + /** + * @return list + */ + private function checkName(Scope $scope, Node\Name $name): array + { + $extendedInterface = (string) $name; + if (!$this->reflectionProvider->hasClass($extendedInterface)) { + return []; + } + + $extendedInterfaceReflection = $this->reflectionProvider->getClass($extendedInterface); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $extendedInterfaceReflection->getName(), $extendedInterfaceReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Extending %s is not covered by backward compatibility promise. The interface might change in a minor PHPStan version.', + $extendedInterfaceReflection->getDisplayName(), + ))->identifier('phpstanApi.interface')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + if (in_array($extendedInterfaceReflection->getName(), BcUncoveredInterface::CLASSES, true)) { + return [$ruleError]; + } + + $docBlock = $extendedInterfaceReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/src/Rules/Api/ApiMethodCallRule.php b/src/Rules/Api/ApiMethodCallRule.php new file mode 100644 index 00000000..65e97287 --- /dev/null +++ b/src/Rules/Api/ApiMethodCallRule.php @@ -0,0 +1,83 @@ + + */ +final class ApiMethodCallRule implements Rule +{ + + public function __construct(private ApiRuleHelper $apiRuleHelper) + { + } + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $methodReflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + if ($methodReflection === null) { + return []; + } + + $declaringClass = $methodReflection->getDeclaringClass(); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $declaringClass->getName(), $declaringClass->getFileName())) { + return []; + } + + if ($this->isCovered($methodReflection)) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Calling %s::%s() is not covered by backward compatibility promise. The method might change in a minor PHPStan version.', + $declaringClass->getDisplayName(), + $methodReflection->getName(), + ))->identifier('phpstanApi.method')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + return [$ruleError]; + } + + private function isCovered(MethodReflection $methodReflection): bool + { + $declaringClass = $methodReflection->getDeclaringClass(); + $classDocBlock = $declaringClass->getResolvedPhpDoc(); + if ($classDocBlock !== null) { + foreach ($classDocBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return true; + } + } + } + + $methodDocComment = $methodReflection->getDocComment(); + if ($methodDocComment === null) { + return false; + } + + return str_contains($methodDocComment, '@api'); + } + +} diff --git a/src/Rules/Api/ApiRuleHelper.php b/src/Rules/Api/ApiRuleHelper.php new file mode 100644 index 00000000..e00310f9 --- /dev/null +++ b/src/Rules/Api/ApiRuleHelper.php @@ -0,0 +1,87 @@ +getNamespace(); + if ($scopeNamespace === null) { + return $this->isPhpStanName($namespace); + } + + if ($this->isPhpStanName($scopeNamespace)) { + if (!$this->isPhpStanName($namespace)) { + return false; + } + + if ($declaringFile !== null) { + $scopeFile = $scope->getFile(); + $dir = dirname($scopeFile); + $helper = new ParentDirectoryRelativePathHelper($dir); + $pathParts = $helper->getFilenameParts($declaringFile); + $directories = $this->createAbsoluteDirectories($dir, $pathParts); + foreach ($directories as $directory) { + if (pathinfo($directory, PATHINFO_BASENAME) === 'vendor') { + return true; + } + } + } + + return false; + } + + return $this->isPhpStanName($namespace); + } + + /** + * @param string[] $parts + * @return string[] + */ + private function createAbsoluteDirectories(string $currentDirectory, array $parts): array + { + $directories = []; + foreach ($parts as $part) { + if ($part === '..') { + $currentDirectory = dirname($currentDirectory); + $directories[] = $currentDirectory; + continue; + } + + $currentDirectory .= '/' . $part; + $directories[] = $currentDirectory; + } + + return $directories; + } + + public function isPhpStanName(string $namespace): bool + { + if (strtolower($namespace) === 'phpstan') { + return true; + } + + if (str_starts_with($namespace, 'PHPStan\\PhpDocParser\\')) { + return false; + } + + if (str_starts_with($namespace, 'PHPStan\\BetterReflection\\')) { + return false; + } + + return stripos($namespace, 'PHPStan\\') === 0; + } + +} diff --git a/src/Rules/Api/ApiStaticCallRule.php b/src/Rules/Api/ApiStaticCallRule.php new file mode 100644 index 00000000..85459c34 --- /dev/null +++ b/src/Rules/Api/ApiStaticCallRule.php @@ -0,0 +1,98 @@ + + */ +final class ApiStaticCallRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + if (!$node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + $methodName = $node->name->toString(); + if (!$classReflection->hasNativeMethod($methodName)) { + return []; + } + + $methodReflection = $classReflection->getNativeMethod($methodName); + $declaringClass = $methodReflection->getDeclaringClass(); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $declaringClass->getName(), $declaringClass->getFileName())) { + return []; + } + + if ($this->isCovered($methodReflection)) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Calling %s::%s() is not covered by backward compatibility promise. The method might change in a minor PHPStan version.', + $declaringClass->getDisplayName(), + $methodReflection->getName(), + ))->identifier('phpstanApi.method')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + return [$ruleError]; + } + + private function isCovered(MethodReflection $methodReflection): bool + { + $declaringClass = $methodReflection->getDeclaringClass(); + $classDocBlock = $declaringClass->getResolvedPhpDoc(); + if ($methodReflection->getName() !== '__construct' && $classDocBlock !== null) { + foreach ($classDocBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return true; + } + } + } + + $methodDocComment = $methodReflection->getDocComment(); + if ($methodDocComment === null) { + return false; + } + + return str_contains($methodDocComment, '@api'); + } + +} diff --git a/src/Rules/Api/ApiTraitUseRule.php b/src/Rules/Api/ApiTraitUseRule.php new file mode 100644 index 00000000..445d42e6 --- /dev/null +++ b/src/Rules/Api/ApiTraitUseRule.php @@ -0,0 +1,58 @@ + + */ +final class ApiTraitUseRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\TraitUse::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $tip = sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ); + foreach ($node->traits as $traitName) { + $traitName = $traitName->toString(); + if (!$this->reflectionProvider->hasClass($traitName)) { + continue; + } + + $traitReflection = $this->reflectionProvider->getClass($traitName); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $traitReflection->getName(), $traitReflection->getFileName())) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Using %s is not covered by backward compatibility promise. The trait might change in a minor PHPStan version.', + $traitReflection->getDisplayName(), + ))->identifier('phpstanApi.trait')->tip($tip)->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Api/BcUncoveredInterface.php b/src/Rules/Api/BcUncoveredInterface.php new file mode 100644 index 00000000..05aed513 --- /dev/null +++ b/src/Rules/Api/BcUncoveredInterface.php @@ -0,0 +1,73 @@ + + */ +final class GetTemplateTypeRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $args = $node->getArgs(); + if (count($args) < 2) { + return []; + } + if (!$node->name instanceof Node\Identifier) { + return []; + } + + if ($node->name->toLowerString() !== 'gettemplatetype') { + return []; + } + + $calledOnType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->toString()); + if ($methodReflection === null) { + return []; + } + + if (!$methodReflection->getDeclaringClass()->is(Type::class)) { + return []; + } + + $classType = $scope->getType($args[0]->value); + $templateType = $scope->getType($args[1]->value); + $errors = []; + foreach ($classType->getConstantStrings() as $classNameType) { + if (!$this->reflectionProvider->hasClass($classNameType->getValue())) { + continue; + } + $classReflection = $this->reflectionProvider->getClass($classNameType->getValue()); + $templateTypeMap = $classReflection->getTemplateTypeMap(); + foreach ($templateType->getConstantStrings() as $templateTypeName) { + if ($templateTypeMap->hasType($templateTypeName->getValue())) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to %s::%s() references unknown template type %s on class %s.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $templateTypeName->getValue(), + $classReflection->getDisplayName(), + ))->identifier('phpstanApi.getTemplateType')->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Api/NodeConnectingVisitorAttributesRule.php b/src/Rules/Api/NodeConnectingVisitorAttributesRule.php new file mode 100644 index 00000000..670e5fdd --- /dev/null +++ b/src/Rules/Api/NodeConnectingVisitorAttributesRule.php @@ -0,0 +1,78 @@ + + */ +final class NodeConnectingVisitorAttributesRule implements Rule +{ + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + if ($node->name->toLowerString() !== 'getattribute') { + return []; + } + $calledOnType = $scope->getType($node->var); + if (!(new ObjectType(Node::class))->isSuperTypeOf($calledOnType)->yes()) { + return []; + } + $args = $node->getArgs(); + if (!isset($args[0])) { + return []; + } + $argType = $scope->getType($args[0]->value); + if (!$argType instanceof ConstantStringType) { + return []; + } + if (!in_array($argType->getValue(), ['parent', 'previous', 'next'], true)) { + return []; + } + if (!$scope->isInClass()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + $hasPhpStanInterface = false; + foreach (array_keys($classReflection->getInterfaces()) as $interfaceName) { + if (!str_starts_with($interfaceName, 'PHPStan\\')) { + continue; + } + + $hasPhpStanInterface = true; + } + + if (!$hasPhpStanInterface) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Node attribute \'%s\' is no longer available.', $argType->getValue())) + ->identifier('phpParser.nodeConnectingAttribute') + ->tip('See: https://phpstan.org/blog/preprocessing-ast-for-custom-rules') + ->build(), + ]; + } + +} diff --git a/src/Rules/Api/OldPhpParser4ClassRule.php b/src/Rules/Api/OldPhpParser4ClassRule.php new file mode 100644 index 00000000..3f7ad23d --- /dev/null +++ b/src/Rules/Api/OldPhpParser4ClassRule.php @@ -0,0 +1,80 @@ + + */ +final class OldPhpParser4ClassRule implements Rule +{ + + private const NAME_MAPPING = [ + // from https://github.com/nikic/PHP-Parser/blob/master/UPGRADE-5.0.md#renamed-nodes + 'PhpParser\Node\Scalar\LNumber' => Node\Scalar\Int_::class, + 'PhpParser\Node\Scalar\DNumber' => Node\Scalar\Float_::class, + 'PhpParser\Node\Scalar\Encapsed' => Node\Scalar\InterpolatedString::class, + 'PhpParser\Node\Scalar\EncapsedStringPart' => Node\InterpolatedStringPart::class, + 'PhpParser\Node\Expr\ArrayItem' => Node\ArrayItem::class, + 'PhpParser\Node\Expr\ClosureUse' => Node\ClosureUse::class, + 'PhpParser\Node\Stmt\DeclareDeclare' => Node\DeclareItem::class, + 'PhpParser\Node\Stmt\PropertyProperty' => Node\PropertyItem::class, + 'PhpParser\Node\Stmt\StaticVar' => Node\StaticVar::class, + 'PhpParser\Node\Stmt\UseUse' => Node\UseItem::class, + ]; + + public function getNodeType(): string + { + return Name::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $nameMapping = array_change_key_case(self::NAME_MAPPING); + $lowerName = $node->toLowerString(); + if (!array_key_exists($lowerName, $nameMapping)) { + return []; + } + + $newName = $nameMapping[$lowerName]; + + if (!$scope->isInClass()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + $hasPhpStanInterface = false; + foreach (array_keys($classReflection->getInterfaces()) as $interfaceName) { + if (!str_starts_with($interfaceName, 'PHPStan\\')) { + continue; + } + + $hasPhpStanInterface = true; + } + + if (!$hasPhpStanInterface) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Class %s not found. It has been renamed to %s in PHP-Parser v5.', + $node->toString(), + $newName, + ))->identifier('phpParser.classRenamed') + ->build(), + ]; + } + +} diff --git a/src/Rules/Api/PhpStanNamespaceIn3rdPartyPackageRule.php b/src/Rules/Api/PhpStanNamespaceIn3rdPartyPackageRule.php new file mode 100644 index 00000000..8c0ebac5 --- /dev/null +++ b/src/Rules/Api/PhpStanNamespaceIn3rdPartyPackageRule.php @@ -0,0 +1,90 @@ + + */ +final class PhpStanNamespaceIn3rdPartyPackageRule implements Rule +{ + + public function __construct(private ApiRuleHelper $apiRuleHelper) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Namespace_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $namespace = null; + if ($node->name !== null) { + $namespace = $node->name->toString(); + } + if ($namespace === null || !$this->apiRuleHelper->isPhpStanName($namespace)) { + return []; + } + + $composerJson = $this->findComposerJsonContents(dirname($scope->getFile())); + if ($composerJson === null) { + return []; + } + + $packageName = $composerJson['name'] ?? null; + if ($packageName !== null && str_starts_with($packageName, 'phpstan/')) { + return []; + } + + return [ + RuleErrorBuilder::message('Declaring PHPStan namespace is not allowed in 3rd party packages.') + ->identifier('phpstanApi.phpstanNamespace') + ->tip("See:\n https://phpstan.org/developing-extensions/backward-compatibility-promise") + ->build(), + ]; + } + + /** + * @return mixed[]|null + */ + private function findComposerJsonContents(string $fromDirectory): ?array + { + if (!is_dir($fromDirectory)) { + return null; + } + + $composerJsonPath = $fromDirectory . '/composer.json'; + if (!is_file($composerJsonPath)) { + $dirName = dirname($fromDirectory); + if ($dirName !== $fromDirectory) { + return $this->findComposerJsonContents($dirName); + } + + return null; + } + + try { + return Json::decode(FileReader::read($composerJsonPath), Json::FORCE_ARRAY); + } catch (JsonException) { + return null; + } catch (CouldNotReadFileException) { + return null; + } + } + +} diff --git a/src/Rules/Api/RuntimeReflectionFunctionRule.php b/src/Rules/Api/RuntimeReflectionFunctionRule.php new file mode 100644 index 00000000..13fbe680 --- /dev/null +++ b/src/Rules/Api/RuntimeReflectionFunctionRule.php @@ -0,0 +1,77 @@ + + */ +final class RuntimeReflectionFunctionRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if (!in_array($functionReflection->getName(), [ + 'is_a', + 'is_subclass_of', + 'class_parents', + 'class_implements', + 'class_uses', + ], true)) { + return []; + } + + if (!$scope->isInClass()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + $hasPhpStanInterface = false; + foreach (array_keys($classReflection->getInterfaces()) as $interfaceName) { + if (!str_starts_with($interfaceName, 'PHPStan\\')) { + continue; + } + + $hasPhpStanInterface = true; + } + + if (!$hasPhpStanInterface) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('Function %s() is a runtime reflection concept that might not work in PHPStan because it uses fully static reflection engine. Use objects retrieved from ReflectionProvider instead.', $functionReflection->getName()), + )->identifier('phpstanApi.runtimeReflection')->build(), + ]; + } + +} diff --git a/src/Rules/Api/RuntimeReflectionInstantiationRule.php b/src/Rules/Api/RuntimeReflectionInstantiationRule.php new file mode 100644 index 00000000..28cf9e22 --- /dev/null +++ b/src/Rules/Api/RuntimeReflectionInstantiationRule.php @@ -0,0 +1,96 @@ + + */ +final class RuntimeReflectionInstantiationRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!in_array($classReflection->getName(), [ + ReflectionMethod::class, + ReflectionClass::class, + ReflectionClassConstant::class, + 'ReflectionEnum', + 'ReflectionEnumBackedCase', + ReflectionZendExtension::class, + ReflectionExtension::class, + ReflectionFunction::class, + ReflectionObject::class, + ReflectionParameter::class, + ReflectionProperty::class, + ReflectionGenerator::class, + 'ReflectionFiber', + ], true)) { + return []; + } + + if (!$scope->isInClass()) { + return []; + } + + $scopeClassReflection = $scope->getClassReflection(); + $hasPhpStanInterface = false; + foreach (array_keys($scopeClassReflection->getInterfaces()) as $interfaceName) { + if (!str_starts_with($interfaceName, 'PHPStan\\')) { + continue; + } + + $hasPhpStanInterface = true; + } + + if (!$hasPhpStanInterface) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('Creating new %s is a runtime reflection concept that might not work in PHPStan because it uses fully static reflection engine. Use objects retrieved from ReflectionProvider instead.', $classReflection->getName()), + )->identifier('phpstanApi.runtimeReflection')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/AllowedArrayKeysTypes.php b/src/Rules/Arrays/AllowedArrayKeysTypes.php new file mode 100644 index 00000000..708a8ff9 --- /dev/null +++ b/src/Rules/Arrays/AllowedArrayKeysTypes.php @@ -0,0 +1,87 @@ +isArray()->yes() || $varType->isIterableAtLeastOnce()->no()) { + return null; + } + + $varIterableKeyType = $varType->getIterableKeyType(); + + if ($varIterableKeyType->isConstantScalarValue()->yes()) { + $narrowedKey = TypeCombinator::union( + $varIterableKeyType, + TypeCombinator::remove($varIterableKeyType->toString(), new ConstantStringType('')), + ); + + if (!$varType->hasOffsetValueType(new ConstantIntegerType(0))->no()) { + $narrowedKey = TypeCombinator::union( + $narrowedKey, + new ConstantBooleanType(false), + ); + } + + if (!$varType->hasOffsetValueType(new ConstantIntegerType(1))->no()) { + $narrowedKey = TypeCombinator::union( + $narrowedKey, + new ConstantBooleanType(true), + ); + } + + if (!$varType->hasOffsetValueType(new ConstantStringType(''))->no()) { + $narrowedKey = TypeCombinator::addNull($narrowedKey); + } + + if (!$varIterableKeyType->isNumericString()->no() || !$varIterableKeyType->isInteger()->no()) { + $narrowedKey = TypeCombinator::union($narrowedKey, new FloatType()); + } + + return $narrowedKey; + } elseif ($varIterableKeyType->isInteger()->yes() && $keyType->isString()->yes()) { + return TypeCombinator::intersect($varIterableKeyType->toString(), $keyType); + } + + return new MixedType( + false, + new UnionType([ + new ArrayType(new MixedType(), new MixedType()), + new ObjectWithoutClassType(), + new ResourceType(), + ]), + ); + } + +} diff --git a/src/Rules/Arrays/ArrayDestructuringRule.php b/src/Rules/Arrays/ArrayDestructuringRule.php new file mode 100644 index 00000000..e3d2446a --- /dev/null +++ b/src/Rules/Arrays/ArrayDestructuringRule.php @@ -0,0 +1,118 @@ + + */ +final class ArrayDestructuringRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private NonexistentOffsetInArrayDimFetchCheck $nonexistentOffsetInArrayDimFetchCheck, + ) + { + } + + public function getNodeType(): string + { + return Assign::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->var instanceof Node\Expr\List_) { + return []; + } + + return $this->getErrors( + $scope, + $node->var, + $node->expr, + ); + } + + /** + * @return list + */ + private function getErrors(Scope $scope, Node\Expr\List_ $var, Expr $expr): array + { + $exprTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $expr, + '', + static fn (Type $varType): bool => $varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->yes(), + ); + $exprType = $exprTypeResult->getType(); + if ($exprType instanceof ErrorType) { + return []; + } + if (!$exprType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($exprType)->yes()) { + return [ + RuleErrorBuilder::message(sprintf('Cannot use array destructuring on %s.', $exprType->describe(VerbosityLevel::typeOnly()))) + ->identifier('offsetAccess.nonArray') + ->build(), + ]; + } + + $errors = []; + $i = 0; + foreach ($var->items as $item) { + if ($item === null) { + $i++; + continue; + } + + $keyExpr = null; + if ($item->key === null) { + $keyType = new ConstantIntegerType($i); + $keyExpr = new Node\Scalar\Int_($i); + } else { + $keyType = $scope->getType($item->key); + $keyExpr = new TypeExpr($keyType); + } + + $itemErrors = $this->nonexistentOffsetInArrayDimFetchCheck->check( + $scope, + $expr, + '', + $keyType, + ); + $errors = array_merge($errors, $itemErrors); + + if (!$item->value instanceof Node\Expr\List_) { + $i++; + continue; + } + + $errors = array_merge($errors, $this->getErrors( + $scope, + $item->value, + new Expr\ArrayDimFetch($expr, $keyExpr), + )); + } + + return $errors; + } + +} diff --git a/src/Rules/Arrays/ArrayUnpackingRule.php b/src/Rules/Arrays/ArrayUnpackingRule.php new file mode 100644 index 00000000..c701afe2 --- /dev/null +++ b/src/Rules/Arrays/ArrayUnpackingRule.php @@ -0,0 +1,66 @@ + + */ +final class ArrayUnpackingRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion, private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return ArrayItem::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->unpack === false || $this->phpVersion->supportsArrayUnpackingWithStringKeys()) { + return []; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + new GetIterableKeyTypeExpr($node->value), + '', + static fn (Type $type): bool => $type->isString()->no(), + ); + + $keyType = $typeResult->getType(); + if ($keyType instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $isString = $keyType->isString(); + if ($isString->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Array unpacking cannot be used on an array with %sstring keys: %s', + $isString->yes() ? '' : 'potential ', + $scope->getType($node->value)->describe(VerbosityLevel::value()), + ))->identifier('arrayUnpacking.stringOffset')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/DeadForeachRule.php b/src/Rules/Arrays/DeadForeachRule.php new file mode 100644 index 00000000..317927ec --- /dev/null +++ b/src/Rules/Arrays/DeadForeachRule.php @@ -0,0 +1,40 @@ + + */ +final class DeadForeachRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Foreach_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $iterableType = $scope->getType($node->expr); + if ($iterableType->isIterable()->no()) { + return []; + } + + if (!$iterableType->isIterableAtLeastOnce()->no()) { + return []; + } + + return [ + RuleErrorBuilder::message('Empty array passed to foreach.') + ->identifier('foreach.emptyArray') + ->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php b/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php new file mode 100644 index 00000000..2caa66d9 --- /dev/null +++ b/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php @@ -0,0 +1,122 @@ + + */ +final class DuplicateKeysInLiteralArraysRule implements Rule +{ + + public function __construct( + private ExprPrinter $exprPrinter, + ) + { + } + + public function getNodeType(): string + { + return LiteralArrayNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $values = []; + $duplicateKeys = []; + $printedValues = []; + $valueLines = []; + + /** + * @var int|false|null $autoGeneratedIndex + * - An int value represent the biggest integer used as array key. + * When no key is provided this value + 1 will be used. + * - Null is used as initializer instead of 0 to avoid issue with negative keys. + * - False means a non-scalar value was encountered and we cannot be sure of the next keys. + */ + $autoGeneratedIndex = null; + foreach ($node->getItemNodes() as $itemNode) { + $item = $itemNode->getArrayItem(); + if ($item === null) { + $autoGeneratedIndex = false; + continue; + } + + $key = $item->key; + if ($key === null) { + if ($autoGeneratedIndex === false) { + continue; + } + + if ($autoGeneratedIndex === null) { + $autoGeneratedIndex = 0; + $keyType = new ConstantIntegerType(0); + } else { + $keyType = new ConstantIntegerType(++$autoGeneratedIndex); + } + } else { + $keyType = $itemNode->getScope()->getType($key); + + $arrayKeyValue = $keyType->toArrayKey(); + if ($arrayKeyValue instanceof ConstantIntegerType) { + $autoGeneratedIndex = $autoGeneratedIndex === null + ? $arrayKeyValue->getValue() + : max($autoGeneratedIndex, $arrayKeyValue->getValue()); + } + } + + if (!$keyType instanceof ConstantScalarType) { + $autoGeneratedIndex = false; + continue; + } + + $value = $keyType->getValue(); + $printedValue = $key !== null + ? $this->exprPrinter->printExpr($key) + : $value; + + $printedValues[$value][] = $printedValue; + + if (!isset($valueLines[$value])) { + $valueLines[$value] = $item->getStartLine(); + } + + $previousCount = count($values); + $values[$value] = $printedValue; + if ($previousCount !== count($values)) { + continue; + } + + $duplicateKeys[$value] = true; + } + + $messages = []; + foreach (array_keys($duplicateKeys) as $value) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Array has %d %s with value %s (%s).', + count($printedValues[$value]), + count($printedValues[$value]) === 1 ? 'duplicate key' : 'duplicate keys', + var_export($value, true), + implode(', ', $printedValues[$value]), + ))->identifier('array.duplicateKey')->line($valueLines[$value])->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Arrays/InvalidKeyInArrayDimFetchRule.php b/src/Rules/Arrays/InvalidKeyInArrayDimFetchRule.php new file mode 100644 index 00000000..62addab0 --- /dev/null +++ b/src/Rules/Arrays/InvalidKeyInArrayDimFetchRule.php @@ -0,0 +1,69 @@ + + */ +final class InvalidKeyInArrayDimFetchRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\ArrayDimFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->dim === null) { + return []; + } + + $dimensionType = $scope->getType($node->dim); + if ($dimensionType instanceof MixedType) { + return []; + } + + $varType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->var, + '', + static fn (Type $varType): bool => $varType->isArray()->no() || AllowedArrayKeysTypes::getType()->isSuperTypeOf($dimensionType)->yes(), + )->getType(); + + if ($varType instanceof ErrorType || $varType->isArray()->no()) { + return []; + } + + $isSuperType = AllowedArrayKeysTypes::getType()->isSuperTypeOf($dimensionType); + if ($isSuperType->yes() || ($isSuperType->maybe() && !$this->reportMaybes)) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('%s array key type %s.', $isSuperType->no() ? 'Invalid' : 'Possibly invalid', $dimensionType->describe(VerbosityLevel::typeOnly())), + )->identifier('offsetAccess.invalidOffset')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/InvalidKeyInArrayItemRule.php b/src/Rules/Arrays/InvalidKeyInArrayItemRule.php new file mode 100644 index 00000000..ba48b613 --- /dev/null +++ b/src/Rules/Arrays/InvalidKeyInArrayItemRule.php @@ -0,0 +1,54 @@ + + */ +final class InvalidKeyInArrayItemRule implements Rule +{ + + public function __construct(private bool $reportMaybes) + { + } + + public function getNodeType(): string + { + return Node\ArrayItem::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->key === null) { + return []; + } + + $dimensionType = $scope->getType($node->key); + $isSuperType = AllowedArrayKeysTypes::getType()->isSuperTypeOf($dimensionType); + if ($isSuperType->no()) { + return [ + RuleErrorBuilder::message( + sprintf('Invalid array key type %s.', $dimensionType->describe(VerbosityLevel::typeOnly())), + )->identifier('array.invalidKey')->build(), + ]; + } elseif ($this->reportMaybes && $isSuperType->maybe() && !$dimensionType instanceof MixedType) { + return [ + RuleErrorBuilder::message( + sprintf('Possibly invalid array key type %s.', $dimensionType->describe(VerbosityLevel::typeOnly())), + )->identifier('array.invalidKey')->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Arrays/IterableInForeachRule.php b/src/Rules/Arrays/IterableInForeachRule.php new file mode 100644 index 00000000..32f6c106 --- /dev/null +++ b/src/Rules/Arrays/IterableInForeachRule.php @@ -0,0 +1,57 @@ + + */ +final class IterableInForeachRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return InForeachNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $originalNode->expr, + 'Iterating over an object of an unknown class %s.', + static fn (Type $type): bool => $type->isIterable()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + if ($type->isIterable()->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Argument of an invalid type %s supplied for foreach, only iterables are supported.', + $type->describe(VerbosityLevel::typeOnly()), + ))->identifier('foreach.nonIterable')->line($originalNode->expr->getStartLine())->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php new file mode 100644 index 00000000..820b1c90 --- /dev/null +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php @@ -0,0 +1,119 @@ + + */ + public function check( + Scope $scope, + Expr $var, + string $unknownClassPattern, + Type $dimType, + ): array + { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $var), + $unknownClassPattern, + static fn (Type $type): bool => $type->hasOffsetValueType($dimType)->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + if ($scope->isInExpressionAssign($var) || $scope->isUndefinedExpressionAllowed($var)) { + return []; + } + + if ($type->hasOffsetValueType($dimType)->no()) { + return [ + RuleErrorBuilder::message(sprintf('Offset %s does not exist on %s.', $dimType->describe(count($dimType->getConstantStrings()) > 0 ? VerbosityLevel::precise() : VerbosityLevel::value()), $type->describe(VerbosityLevel::value()))) + ->identifier('offsetAccess.notFound') + ->build(), + ]; + } + + if ($this->reportMaybes) { + $report = false; + + if ($type instanceof BenevolentUnionType) { + $flattenedTypes = [$type]; + } else { + $flattenedTypes = TypeUtils::flattenTypes($type); + } + foreach ($flattenedTypes as $innerType) { + if ( + $this->reportPossiblyNonexistentGeneralArrayOffset + && $innerType->isArray()->yes() + && !$innerType->isConstantArray()->yes() + && !$innerType->hasOffsetValueType($dimType)->yes() + ) { + $report = true; + break; + } + if ( + $this->reportPossiblyNonexistentConstantArrayOffset + && $innerType->isConstantArray()->yes() + && !$innerType->hasOffsetValueType($dimType)->yes() + ) { + $report = true; + break; + } + if ($dimType instanceof UnionType) { + if ($innerType->hasOffsetValueType($dimType)->no()) { + $report = true; + break; + } + continue; + } + foreach (TypeUtils::flattenTypes($dimType) as $innerDimType) { + if ($innerType->hasOffsetValueType($innerDimType)->no()) { + $report = true; + break 2; + } + } + } + + if ($report) { + return [ + RuleErrorBuilder::message(sprintf('Offset %s might not exist on %s.', $dimType->describe(count($dimType->getConstantStrings()) > 0 ? VerbosityLevel::precise() : VerbosityLevel::value()), $type->describe(VerbosityLevel::value()))) + ->identifier('offsetAccess.notFound') + ->build(), + ]; + } + } + + return []; + } + +} diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php new file mode 100644 index 00000000..58efc5e7 --- /dev/null +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -0,0 +1,108 @@ + + */ +final class NonexistentOffsetInArrayDimFetchRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private NonexistentOffsetInArrayDimFetchCheck $nonexistentOffsetInArrayDimFetchCheck, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\ArrayDimFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->dim !== null) { + $dimType = $scope->getType($node->dim); + $unknownClassPattern = sprintf('Access to offset %s on an unknown class %%s.', SprintfHelper::escapeFormatString($dimType->describe(VerbosityLevel::value()))); + } else { + $dimType = null; + $unknownClassPattern = 'Access to an offset on an unknown class %s.'; + } + + $isOffsetAccessibleTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->var), + $unknownClassPattern, + static fn (Type $type): bool => $type->isOffsetAccessible()->yes(), + ); + $isOffsetAccessibleType = $isOffsetAccessibleTypeResult->getType(); + if ($isOffsetAccessibleType instanceof ErrorType) { + return $isOffsetAccessibleTypeResult->getUnknownClassErrors(); + } + + if ($scope->hasExpressionType($node)->yes()) { + return []; + } + + $isOffsetAccessible = $isOffsetAccessibleType->isOffsetAccessible(); + + if ($scope->isInExpressionAssign($node) && $isOffsetAccessible->yes()) { + return []; + } + + if ($scope->isUndefinedExpressionAllowed($node) && $isOffsetAccessibleType->isOffsetAccessLegal()->yes()) { + return []; + } + + if (!$isOffsetAccessible->yes()) { + if ($isOffsetAccessible->no() || $this->reportMaybes) { + if ($dimType !== null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot access offset %s on %s.', + $dimType->describe(count($dimType->getConstantStrings()) > 0 ? VerbosityLevel::precise() : VerbosityLevel::value()), + $isOffsetAccessibleType->describe(VerbosityLevel::value()), + ))->identifier('offsetAccess.nonOffsetAccessible')->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot access an offset on %s.', + $isOffsetAccessibleType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAccess.nonOffsetAccessible')->build(), + ]; + } + + return []; + } + + if ($dimType === null) { + return []; + } + + return $this->nonexistentOffsetInArrayDimFetchCheck->check( + $scope, + $node->var, + $unknownClassPattern, + $dimType, + ); + } + +} diff --git a/src/Rules/Arrays/OffsetAccessAssignOpRule.php b/src/Rules/Arrays/OffsetAccessAssignOpRule.php new file mode 100644 index 00000000..592e71ae --- /dev/null +++ b/src/Rules/Arrays/OffsetAccessAssignOpRule.php @@ -0,0 +1,98 @@ + + */ +final class OffsetAccessAssignOpRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Node\Expr\AssignOp::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->var instanceof ArrayDimFetch) { + return []; + } + + $arrayDimFetch = $node->var; + + $potentialDimType = null; + if ($arrayDimFetch->dim !== null) { + $potentialDimType = $scope->getType($arrayDimFetch->dim); + } + + $varTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $arrayDimFetch->var, + '', + static function (Type $varType) use ($potentialDimType): bool { + $arrayDimType = $varType->setOffsetValueType($potentialDimType, new MixedType()); + return !($arrayDimType instanceof ErrorType); + }, + ); + $varType = $varTypeResult->getType(); + + if ($arrayDimFetch->dim !== null) { + $dimTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $arrayDimFetch->dim, + '', + static function (Type $dimType) use ($varType): bool { + $arrayDimType = $varType->setOffsetValueType($dimType, new MixedType()); + return !($arrayDimType instanceof ErrorType); + }, + ); + $dimType = $dimTypeResult->getType(); + if ($varType->hasOffsetValueType($dimType)->no()) { + return []; + } + } else { + $dimType = $potentialDimType; + } + + $resultType = $varType->setOffsetValueType($dimType, new MixedType()); + if (!($resultType instanceof ErrorType)) { + return []; + } + + if ($dimType === null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot assign new offset to %s.', + $varType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAssign.dimType')->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot assign offset %s to %s.', + $dimType->describe(VerbosityLevel::value()), + $varType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAssign.dimType')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/OffsetAccessAssignmentRule.php b/src/Rules/Arrays/OffsetAccessAssignmentRule.php new file mode 100644 index 00000000..e25016ac --- /dev/null +++ b/src/Rules/Arrays/OffsetAccessAssignmentRule.php @@ -0,0 +1,99 @@ + + */ +final class OffsetAccessAssignmentRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Node\Expr\ArrayDimFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInExpressionAssign($node)) { + return []; + } + + $potentialDimType = null; + if ($node->dim !== null) { + $potentialDimType = $scope->getType($node->dim); + } + + $varTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->var), + '', + static function (Type $varType) use ($potentialDimType): bool { + $arrayDimType = $varType->setOffsetValueType($potentialDimType, new MixedType()); + return !($arrayDimType instanceof ErrorType); + }, + ); + $varType = $varTypeResult->getType(); + if ($varType instanceof ErrorType) { + return []; + } + if (!$varType->isOffsetAccessible()->yes()) { + return []; + } + + if ($node->dim !== null) { + $dimTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->dim, + '', + static function (Type $dimType) use ($varType): bool { + $arrayDimType = $varType->setOffsetValueType($dimType, new MixedType()); + return !($arrayDimType instanceof ErrorType); + }, + ); + $dimType = $dimTypeResult->getType(); + } else { + $dimType = $potentialDimType; + } + + $resultType = $varType->setOffsetValueType($dimType, new MixedType()); + if (!($resultType instanceof ErrorType)) { + return []; + } + + if ($dimType === null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot assign new offset to %s.', + $varType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAssign.dimType')->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot assign offset %s to %s.', + $dimType->describe(VerbosityLevel::value()), + $varType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAssign.dimType')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/OffsetAccessValueAssignmentRule.php b/src/Rules/Arrays/OffsetAccessValueAssignmentRule.php new file mode 100644 index 00000000..9a919a9d --- /dev/null +++ b/src/Rules/Arrays/OffsetAccessValueAssignmentRule.php @@ -0,0 +1,94 @@ + + */ +final class OffsetAccessValueAssignmentRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Assign + && !$node instanceof AssignOp + && !$node instanceof Expr\AssignRef + ) { + return []; + } + + if (!$node->var instanceof Expr\ArrayDimFetch) { + return []; + } + + $arrayDimFetch = $node->var; + $varType = $scope->getType($arrayDimFetch->var); + if ($varType->isObject()->no()) { + return []; + } + + if ($node instanceof Assign || $node instanceof Expr\AssignRef) { + $assignedValueType = $scope->getType($node->expr); + } else { + $assignedValueType = $scope->getType($node); + } + + $arrayTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $arrayDimFetch->var, + '', + static function (Type $varType) use ($assignedValueType): bool { + $result = $varType->setOffsetValueType(new MixedType(), $assignedValueType); + return !($result instanceof ErrorType); + }, + ); + $arrayType = $arrayTypeResult->getType(); + if ($arrayType instanceof ErrorType) { + return []; + } + $isOffsetAccessible = $arrayType->isOffsetAccessible(); + if (!$isOffsetAccessible->yes()) { + return []; + } + $resultType = $arrayType->setOffsetValueType(new MixedType(), $assignedValueType); + if (!$resultType instanceof ErrorType) { + return []; + } + + $originalArrayType = $scope->getType($arrayDimFetch->var); + + return [ + RuleErrorBuilder::message(sprintf( + '%s does not accept %s.', + $originalArrayType->describe(VerbosityLevel::value()), + $assignedValueType->describe(VerbosityLevel::typeOnly()), + ))->identifier('offsetAssign.valueType')->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/OffsetAccessWithoutDimForReadingRule.php b/src/Rules/Arrays/OffsetAccessWithoutDimForReadingRule.php new file mode 100644 index 00000000..43400048 --- /dev/null +++ b/src/Rules/Arrays/OffsetAccessWithoutDimForReadingRule.php @@ -0,0 +1,40 @@ + + */ +final class OffsetAccessWithoutDimForReadingRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\ArrayDimFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($scope->isInExpressionAssign($node)) { + return []; + } + + if ($node->dim !== null) { + return []; + } + + return [ + RuleErrorBuilder::message('Cannot use [] for reading.') + ->identifier('offsetAccess.noDim') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Arrays/UnpackIterableInArrayRule.php b/src/Rules/Arrays/UnpackIterableInArrayRule.php new file mode 100644 index 00000000..e2e77516 --- /dev/null +++ b/src/Rules/Arrays/UnpackIterableInArrayRule.php @@ -0,0 +1,70 @@ + + */ +final class UnpackIterableInArrayRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return LiteralArrayNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->getItemNodes() as $itemNode) { + $item = $itemNode->getArrayItem(); + if ($item === null) { + continue; + } + if (!$item->unpack) { + continue; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $item->value, + '', + static fn (Type $type): bool => $type->isIterable()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + continue; + } + + if ($type->isIterable()->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Only iterables can be unpacked, %s given.', + $type->describe(VerbosityLevel::typeOnly()), + ))->identifier('arrayUnpacking.nonIterable')->line($item->getStartLine())->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/AttributesCheck.php b/src/Rules/AttributesCheck.php new file mode 100644 index 00000000..1edda061 --- /dev/null +++ b/src/Rules/AttributesCheck.php @@ -0,0 +1,168 @@ + $requiredTarget + * @return list + */ + public function check( + Scope $scope, + array $attrGroups, + int $requiredTarget, + string $targetName, + ): array + { + $errors = []; + $alreadyPresent = []; + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attribute) { + $name = $attribute->name->toString(); + if (!$this->reflectionProvider->hasClass($name)) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class %s does not exist.', $name)) + ->line($attribute->getStartLine()) + ->identifier('attribute.notFound') + ->build(); + continue; + } + + $attributeClass = $this->reflectionProvider->getClass($name); + if (!$attributeClass->isAttributeClass()) { + $errors[] = RuleErrorBuilder::message(sprintf('%s %s is not an Attribute class.', $attributeClass->getClassTypeDescription(), $attributeClass->getDisplayName())) + ->identifier('attribute.notAttribute') + ->line($attribute->getStartLine()) + ->build(); + continue; + } + + if ($attributeClass->isAbstract()) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class %s is abstract.', $name)) + ->identifier('attribute.abstract') + ->line($attribute->getStartLine()) + ->build(); + } + + foreach ($this->classCheck->checkClassNames([new ClassNameNodePair($name, $attribute)]) as $caseSensitivityError) { + $errors[] = $caseSensitivityError; + } + + $flags = $attributeClass->getAttributeClassFlags(); + if (($flags & $requiredTarget) === 0) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class %s does not have the %s target.', $name, $targetName)) + ->identifier('attribute.target') + ->line($attribute->getStartLine()) + ->build(); + } + + if (($flags & Attribute::IS_REPEATABLE) === 0) { + $loweredName = strtolower($name); + if (array_key_exists($loweredName, $alreadyPresent)) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class %s is not repeatable but is already present above the %s.', $name, $targetName)) + ->identifier('attribute.nonRepeatable') + ->line($attribute->getStartLine()) + ->build(); + } + + $alreadyPresent[$loweredName] = true; + } + + if ($this->deprecationRulesInstalled && $attributeClass->isDeprecated()) { + if ($attributeClass->getDeprecatedDescription() !== null) { + $deprecatedError = sprintf('Attribute class %s is deprecated: %s', $name, $attributeClass->getDeprecatedDescription()); + } else { + $deprecatedError = sprintf('Attribute class %s is deprecated.', $name); + } + $errors[] = RuleErrorBuilder::message($deprecatedError) + ->identifier('attribute.deprecated') + ->line($attribute->getStartLine()) + ->build(); + } + + if (!$attributeClass->hasConstructor()) { + if (count($attribute->args) > 0) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class %s does not have a constructor and must be instantiated without any parameters.', $name)) + ->identifier('attribute.noConstructor') + ->line($attribute->getStartLine()) + ->build(); + } + continue; + } + + $attributeConstructor = $attributeClass->getConstructor(); + if (!$attributeConstructor->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf('Constructor of attribute class %s is not public.', $name)) + ->identifier('attribute.constructorNotPublic') + ->line($attribute->getStartLine()) + ->build(); + } + + $attributeClassName = SprintfHelper::escapeFormatString($attributeClass->getDisplayName()); + + $nodeAttributes = $attribute->getAttributes(); + $nodeAttributes['isAttribute'] = true; + + $parameterErrors = $this->functionCallParametersCheck->check( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $attribute->args, + $attributeConstructor->getVariants(), + $attributeConstructor->getNamedArgumentsVariants(), + ), + $scope, + $attributeConstructor->getDeclaringClass()->isBuiltin(), + new New_($attribute->name, $attribute->args, $nodeAttributes), + 'attribute', + $attributeConstructor->acceptsNamedArguments(), + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameter, %d required.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameters, %d required.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameter, at least %d required.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameters, at least %d required.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameter, %d-%d required.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %d parameters, %d-%d required.', + '%s of attribute class ' . $attributeClassName . ' constructor expects %s, %s given.', + '', // constructor does not have a return type + '%s of attribute class ' . $attributeClassName . ' constructor is passed by reference, so it expects variables only', + 'Unable to resolve the template type %s in instantiation of attribute class ' . $attributeClassName, + 'Missing parameter $%s in call to ' . $attributeClassName . ' constructor.', + 'Unknown parameter $%s in call to ' . $attributeClassName . ' constructor.', + 'Return type of call to ' . $attributeClassName . ' constructor contains unresolvable type.', + '%s of attribute class ' . $attributeClassName . ' constructor contains unresolvable type.', + 'Attribute class ' . $attributeClassName . ' constructor invoked with %s, but it\'s not allowed because of @no-named-arguments.', + ); + + foreach ($parameterErrors as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + +} diff --git a/src/Rules/Cast/EchoRule.php b/src/Rules/Cast/EchoRule.php new file mode 100644 index 00000000..9288a85a --- /dev/null +++ b/src/Rules/Cast/EchoRule.php @@ -0,0 +1,58 @@ + + */ +final class EchoRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Echo_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = []; + + foreach ($node->exprs as $key => $expr) { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $expr, + '', + static fn (Type $type): bool => !$type->toString() instanceof ErrorType, + ); + + if ($typeResult->getType() instanceof ErrorType + || !$typeResult->getType()->toString() instanceof ErrorType + ) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Parameter #%d (%s) of echo cannot be converted to string.', + $key + 1, + $typeResult->getType()->describe(VerbosityLevel::value()), + ))->identifier('echo.nonString')->line($expr->getStartLine())->build(); + } + return $messages; + } + +} diff --git a/src/Rules/Cast/InvalidCastRule.php b/src/Rules/Cast/InvalidCastRule.php new file mode 100644 index 00000000..ac43282e --- /dev/null +++ b/src/Rules/Cast/InvalidCastRule.php @@ -0,0 +1,103 @@ + + */ +final class InvalidCastRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Cast::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $castTypeCallback = static function (Type $type) use ($node): ?array { + if ($node instanceof Node\Expr\Cast\Int_) { + return [$type->toInteger(), 'int']; + } elseif ($node instanceof Node\Expr\Cast\Bool_) { + return [$type->toBoolean(), 'bool']; + } elseif ($node instanceof Node\Expr\Cast\Double) { + return [$type->toFloat(), 'double']; + } elseif ($node instanceof Node\Expr\Cast\String_) { + return [$type->toString(), 'string']; + } + + return null; + }; + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + '', + static function (Type $type) use ($castTypeCallback): bool { + $castResult = $castTypeCallback($type); + if ($castResult === null) { + return true; + } + + [$castType] = $castResult; + + return !$castType instanceof ErrorType; + }, + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return []; + } + + $castResult = $castTypeCallback($type); + if ($castResult === null) { + return []; + } + + [$castType, $castIdentifier] = $castResult; + + if ($castType instanceof ErrorType) { + $classReflection = $this->reflectionProvider->getClass(get_class($node)); + $shortName = $classReflection->getNativeReflection()->getShortName(); + $shortName = strtolower($shortName); + if ($shortName === 'double') { + $shortName = 'float'; + } else { + $shortName = substr($shortName, 0, -1); + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot cast %s to %s.', + $scope->getType($node->expr)->describe(VerbosityLevel::value()), + $shortName, + ))->identifier(sprintf('cast.%s', $castIdentifier))->line($node->getStartLine())->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Cast/InvalidPartOfEncapsedStringRule.php b/src/Rules/Cast/InvalidPartOfEncapsedStringRule.php new file mode 100644 index 00000000..054c22b3 --- /dev/null +++ b/src/Rules/Cast/InvalidPartOfEncapsedStringRule.php @@ -0,0 +1,68 @@ + + */ +final class InvalidPartOfEncapsedStringRule implements Rule +{ + + public function __construct( + private ExprPrinter $exprPrinter, + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Scalar\InterpolatedString::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = []; + foreach ($node->parts as $part) { + if ($part instanceof Node\InterpolatedStringPart) { + continue; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $part, + '', + static fn (Type $type): bool => !$type->toString() instanceof ErrorType, + ); + $partType = $typeResult->getType(); + if ($partType instanceof ErrorType) { + continue; + } + + $stringPartType = $partType->toString(); + if (!$stringPartType instanceof ErrorType) { + continue; + } + $messages[] = RuleErrorBuilder::message(sprintf( + 'Part %s (%s) of encapsed string cannot be cast to string.', + $this->exprPrinter->printExpr($part), + $partType->describe(VerbosityLevel::value()), + ))->identifier('encapsedStringPart.nonString')->line($part->getStartLine())->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Cast/PrintRule.php b/src/Rules/Cast/PrintRule.php new file mode 100644 index 00000000..f50f39e0 --- /dev/null +++ b/src/Rules/Cast/PrintRule.php @@ -0,0 +1,52 @@ + + */ +final class PrintRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Node\Expr\Print_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + '', + static fn (Type $type): bool => !$type->toString() instanceof ErrorType, + ); + + if (!$typeResult->getType() instanceof ErrorType + && $typeResult->getType()->toString() instanceof ErrorType + ) { + return [RuleErrorBuilder::message(sprintf( + 'Parameter %s of print cannot be converted to string.', + $typeResult->getType()->describe(VerbosityLevel::value()), + ))->identifier('print.nonString')->line($node->expr->getStartLine())->build()]; + } + + return []; + } + +} diff --git a/src/Rules/Cast/UnsetCastRule.php b/src/Rules/Cast/UnsetCastRule.php new file mode 100644 index 00000000..4b674ae7 --- /dev/null +++ b/src/Rules/Cast/UnsetCastRule.php @@ -0,0 +1,41 @@ + + */ +final class UnsetCastRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\Expr\Cast\Unset_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($this->phpVersion->supportsUnsetCast()) { + return []; + } + + return [ + RuleErrorBuilder::message('The (unset) cast is no longer supported in PHP 8.0 and later.') + ->identifier('cast.unset') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/ClassCaseSensitivityCheck.php b/src/Rules/ClassCaseSensitivityCheck.php new file mode 100644 index 00000000..b79b4e2e --- /dev/null +++ b/src/Rules/ClassCaseSensitivityCheck.php @@ -0,0 +1,56 @@ + + */ + public function checkClassNames(array $pairs): array + { + $errors = []; + foreach ($pairs as $pair) { + $className = $pair->getClassName(); + if (!$this->reflectionProvider->hasClass($className)) { + continue; + } + $classReflection = $this->reflectionProvider->getClass($className); + if (!$this->checkInternalClassCaseSensitivity && $classReflection->isBuiltin()) { + continue; // skip built-in classes + } + $realClassName = $classReflection->getName(); + if (strtolower($realClassName) !== strtolower($className)) { + continue; // skip class alias + } + if ($realClassName === $className) { + continue; + } + + $typeName = $classReflection->getClassTypeDescription(); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s referenced with incorrect case: %s.', + $typeName, + $realClassName, + $className, + )) + ->identifier(sprintf('%s.nameCase', strtolower($typeName))) + ->line($pair->getNode()->getStartLine()) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/ClassForbiddenNameCheck.php b/src/Rules/ClassForbiddenNameCheck.php new file mode 100644 index 00000000..24ac149f --- /dev/null +++ b/src/Rules/ClassForbiddenNameCheck.php @@ -0,0 +1,94 @@ + '_PHPStan_', + 'Rector' => 'RectorPrefix', + 'PHP-Scoper' => '_PhpScoper', + 'PHPUnit' => 'PHPUnitPHAR', + 'Box' => '_HumbugBox', + ]; + + public function __construct(private Container $container) + { + } + + /** + * @param ClassNameNodePair[] $pairs + * @return list + */ + public function checkClassNames(array $pairs): array + { + $extensions = $this->container->getServicesByTag(ForbiddenClassNameExtension::EXTENSION_TAG); + + $classPrefixes = array_merge( + self::INTERNAL_CLASS_PREFIXES, + ...array_map( + static fn (ForbiddenClassNameExtension $extension): array => $extension->getClassPrefixes(), + $extensions, + ), + ); + + $errors = []; + foreach ($pairs as $pair) { + $className = $pair->getClassName(); + + $projectName = null; + $withoutPrefixClassName = null; + foreach ($classPrefixes as $project => $prefix) { + if (!str_starts_with($className, $prefix)) { + continue; + } + + $projectName = $project; + $withoutPrefixClassName = substr($className, strlen($prefix)); + + if (strpos($withoutPrefixClassName, '\\') === false) { + continue; + } + + $withoutPrefixClassName = substr($withoutPrefixClassName, strpos($withoutPrefixClassName, '\\')); + } + + if ($projectName === null) { + continue; + } + + $error = RuleErrorBuilder::message(sprintf( + 'Referencing prefixed %s class: %s.', + $projectName, + $className, + )) + ->line($pair->getNode()->getStartLine()) + ->identifier('class.prefixed') + ->nonIgnorable(); + + if ($withoutPrefixClassName !== null) { + $error->tip(sprintf( + 'This is most likely unintentional. Did you mean to type %s?', + $withoutPrefixClassName, + )); + } + + $errors[] = $error->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/ClassNameCheck.php b/src/Rules/ClassNameCheck.php new file mode 100644 index 00000000..d7cac511 --- /dev/null +++ b/src/Rules/ClassNameCheck.php @@ -0,0 +1,36 @@ + + */ + public function checkClassNames(array $pairs, bool $checkClassCaseSensitivity = true): array + { + $errors = []; + + if ($checkClassCaseSensitivity) { + foreach ($this->classCaseSensitivityCheck->checkClassNames($pairs) as $error) { + $errors[] = $error; + } + } + foreach ($this->classForbiddenNameCheck->checkClassNames($pairs) as $error) { + $errors[] = $error; + } + + return $errors; + } + +} diff --git a/src/Rules/ClassNameNodePair.php b/src/Rules/ClassNameNodePair.php new file mode 100644 index 00000000..a1fd1dcd --- /dev/null +++ b/src/Rules/ClassNameNodePair.php @@ -0,0 +1,25 @@ +className; + } + + public function getNode(): Node + { + return $this->node; + } + +} diff --git a/src/Rules/Classes/AccessPrivateConstantThroughStaticRule.php b/src/Rules/Classes/AccessPrivateConstantThroughStaticRule.php new file mode 100644 index 00000000..547768bf --- /dev/null +++ b/src/Rules/Classes/AccessPrivateConstantThroughStaticRule.php @@ -0,0 +1,62 @@ + + */ +final class AccessPrivateConstantThroughStaticRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\ClassConstFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + if (!$node->class instanceof Name) { + return []; + } + + $constantName = $node->name->name; + $className = $node->class; + if ($className->toLowerString() !== 'static') { + return []; + } + + $classType = $scope->resolveTypeByName($className); + if (!$classType->hasConstant($constantName)->yes()) { + return []; + } + + $constant = $classType->getConstant($constantName); + if (!$constant->isPrivate()) { + return []; + } + + if ($scope->isInClass() && $scope->getClassReflection()->isFinal()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unsafe access to private constant %s::%s through static::.', + $constant->getDeclaringClass()->getDisplayName(), + $constantName, + ))->identifier('staticClassAccess.privateConstant')->build(), + ]; + } + +} diff --git a/src/Rules/Classes/AllowedSubTypesRule.php b/src/Rules/Classes/AllowedSubTypesRule.php new file mode 100644 index 00000000..838a9ccf --- /dev/null +++ b/src/Rules/Classes/AllowedSubTypesRule.php @@ -0,0 +1,69 @@ + + */ +final class AllowedSubTypesRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * @param InClassNode $node + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $className = $classReflection->getName(); + + $parents = array_values($classReflection->getImmediateInterfaces()); + $parentClass = $classReflection->getParentClass(); + if ($parentClass !== null) { + $parents[] = $parentClass; + } + + $messages = []; + + foreach ($parents as $parentReflection) { + $allowedSubTypes = $parentReflection->getAllowedSubTypes(); + if ($allowedSubTypes === null) { + continue; + } + + foreach ($allowedSubTypes as $allowedSubType) { + if (!$allowedSubType->isObject()->yes()) { + continue; + } + + if ($allowedSubType->getObjectClassNames() === [$className]) { + continue 2; + } + } + + $identifierType = strtolower($classReflection->getClassTypeDescription()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Type %s is not allowed to be a subtype of %s.', + $className, + $parentReflection->getName(), + ))->identifier(sprintf('%s.disallowedSubtype', $identifierType))->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ClassAttributesRule.php b/src/Rules/Classes/ClassAttributesRule.php new file mode 100644 index 00000000..d9a3d257 --- /dev/null +++ b/src/Rules/Classes/ClassAttributesRule.php @@ -0,0 +1,70 @@ + + */ +final class ClassAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classLikeNode = $node->getOriginalNode(); + + $errors = $this->attributesCheck->check( + $scope, + $classLikeNode->attrGroups, + Attribute::TARGET_CLASS, + 'class', + ); + + $classReflection = $node->getClassReflection(); + if ( + $classReflection->isReadOnly() + || $classReflection->isEnum() + || $classReflection->isInterface() + ) { + $typeName = 'readonly class'; + $identifier = 'class.allowDynamicPropertiesReadonly'; + if ($classReflection->isEnum()) { + $typeName = 'enum'; + $identifier = 'enum.allowDynamicProperties'; + } + if ($classReflection->isInterface()) { + $typeName = 'interface'; + $identifier = 'interface.allowDynamicProperties'; + } + + if (count($classReflection->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) { + $errors[] = RuleErrorBuilder::message(sprintf('Attribute class AllowDynamicProperties cannot be used with %s.', $typeName)) + ->identifier($identifier) + ->nonIgnorable() + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/ClassConstantAttributesRule.php b/src/Rules/Classes/ClassConstantAttributesRule.php new file mode 100644 index 00000000..063b4075 --- /dev/null +++ b/src/Rules/Classes/ClassConstantAttributesRule.php @@ -0,0 +1,37 @@ + + */ +final class ClassConstantAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->attrGroups, + Attribute::TARGET_CLASS_CONSTANT, + 'class constant', + ); + } + +} diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php new file mode 100644 index 00000000..b6c0fbb3 --- /dev/null +++ b/src/Rules/Classes/ClassConstantRule.php @@ -0,0 +1,208 @@ + + */ +final class ClassConstantRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuleLevelHelper $ruleLevelHelper, + private ClassNameCheck $classCheck, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return ClassConstFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + $constantName = $node->name->name; + + $class = $node->class; + $messages = []; + if ($class instanceof Node\Name) { + $className = (string) $class; + $lowercasedClassName = strtolower($className); + if (in_array($lowercasedClassName, ['self', 'static'], true)) { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className)) + ->identifier(sprintf('outOfClass.%s', $lowercasedClassName)) + ->build(), + ]; + } + + $classType = $scope->resolveTypeByName($class); + } elseif ($lowercasedClassName === 'parent') { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className)) + ->identifier(sprintf('outOfClass.%s', $lowercasedClassName)) + ->build(), + ]; + } + $currentClassReflection = $scope->getClassReflection(); + if ($currentClassReflection->getParentClass() === null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Access to parent::%s but %s does not extend any class.', + $constantName, + $currentClassReflection->getDisplayName(), + ))->identifier('class.noParent')->build(), + ]; + } + $classType = $scope->resolveTypeByName($class); + } else { + if (!$this->reflectionProvider->hasClass($className)) { + if ($scope->isInClassExists($className)) { + return []; + } + + if (strtolower($constantName) === 'class') { + return [ + RuleErrorBuilder::message(sprintf('Class %s not found.', $className)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(), + ]; + } + + return [ + RuleErrorBuilder::message( + sprintf('Access to constant %s on an unknown class %s.', $constantName, $className), + ) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(), + ]; + } + + $messages = $this->classCheck->checkClassNames([new ClassNameNodePair($className, $class)]); + + $classType = $scope->resolveTypeByName($class); + } + + if (strtolower($constantName) === 'class') { + return $messages; + } + } else { + $classTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class), + sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)), + static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(), + ); + $classType = $classTypeResult->getType(); + if ($classType instanceof ErrorType) { + return $classTypeResult->getUnknownClassErrors(); + } + + if (strtolower($constantName) === 'class') { + if (!$this->phpVersion->supportsClassConstantOnExpression()) { + return [ + RuleErrorBuilder::message('Accessing ::class constant on an expression is supported only on PHP 8.0 and later.') + ->identifier('classConstant.notSupported') + ->nonIgnorable() + ->build(), + ]; + } + + if (!$class instanceof Node\Scalar\String_ && $classType->isString()->yes()) { + return [ + RuleErrorBuilder::message('Accessing ::class constant on a dynamic string is not supported in PHP.') + ->identifier('classConstant.dynamicString') + ->nonIgnorable() + ->build(), + ]; + } + } + } + + if ($classType->isString()->yes()) { + return $messages; + } + + $typeForDescribe = $classType; + if ($classType instanceof ThisType) { + $typeForDescribe = $classType->getStaticObjectType(); + } + $classType = TypeCombinator::remove($classType, new StringType()); + + if (!$classType->canAccessConstants()->yes()) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Cannot access constant %s on %s.', + $constantName, + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + ))->identifier('classConstant.nonObject')->build(), + ]); + } + + if (strtolower($constantName) === 'class' || $scope->hasExpressionType($node)->yes()) { + return $messages; + } + + if (!$classType->hasConstant($constantName)->yes()) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Access to undefined constant %s::%s.', + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + $constantName, + ))->identifier('classConstant.notFound')->build(), + ]); + } + + $constantReflection = $classType->getConstant($constantName); + if (!$scope->canAccessConstant($constantReflection)) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Access to %s constant %s of class %s.', + $constantReflection->isPrivate() ? 'private' : 'protected', + $constantName, + $constantReflection->getDeclaringClass()->getDisplayName(), + ))->identifier(sprintf( + 'classConstant.%s', + $constantReflection->isPrivate() ? 'private' : 'protected', + ))->build(), + ]); + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/DuplicateClassDeclarationRule.php b/src/Rules/Classes/DuplicateClassDeclarationRule.php new file mode 100644 index 00000000..e54112fd --- /dev/null +++ b/src/Rules/Classes/DuplicateClassDeclarationRule.php @@ -0,0 +1,67 @@ + + */ +final class DuplicateClassDeclarationRule implements Rule +{ + + public function __construct(private Reflector $reflector, private RelativePathHelper $relativePathHelper) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $thisClass = $node->getClassReflection(); + $className = $thisClass->getName(); + $allClasses = $this->reflector->reflectAllClasses(); + $filteredClasses = []; + foreach ($allClasses as $reflectionClass) { + if ($reflectionClass->getName() !== $className) { + continue; + } + + $filteredClasses[] = $reflectionClass; + } + + if (count($filteredClasses) < 2) { + return []; + } + + $filteredClasses = array_filter($filteredClasses, static fn (ReflectionClass $class) => $class->getStartLine() !== $thisClass->getNativeReflection()->getStartLine()); + + $identifierType = strtolower($thisClass->getClassTypeDescription()); + + return [ + RuleErrorBuilder::message(sprintf( + "Class %s declared multiple times:\n%s", + $thisClass->getDisplayName(), + implode("\n", array_map(fn (ReflectionClass $class) => sprintf('- %s:%d', $this->relativePathHelper->getRelativePath($class->getFileName() ?? 'unknown'), $class->getStartLine()), $filteredClasses)), + ))->identifier(sprintf('%s.duplicate', $identifierType))->build(), + ]; + } + +} diff --git a/src/Rules/Classes/DuplicateDeclarationRule.php b/src/Rules/Classes/DuplicateDeclarationRule.php new file mode 100644 index 00000000..baeccead --- /dev/null +++ b/src/Rules/Classes/DuplicateDeclarationRule.php @@ -0,0 +1,134 @@ + + */ +final class DuplicateDeclarationRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + $identifierType = strtolower($classReflection->getClassTypeDescription()); + + $errors = []; + + $declaredClassConstantsOrEnumCases = []; + foreach ($node->getOriginalNode()->stmts as $stmtNode) { + if ($stmtNode instanceof EnumCase) { + if (array_key_exists($stmtNode->name->name, $declaredClassConstantsOrEnumCases)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot redeclare enum case %s::%s.', + $classReflection->getDisplayName(), + $stmtNode->name->name, + ))->identifier(sprintf('%s.duplicateEnumCase', $identifierType)) + ->line($stmtNode->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $declaredClassConstantsOrEnumCases[$stmtNode->name->name] = true; + } + } elseif ($stmtNode instanceof ClassConst) { + foreach ($stmtNode->consts as $classConstNode) { + if (array_key_exists($classConstNode->name->name, $declaredClassConstantsOrEnumCases)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot redeclare constant %s::%s.', + $classReflection->getDisplayName(), + $classConstNode->name->name, + ))->identifier(sprintf('%s.duplicateConstant', $identifierType)) + ->line($classConstNode->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $declaredClassConstantsOrEnumCases[$classConstNode->name->name] = true; + } + } + } + } + + $declaredProperties = []; + foreach ($node->getOriginalNode()->getProperties() as $propertyDecl) { + foreach ($propertyDecl->props as $property) { + if (array_key_exists($property->name->name, $declaredProperties)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot redeclare property %s::$%s.', + $classReflection->getDisplayName(), + $property->name->name, + ))->identifier(sprintf('%s.duplicateProperty', $identifierType)) + ->line($property->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $declaredProperties[$property->name->name] = true; + } + } + } + + $declaredFunctions = []; + foreach ($node->getOriginalNode()->getMethods() as $method) { + if ($method->name->toLowerString() === '__construct') { + foreach ($method->params as $param) { + if ($param->flags === 0) { + continue; + } + + if (!$param->var instanceof Node\Expr\Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + $propertyName = $param->var->name; + + if (array_key_exists($propertyName, $declaredProperties)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot redeclare property %s::$%s.', + $classReflection->getDisplayName(), + $propertyName, + ))->identifier(sprintf('%s.duplicateProperty', $identifierType)) + ->line($param->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $declaredProperties[$propertyName] = true; + } + } + } + if (array_key_exists(strtolower($method->name->name), $declaredFunctions)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot redeclare method %s::%s().', + $classReflection->getDisplayName(), + $method->name->name, + ))->identifier(sprintf('%s.duplicateMethod', $identifierType)) + ->line($method->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $declaredFunctions[strtolower($method->name->name)] = true; + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/EnumSanityRule.php b/src/Rules/Classes/EnumSanityRule.php new file mode 100644 index 00000000..4a606fb7 --- /dev/null +++ b/src/Rules/Classes/EnumSanityRule.php @@ -0,0 +1,227 @@ + + */ +final class EnumSanityRule implements Rule +{ + + private const ALLOWED_MAGIC_METHODS = [ + '__call' => true, + '__callstatic' => true, + '__invoke' => true, + ]; + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$classReflection->isEnum()) { + return []; + } + + /** @var Node\Stmt\Enum_ $enumNode */ + $enumNode = $node->getOriginalNode(); + + $errors = []; + + foreach ($enumNode->getMethods() as $methodNode) { + $lowercasedMethodName = $methodNode->name->toLowerString(); + if ($methodNode->isMagic()) { + if ($lowercasedMethodName === '__construct') { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s contains constructor.', + $classReflection->getDisplayName(), + )) + ->identifier('enum.constructor') + ->line($methodNode->getStartLine()) + ->nonIgnorable() + ->build(); + } elseif ($lowercasedMethodName === '__destruct') { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s contains destructor.', + $classReflection->getDisplayName(), + )) + ->identifier('enum.destructor') + ->line($methodNode->getStartLine()) + ->nonIgnorable() + ->build(); + } elseif (!array_key_exists($lowercasedMethodName, self::ALLOWED_MAGIC_METHODS)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s contains magic method %s().', + $classReflection->getDisplayName(), + $methodNode->name->name, + )) + ->identifier('enum.magicMethod') + ->line($methodNode->getStartLine()) + ->nonIgnorable() + ->build(); + } + } + + if ($lowercasedMethodName === 'cases') { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s cannot redeclare native method %s().', + $classReflection->getDisplayName(), + $methodNode->name->name, + )) + ->identifier('enum.methodRedeclaration') + ->line($methodNode->getStartLine()) + ->nonIgnorable() + ->build(); + } + + if ($enumNode->scalarType === null) { + continue; + } + + if (!in_array($lowercasedMethodName, ['from', 'tryfrom'], true)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s cannot redeclare native method %s().', + $classReflection->getDisplayName(), + $methodNode->name->name, + )) + ->identifier('enum.methodRedeclaration') + ->line($methodNode->getStartLine()) + ->nonIgnorable() + ->build(); + } + + if ( + $enumNode->scalarType !== null + && !in_array($enumNode->scalarType->name, ['int', 'string'], true) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Backed enum %s can have only "int" or "string" type.', + $classReflection->getDisplayName(), + )) + ->identifier('enum.backingType') + ->line($enumNode->scalarType->getStartLine()) + ->nonIgnorable() + ->build(); + } + + if ($classReflection->implementsInterface(Serializable::class)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s cannot implement the Serializable interface.', + $classReflection->getDisplayName(), + )) + ->identifier('enum.serializable') + ->line($enumNode->getStartLine()) + ->nonIgnorable() + ->build(); + } + + $enumCases = []; + foreach ($enumNode->stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\EnumCase) { + continue; + } + $caseName = $stmt->name->name; + + if ($stmt->expr instanceof Node\Scalar\Int_ || $stmt->expr instanceof Node\Scalar\String_) { + if ($enumNode->scalarType === null) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s is not backed, but case %s has value %s.', + $classReflection->getDisplayName(), + $caseName, + $stmt->expr->value, + )) + ->identifier('enum.caseWithValue') + ->line($stmt->getStartLine()) + ->nonIgnorable() + ->build(); + } else { + $caseValue = $stmt->expr->value; + + if (!isset($enumCases[$caseValue])) { + $enumCases[$caseValue] = []; + } + + $enumCases[$caseValue][] = $caseName; + } + } + + if ($enumNode->scalarType === null) { + continue; + } + + if ($stmt->expr === null) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum case %s::%s does not have a value but the enum is backed with the "%s" type.', + $classReflection->getDisplayName(), + $caseName, + $enumNode->scalarType->name, + )) + ->identifier('enum.missingCase') + ->line($stmt->getStartLine()) + ->nonIgnorable() + ->build(); + continue; + } + + $exprType = $scope->getType($stmt->expr); + $scalarType = $enumNode->scalarType->toLowerString() === 'int' ? new IntegerType() : new StringType(); + if ($scalarType->isSuperTypeOf($exprType)->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum case %s::%s value %s does not match the "%s" type.', + $classReflection->getDisplayName(), + $caseName, + $exprType->describe(VerbosityLevel::value()), + $scalarType->describe(VerbosityLevel::typeOnly()), + )) + ->identifier('enum.caseType') + ->line($stmt->getStartLine()) + ->nonIgnorable() + ->build(); + } + + foreach ($enumCases as $caseValue => $caseNames) { + if (count($caseNames) <= 1) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Enum %s has duplicate value %s for cases %s.', + $classReflection->getDisplayName(), + $caseValue, + implode(', ', $caseNames), + )) + ->identifier('enum.duplicateValue') + ->line($enumNode->getStartLine()) + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/ExistingClassInClassExtendsRule.php b/src/Rules/Classes/ExistingClassInClassExtendsRule.php new file mode 100644 index 00000000..c2aa54f8 --- /dev/null +++ b/src/Rules/Classes/ExistingClassInClassExtendsRule.php @@ -0,0 +1,132 @@ + + */ +final class ExistingClassInClassExtendsRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->extends === null) { + return []; + } + $extendedClassName = (string) $node->extends; + $messages = $this->classCheck->checkClassNames([new ClassNameNodePair($extendedClassName, $node->extends)]); + $currentClassName = null; + if (isset($node->namespacedName)) { + $currentClassName = (string) $node->namespacedName; + } + if (!$this->reflectionProvider->hasClass($extendedClassName)) { + if (!$scope->isInClassExists($extendedClassName)) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends unknown class %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $extendedClassName, + )) + ->identifier('class.notFound') + ->nonIgnorable() + ->discoveringSymbolsTip() + ->build(); + } + } else { + $reflection = $this->reflectionProvider->getClass($extendedClassName); + if ($reflection->isInterface()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends interface %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('class.extendsInterface') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isTrait()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends trait %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('class.extendsTrait') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isEnum()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends enum %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('class.extendsEnum') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isFinalByKeyword()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends final class %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('class.extendsFinal') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isFinal()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends @final class %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('class.extendsFinalByPhpDoc') + ->build(); + } + + if ($reflection->isClass()) { + if ($node->isReadonly()) { + if (!$reflection->isReadOnly()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends non-readonly class %s.', + $currentClassName !== null ? sprintf('Readonly class %s', $currentClassName) : 'Anonymous readonly class', + $reflection->getDisplayName(), + )) + ->identifier('class.readOnly') + ->nonIgnorable() + ->build(); + } + } elseif ($reflection->isReadOnly()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s extends readonly class %s.', + $currentClassName !== null ? sprintf('Non-readonly class %s', $currentClassName) : 'Anonymous non-readonly class', + $reflection->getDisplayName(), + )) + ->identifier('class.nonReadOnly') + ->nonIgnorable() + ->build(); + } + } + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ExistingClassInInstanceOfRule.php b/src/Rules/Classes/ExistingClassInInstanceOfRule.php new file mode 100644 index 00000000..f888e184 --- /dev/null +++ b/src/Rules/Classes/ExistingClassInInstanceOfRule.php @@ -0,0 +1,105 @@ + + */ +final class ExistingClassInInstanceOfRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private bool $checkClassCaseSensitivity, + ) + { + } + + public function getNodeType(): string + { + return Instanceof_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $class = $node->class; + if (!($class instanceof Node\Name)) { + return []; + } + + $name = (string) $class; + $lowercaseName = strtolower($name); + + if (in_array($lowercaseName, [ + 'self', + 'static', + 'parent', + ], true)) { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $lowercaseName)) + ->identifier(sprintf('outOfClass.%s', $lowercaseName)) + ->line($class->getStartLine()) + ->build(), + ]; + } + + return []; + } + + $errors = []; + + if (!$this->reflectionProvider->hasClass($name)) { + if ($scope->isInClassExists($name)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Class %s not found.', $name)) + ->identifier('class.notFound') + ->line($class->getStartLine()) + ->discoveringSymbolsTip() + ->build(), + ]; + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + [new ClassNameNodePair($name, $class)], + $this->checkClassCaseSensitivity, + ), + ); + + $classReflection = $this->reflectionProvider->getClass($name); + + if ($classReflection->isTrait()) { + $expressionType = $scope->getType($node->expr); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Instanceof between %s and trait %s will always evaluate to false.', + $expressionType->describe(VerbosityLevel::typeOnly()), + $name, + ))->identifier('instanceof.trait')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/ExistingClassInTraitUseRule.php b/src/Rules/Classes/ExistingClassInTraitUseRule.php new file mode 100644 index 00000000..b0a23919 --- /dev/null +++ b/src/Rules/Classes/ExistingClassInTraitUseRule.php @@ -0,0 +1,98 @@ + + */ +final class ExistingClassInTraitUseRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\TraitUse::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = $this->classCheck->checkClassNames( + array_map(static fn (Node\Name $traitName): ClassNameNodePair => new ClassNameNodePair((string) $traitName, $traitName), $node->traits), + ); + + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection->isInterface()) { + if (!$scope->isInTrait()) { + foreach ($node->traits as $trait) { + $messages[] = RuleErrorBuilder::message(sprintf('Interface %s uses trait %s.', $classReflection->getName(), (string) $trait)) + ->identifier('interface.traitUse') + ->nonIgnorable() + ->build(); + } + } + } else { + if ($scope->isInTrait()) { + $currentName = sprintf('Trait %s', $scope->getTraitReflection()->getName()); + } else { + if ($classReflection->isAnonymous()) { + $currentName = 'Anonymous class'; + } else { + $currentName = sprintf('Class %s', $classReflection->getName()); + } + } + foreach ($node->traits as $trait) { + $traitName = (string) $trait; + if (!$this->reflectionProvider->hasClass($traitName)) { + $messages[] = RuleErrorBuilder::message(sprintf('%s uses unknown trait %s.', $currentName, $traitName)) + ->identifier('trait.notFound') + ->nonIgnorable() + ->discoveringSymbolsTip() + ->build(); + } else { + $reflection = $this->reflectionProvider->getClass($traitName); + if ($reflection->isClass()) { + $messages[] = RuleErrorBuilder::message(sprintf('%s uses class %s.', $currentName, $reflection->getDisplayName())) + ->identifier('traitUse.class') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isInterface()) { + $messages[] = RuleErrorBuilder::message(sprintf('%s uses interface %s.', $currentName, $reflection->getDisplayName())) + ->identifier('traitUse.interface') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isEnum()) { + $messages[] = RuleErrorBuilder::message(sprintf('%s uses enum %s.', $currentName, $reflection->getDisplayName())) + ->identifier('traitUse.enum') + ->nonIgnorable() + ->build(); + } + } + } + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ExistingClassesInClassImplementsRule.php b/src/Rules/Classes/ExistingClassesInClassImplementsRule.php new file mode 100644 index 00000000..c6518ace --- /dev/null +++ b/src/Rules/Classes/ExistingClassesInClassImplementsRule.php @@ -0,0 +1,95 @@ + + */ +final class ExistingClassesInClassImplementsRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = $this->classCheck->checkClassNames( + array_map(static fn (Node\Name $interfaceName): ClassNameNodePair => new ClassNameNodePair((string) $interfaceName, $interfaceName), $node->implements), + ); + + $currentClassName = null; + if (isset($node->namespacedName)) { + $currentClassName = (string) $node->namespacedName; + } + + foreach ($node->implements as $implements) { + $implementedClassName = (string) $implements; + if (!$this->reflectionProvider->hasClass($implementedClassName)) { + if (!$scope->isInClassExists($implementedClassName)) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s implements unknown interface %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $implementedClassName, + )) + ->identifier('interface.notFound') + ->nonIgnorable() + ->discoveringSymbolsTip() + ->build(); + } + } else { + $reflection = $this->reflectionProvider->getClass($implementedClassName); + if ($reflection->isClass()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s implements class %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('classImplements.class') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isTrait()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s implements trait %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('classImplements.trait') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isEnum()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s implements enum %s.', + $currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class', + $reflection->getDisplayName(), + )) + ->identifier('classImplements.enum') + ->nonIgnorable() + ->build(); + } + } + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ExistingClassesInEnumImplementsRule.php b/src/Rules/Classes/ExistingClassesInEnumImplementsRule.php new file mode 100644 index 00000000..dd9764a4 --- /dev/null +++ b/src/Rules/Classes/ExistingClassesInEnumImplementsRule.php @@ -0,0 +1,92 @@ + + */ +final class ExistingClassesInEnumImplementsRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Enum_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = $this->classCheck->checkClassNames( + array_map(static fn (Node\Name $interfaceName): ClassNameNodePair => new ClassNameNodePair((string) $interfaceName, $interfaceName), $node->implements), + ); + + $currentEnumName = (string) $node->namespacedName; + + foreach ($node->implements as $implements) { + $implementedClassName = (string) $implements; + if (!$this->reflectionProvider->hasClass($implementedClassName)) { + if (!$scope->isInClassExists($implementedClassName)) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Enum %s implements unknown interface %s.', + $currentEnumName, + $implementedClassName, + )) + ->identifier('interface.notFound') + ->nonIgnorable() + ->discoveringSymbolsTip() + ->build(); + } + } else { + $reflection = $this->reflectionProvider->getClass($implementedClassName); + if ($reflection->isClass()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Enum %s implements class %s.', + $currentEnumName, + $reflection->getDisplayName(), + )) + ->identifier('enumImplements.class') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isTrait()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Enum %s implements trait %s.', + $currentEnumName, + $reflection->getDisplayName(), + )) + ->identifier('enumImplements.trait') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isEnum()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Enum %s implements enum %s.', + $currentEnumName, + $reflection->getDisplayName(), + )) + ->identifier('enumImplements.enum') + ->nonIgnorable() + ->build(); + } + } + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ExistingClassesInInterfaceExtendsRule.php b/src/Rules/Classes/ExistingClassesInInterfaceExtendsRule.php new file mode 100644 index 00000000..a410ce8b --- /dev/null +++ b/src/Rules/Classes/ExistingClassesInInterfaceExtendsRule.php @@ -0,0 +1,93 @@ + + */ +final class ExistingClassesInInterfaceExtendsRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Interface_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = $this->classCheck->checkClassNames( + array_map(static fn (Node\Name $interfaceName): ClassNameNodePair => new ClassNameNodePair((string) $interfaceName, $interfaceName), $node->extends), + ); + + $currentInterfaceName = (string) $node->namespacedName; + foreach ($node->extends as $extends) { + $extendedInterfaceName = (string) $extends; + if (!$this->reflectionProvider->hasClass($extendedInterfaceName)) { + if (!$scope->isInClassExists($extendedInterfaceName)) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Interface %s extends unknown interface %s.', + $currentInterfaceName, + $extendedInterfaceName, + )) + ->identifier('interface.notFound') + ->nonIgnorable() + ->discoveringSymbolsTip() + ->build(); + } + } else { + $reflection = $this->reflectionProvider->getClass($extendedInterfaceName); + if ($reflection->isClass()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Interface %s extends class %s.', + $currentInterfaceName, + $reflection->getDisplayName(), + )) + ->identifier('interfaceExtends.class') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isTrait()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Interface %s extends trait %s.', + $currentInterfaceName, + $reflection->getDisplayName(), + )) + ->identifier('interfaceExtends.trait') + ->nonIgnorable() + ->build(); + } elseif ($reflection->isEnum()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Interface %s extends enum %s.', + $currentInterfaceName, + $reflection->getDisplayName(), + )) + ->identifier('interfaceExtends.enum') + ->nonIgnorable() + ->build(); + } + } + + return $messages; + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/ImpossibleInstanceOfRule.php b/src/Rules/Classes/ImpossibleInstanceOfRule.php new file mode 100644 index 00000000..e2757847 --- /dev/null +++ b/src/Rules/Classes/ImpossibleInstanceOfRule.php @@ -0,0 +1,114 @@ + + */ +final class ImpossibleInstanceOfRule implements Rule +{ + + public function __construct( + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Instanceof_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $instanceofType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); + if (!$instanceofType instanceof ConstantBooleanType) { + return []; + } + + if ($node->class instanceof Node\Name) { + $className = $scope->resolveName($node->class); + $classType = new ObjectType($className); + } else { + $classType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->class) : $scope->getNativeType($node->class); + $allowed = TypeCombinator::union( + new StringType(), + new ObjectWithoutClassType(), + ); + if (!$allowed->isSuperTypeOf($classType)->yes()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Instanceof between %s and %s results in an error.', + $scope->getType($node->expr)->describe(VerbosityLevel::typeOnly()), + $classType->describe(VerbosityLevel::typeOnly()), + ))->identifier('instanceof.invalidExprType')->build(), + ]; + } + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $instanceofTypeWithoutPhpDocs = $scope->getNativeType($node); + if ($instanceofTypeWithoutPhpDocs instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + if (!$instanceofType->getValue()) { + $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr); + + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Instanceof between %s and %s will always evaluate to false.', + $exprType->describe(VerbosityLevel::typeOnly()), + $classType->describe(VerbosityLevel::getRecommendedLevelByType($classType)), + )))->identifier('instanceof.alwaysFalse')->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr); + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Instanceof between %s and %s will always evaluate to true.', + $exprType->describe(VerbosityLevel::typeOnly()), + $classType->describe(VerbosityLevel::getRecommendedLevelByType($classType)), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier('instanceof.alwaysTrue'); + + return [$errorBuilder->build()]; + } + +} diff --git a/src/Rules/Classes/InstantiationCallableRule.php b/src/Rules/Classes/InstantiationCallableRule.php new file mode 100644 index 00000000..509af0b7 --- /dev/null +++ b/src/Rules/Classes/InstantiationCallableRule.php @@ -0,0 +1,33 @@ + + */ +final class InstantiationCallableRule implements Rule +{ + + public function getNodeType(): string + { + return InstantiationCallableNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message('Cannot create callable from the new operator.') + ->identifier('callable.notSupported') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Classes/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php new file mode 100644 index 00000000..3384c414 --- /dev/null +++ b/src/Rules/Classes/InstantiationRule.php @@ -0,0 +1,277 @@ + + */ +final class InstantiationRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private FunctionCallParametersCheck $check, + private ClassNameCheck $classCheck, + ) + { + } + + public function getNodeType(): string + { + return New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($this->getClassNames($node, $scope) as [$class, $isName]) { + $errors = array_merge($errors, $this->checkClassName($class, $isName, $node, $scope)); + } + return $errors; + } + + /** + * @param Node\Expr\New_ $node + * @return list + */ + private function checkClassName(string $class, bool $isName, Node $node, Scope $scope): array + { + $lowercasedClass = strtolower($class); + $messages = []; + $isStatic = false; + if ($lowercasedClass === 'static') { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class)) + ->identifier('outOfClass.static') + ->build(), + ]; + } + + $isStatic = true; + $classReflection = $scope->getClassReflection(); + if (!$classReflection->isFinal()) { + if (!$classReflection->hasConstructor()) { + return []; + } + + $constructor = $classReflection->getConstructor(); + if ( + !$constructor->getPrototype()->getDeclaringClass()->isInterface() + && $constructor instanceof PhpMethodReflection + && !$constructor->isFinal()->yes() + && !$constructor->getPrototype()->isAbstract() + ) { + return []; + } + } + } elseif ($lowercasedClass === 'self') { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class)) + ->identifier('outOfClass.self') + ->build(), + ]; + } + $classReflection = $scope->getClassReflection(); + } elseif ($lowercasedClass === 'parent') { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class)) + ->identifier('outOfClass.parent') + ->build(), + ]; + } + if ($scope->getClassReflection()->getParentClass() === null) { + return [ + RuleErrorBuilder::message(sprintf( + '%s::%s() calls new parent but %s does not extend any class.', + $scope->getClassReflection()->getDisplayName(), + $scope->getFunctionName(), + $scope->getClassReflection()->getDisplayName(), + ))->identifier('class.noParent')->build(), + ]; + } + $classReflection = $scope->getClassReflection()->getParentClass(); + } else { + if (!$this->reflectionProvider->hasClass($class)) { + if ($scope->isInClassExists($class)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Instantiated class %s not found.', $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(), + ]; + } + + $messages = $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node->class), + ]); + + $classReflection = $this->reflectionProvider->getClass($class); + } + + if ($classReflection->isEnum() && $isName) { + return [ + RuleErrorBuilder::message( + sprintf('Cannot instantiate enum %s.', $classReflection->getDisplayName()), + )->identifier('new.enum')->build(), + ]; + } + + if (!$isStatic && $classReflection->isInterface() && $isName) { + return [ + RuleErrorBuilder::message( + sprintf('Cannot instantiate interface %s.', $classReflection->getDisplayName()), + )->identifier('new.interface')->build(), + ]; + } + + if (!$isStatic && $classReflection->isAbstract() && $isName) { + return [ + RuleErrorBuilder::message( + sprintf('Instantiated class %s is abstract.', $classReflection->getDisplayName()), + )->identifier('new.abstract')->build(), + ]; + } + + if (!$isName) { + return []; + } + + if (!$classReflection->hasConstructor()) { + if (count($node->getArgs()) > 0) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Class %s does not have a constructor and must be instantiated without any parameters.', + $classReflection->getDisplayName(), + ))->identifier('new.noConstructor')->build(), + ]); + } + + return $messages; + } + + $constructorReflection = $classReflection->getConstructor(); + if (!$scope->canCallMethod($constructorReflection)) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Cannot instantiate class %s via %s constructor %s::%s().', + $classReflection->getDisplayName(), + $constructorReflection->isPrivate() ? 'private' : 'protected', + $constructorReflection->getDeclaringClass()->getDisplayName(), + $constructorReflection->getName(), + )) + ->identifier(sprintf('new.%sConstructor', $constructorReflection->isPrivate() ? 'private' : 'protected')) + ->build(); + } + + $classDisplayName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + + return array_merge($messages, $this->check->check( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $constructorReflection->getVariants(), + $constructorReflection->getNamedArgumentsVariants(), + ), + $scope, + $constructorReflection->getDeclaringClass()->isBuiltin(), + $node, + 'new', + $constructorReflection->acceptsNamedArguments(), + 'Class ' . $classDisplayName . ' constructor invoked with %d parameter, %d required.', + 'Class ' . $classDisplayName . ' constructor invoked with %d parameters, %d required.', + 'Class ' . $classDisplayName . ' constructor invoked with %d parameter, at least %d required.', + 'Class ' . $classDisplayName . ' constructor invoked with %d parameters, at least %d required.', + 'Class ' . $classDisplayName . ' constructor invoked with %d parameter, %d-%d required.', + 'Class ' . $classDisplayName . ' constructor invoked with %d parameters, %d-%d required.', + '%s of class ' . $classDisplayName . ' constructor expects %s, %s given.', + '', // constructor does not have a return type + ' %s of class ' . $classDisplayName . ' constructor is passed by reference, so it expects variables only', + 'Unable to resolve the template type %s in instantiation of class ' . $classDisplayName, + 'Missing parameter $%s in call to ' . $classDisplayName . ' constructor.', + 'Unknown parameter $%s in call to ' . $classDisplayName . ' constructor.', + 'Return type of call to ' . $classDisplayName . ' constructor contains unresolvable type.', + '%s of class ' . $classDisplayName . ' constructor contains unresolvable type.', + 'Class ' . $classDisplayName . ' constructor invoked with %s, but it\'s not allowed because of @no-named-arguments.', + )); + } + + /** + * @param Node\Expr\New_ $node + * @return array + */ + private function getClassNames(Node $node, Scope $scope): array + { + if ($node->class instanceof Node\Name) { + return [[(string) $node->class, true]]; + } + + if ($node->class instanceof Node\Stmt\Class_) { + $classNames = $scope->getType($node)->getObjectClassNames(); + if ($classNames === []) { + throw new ShouldNotHappenException(); + } + + return array_map( + static fn (string $className) => [$className, true], + $classNames, + ); + } + + $type = $scope->getType($node->class); + + if ($type->isClassString()->yes()) { + $concretes = array_filter( + $type->getClassStringObjectType()->getObjectClassReflections(), + static fn (ClassReflection $classReflection): bool => !$classReflection->isAbstract() && !$classReflection->isInterface(), + ); + + if (count($concretes) > 0) { + return array_map( + static fn (ClassReflection $classReflection): array => [$classReflection->getName(), true], + $concretes, + ); + } + } + + return array_merge( + array_map( + static fn (ConstantStringType $type): array => [$type->getValue(), true], + $type->getConstantStrings(), + ), + array_map( + static fn (string $name): array => [$name, false], + $type->getObjectClassNames(), + ), + ); + } + +} diff --git a/src/Rules/Classes/InvalidPromotedPropertiesRule.php b/src/Rules/Classes/InvalidPromotedPropertiesRule.php new file mode 100644 index 00000000..56939a31 --- /dev/null +++ b/src/Rules/Classes/InvalidPromotedPropertiesRule.php @@ -0,0 +1,104 @@ + + */ +final class InvalidPromotedPropertiesRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hasPromotedProperties = false; + + foreach ($node->getParams() as $param) { + if ($param->flags !== 0) { + $hasPromotedProperties = true; + break; + } + + if ($param->hooks === []) { + continue; + } + + $hasPromotedProperties = true; + break; + } + + if (!$hasPromotedProperties) { + return []; + } + + if (!$this->phpVersion->supportsPromotedProperties()) { + return [ + RuleErrorBuilder::message( + 'Promoted properties are supported only on PHP 8.0 and later.', + )->identifier('property.promotedNotSupported')->nonIgnorable()->build(), + ]; + } + + if ( + !$node instanceof Node\Stmt\ClassMethod + || ( + $node->name->toLowerString() !== '__construct' + && $node->getAttribute('originalTraitMethodName') !== '__construct') + ) { + return [ + RuleErrorBuilder::message( + 'Promoted properties can be in constructor only.', + )->identifier('property.invalidPromoted')->nonIgnorable()->build(), + ]; + } + + if ($node->getStmts() === null) { + return [ + RuleErrorBuilder::message( + 'Promoted properties are not allowed in abstract constructors.', + )->identifier('property.invalidPromoted')->nonIgnorable()->build(), + ]; + } + + $errors = []; + foreach ($node->getParams() as $param) { + if ($param->flags === 0) { + continue; + } + + if (!$param->var instanceof Node\Expr\Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + if (!$param->variadic) { + continue; + } + + $propertyName = $param->var->name; + $errors[] = RuleErrorBuilder::message( + sprintf('Promoted property parameter $%s can not be variadic.', $propertyName), + )->identifier('property.invalidPromoted')->nonIgnorable()->line($param->getStartLine())->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php new file mode 100644 index 00000000..8ab34c54 --- /dev/null +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -0,0 +1,360 @@ + $globalTypeAliases + */ + public function __construct( + private array $globalTypeAliases, + private ReflectionProvider $reflectionProvider, + private TypeNodeResolver $typeNodeResolver, + private MissingTypehintCheck $missingTypehintCheck, + private ClassNameCheck $classCheck, + private UnresolvableTypeHelper $unresolvableTypeHelper, + private GenericObjectTypeCheck $genericObjectTypeCheck, + private bool $checkMissingTypehints, + private bool $checkClassCaseSensitivity, + ) + { + } + + /** + * @return list + */ + public function check(ClassReflection $reflection, ClassLike $node): array + { + $errors = []; + foreach ($this->checkInTraitDefinitionContext($reflection) as $error) { + $errors[] = $error; + } + foreach ($this->checkInTraitUseContext($reflection, $reflection, $node) as $error) { + $errors[] = $error; + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitDefinitionContext(ClassReflection $reflection): array + { + $phpDoc = $reflection->getResolvedPhpDoc(); + if ($phpDoc === null) { + return []; + } + + $nameScope = $phpDoc->getNullableNameScope(); + $resolveName = static function (string $name) use ($nameScope): string { + if ($nameScope === null) { + return $name; + } + + return $nameScope->resolveStringName($name); + }; + + $errors = []; + $className = $reflection->getDisplayName(); + + $importedAliases = []; + + foreach ($phpDoc->getTypeAliasImportTags() as $typeAliasImportTag) { + $aliasName = $typeAliasImportTag->getImportedAs() ?? $typeAliasImportTag->getImportedAlias(); + $importedAlias = $typeAliasImportTag->getImportedAlias(); + $importedFromClassName = $typeAliasImportTag->getImportedFrom(); + + if (!$this->reflectionProvider->hasClass($importedFromClassName)) { + $errors[] = RuleErrorBuilder::message(sprintf('Cannot import type alias %s: class %s does not exist.', $importedAlias, $importedFromClassName)) + ->identifier('class.notFound') + ->build(); + continue; + } + + $importedFromReflection = $this->reflectionProvider->getClass($importedFromClassName); + $typeAliases = $importedFromReflection->getTypeAliases(); + + if (!array_key_exists($importedAlias, $typeAliases)) { + $errors[] = RuleErrorBuilder::message(sprintf('Cannot import type alias %s: type alias does not exist in %s.', $importedAlias, $importedFromClassName)) + ->identifier('typeAlias.notFound') + ->build(); + continue; + } + + $resolvedName = $resolveName($aliasName); + if ($this->reflectionProvider->hasClass($resolveName($aliasName))) { + $classReflection = $this->reflectionProvider->getClass($resolvedName); + $classLikeDescription = 'a class'; + if ($classReflection->isInterface()) { + $classLikeDescription = 'an interface'; + } elseif ($classReflection->isTrait()) { + $classLikeDescription = 'a trait'; + } elseif ($classReflection->isEnum()) { + $classLikeDescription = 'an enum'; + } + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s already exists as %s in scope of %s.', $aliasName, $classLikeDescription, $className)) + ->identifier('typeAlias.duplicate') + ->build(); + continue; + } + + if (array_key_exists($aliasName, $this->globalTypeAliases)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s already exists as a global type alias.', $aliasName))->identifier('typeAlias.duplicate')->build(); + continue; + } + + $importedAs = $typeAliasImportTag->getImportedAs(); + if ($importedAs !== null && !$this->isAliasNameValid($importedAs, $nameScope)) { + $errors[] = RuleErrorBuilder::message(sprintf('Imported type alias %s has an invalid name: %s.', $importedAlias, $importedAs))->identifier('typeAlias.invalidName')->build(); + continue; + } + + $importedAliases[] = $aliasName; + } + + foreach ($phpDoc->getTypeAliasTags() as $typeAliasTag) { + $aliasName = $typeAliasTag->getAliasName(); + + if (in_array($aliasName, $importedAliases, true)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s overwrites an imported type alias of the same name.', $aliasName))->identifier('typeAlias.duplicate')->build(); + continue; + } + + $resolvedName = $resolveName($aliasName); + if ($this->reflectionProvider->hasClass($resolvedName)) { + $classReflection = $this->reflectionProvider->getClass($resolvedName); + $classLikeDescription = 'a class'; + if ($classReflection->isInterface()) { + $classLikeDescription = 'an interface'; + } elseif ($classReflection->isTrait()) { + $classLikeDescription = 'a trait'; + } elseif ($classReflection->isEnum()) { + $classLikeDescription = 'an enum'; + } + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s already exists as %s in scope of %s.', $aliasName, $classLikeDescription, $className))->identifier('typeAlias.duplicate')->build(); + continue; + } + + if (array_key_exists($aliasName, $this->globalTypeAliases)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s already exists as a global type alias.', $aliasName))->identifier('typeAlias.duplicate')->build(); + continue; + } + + if (!$this->isAliasNameValid($aliasName, $nameScope)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias has an invalid name: %s.', $aliasName)) + ->identifier('typeAlias.invalidName') + ->build(); + continue; + } + + $resolvedType = $typeAliasTag->getTypeAlias()->resolve($this->typeNodeResolver); + if ($this->hasErrorType($resolvedType, $aliasName, $errors)) { + continue; + } + + if (!$this->checkMissingTypehints) { + continue; + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has type alias %s with no value type specified in iterable type %s.', + $reflection->getClassTypeDescription(), + $reflection->getDisplayName(), + $aliasName, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($resolvedType) as [$name, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has type alias %s with generic %s but does not specify its types: %s', + $reflection->getClassTypeDescription(), + $reflection->getDisplayName(), + $aliasName, + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($resolvedType) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has type alias %s with no signature specified for %s.', + $reflection->getClassTypeDescription(), + $reflection->getDisplayName(), + $aliasName, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitUseContext( + ClassReflection $reflection, + ClassReflection $implementingClassReflection, + ClassLike $node, + ): array + { + if ($reflection->getNativeReflection()->getName() === $implementingClassReflection->getName()) { + $phpDoc = $reflection->getResolvedPhpDoc(); + } else { + $phpDoc = $reflection->getTraitContextResolvedPhpDoc($implementingClassReflection); + } + if ($phpDoc === null) { + return []; + } + + $errors = []; + + foreach ($phpDoc->getTypeAliasTags() as $typeAliasTag) { + $aliasName = $typeAliasTag->getAliasName(); + $resolvedType = $typeAliasTag->getTypeAlias()->resolve($this->typeNodeResolver); + $throwawayErrors = []; + if ($this->hasErrorType($resolvedType, $aliasName, $throwawayErrors)) { + continue; + } + foreach ($resolvedType->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s contains unknown class %s.', $aliasName, $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->reflectionProvider->getClass($class)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s contains invalid type %s.', $aliasName, $class)) + ->identifier('typeAlias.trait') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + if ($this->unresolvableTypeHelper->containsUnresolvableType($resolvedType)) { + $errors[] = RuleErrorBuilder::message(sprintf('Type alias %s contains unresolvable type.', $aliasName)) + ->identifier('typeAlias.unresolvableType') + ->build(); + } + + $escapedTypeAlias = SprintfHelper::escapeFormatString($aliasName); + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $resolvedType, + sprintf( + 'Type alias %s contains generic type %%s but %%s %%s is not generic.', + $escapedTypeAlias, + ), + sprintf( + 'Generic type %%s in type alias %s does not specify all template types of %%s %%s: %%s', + $escapedTypeAlias, + ), + sprintf( + 'Generic type %%s in type alias %s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $escapedTypeAlias, + ), + sprintf( + 'Type %%s in generic type %%s in type alias %s is not subtype of template type %%s of %%s %%s.', + $escapedTypeAlias, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in type alias %s is in conflict with %%s template type %%s of %%s %%s.', + $escapedTypeAlias, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in type alias %s is redundant, template type %%s of %%s %%s has the same variance.', + $escapedTypeAlias, + ), + )); + } + + return $errors; + } + + private function isAliasNameValid(string $aliasName, ?NameScope $nameScope): bool + { + if ($nameScope === null) { + return true; + } + + $aliasNameResolvedType = $this->typeNodeResolver->resolve(new IdentifierTypeNode($aliasName), $nameScope->bypassTypeAliases()); + return ($aliasNameResolvedType->isObject()->yes() && !in_array($aliasName, ['self', 'parent'], true)) + || $aliasNameResolvedType instanceof TemplateType; // aliases take precedence over type parameters, this is reported by other rules using TemplateTypeCheck + } + + /** + * @param list $errors + * @param-out list $errors + */ + private function hasErrorType(Type $type, string $aliasName, array &$errors): bool + { + $foundError = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$errors, &$foundError, $aliasName): Type { + if ($foundError) { + return $type; + } + + if ($type instanceof CircularTypeAliasErrorType) { + $errors[] = RuleErrorBuilder::message(sprintf('Circular definition detected in type alias %s.', $aliasName)) + ->identifier('typeAlias.circular') + ->build(); + $foundError = true; + return $type; + } + + if ($type instanceof ErrorType) { + $errors[] = RuleErrorBuilder::message(sprintf('Invalid type definition detected in type alias %s.', $aliasName)) + ->identifier('typeAlias.invalidType') + ->build(); + $foundError = true; + return $type; + } + + return $traverse($type); + }); + + return $foundError; + } + +} diff --git a/src/Rules/Classes/LocalTypeAliasesRule.php b/src/Rules/Classes/LocalTypeAliasesRule.php new file mode 100644 index 00000000..71b5a505 --- /dev/null +++ b/src/Rules/Classes/LocalTypeAliasesRule.php @@ -0,0 +1,31 @@ + + */ +final class LocalTypeAliasesRule implements Rule +{ + + public function __construct(private LocalTypeAliasesCheck $check) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check($node->getClassReflection(), $node->getOriginalNode()); + } + +} diff --git a/src/Rules/Classes/LocalTypeTraitAliasesRule.php b/src/Rules/Classes/LocalTypeTraitAliasesRule.php new file mode 100644 index 00000000..2e910b35 --- /dev/null +++ b/src/Rules/Classes/LocalTypeTraitAliasesRule.php @@ -0,0 +1,40 @@ + + */ +final class LocalTypeTraitAliasesRule implements Rule +{ + + public function __construct(private LocalTypeAliasesCheck $check, private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->checkInTraitDefinitionContext($this->reflectionProvider->getClass($traitName->toString())); + } + +} diff --git a/src/Rules/Classes/LocalTypeTraitUseAliasesRule.php b/src/Rules/Classes/LocalTypeTraitUseAliasesRule.php new file mode 100644 index 00000000..1bc1020f --- /dev/null +++ b/src/Rules/Classes/LocalTypeTraitUseAliasesRule.php @@ -0,0 +1,35 @@ + + */ +final class LocalTypeTraitUseAliasesRule implements Rule +{ + + public function __construct(private LocalTypeAliasesCheck $check) + { + } + + public function getNodeType(): string + { + return InTraitNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->checkInTraitUseContext( + $node->getTraitReflection(), + $node->getImplementingClassReflection(), + $node->getOriginalNode(), + ); + } + +} diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php new file mode 100644 index 00000000..ba2201a5 --- /dev/null +++ b/src/Rules/Classes/MethodTagCheck.php @@ -0,0 +1,265 @@ + + */ + public function check( + ClassReflection $classReflection, + ClassLike $node, + ): array + { + $errors = []; + foreach ($classReflection->getMethodTags() as $methodName => $methodTag) { + $i = 0; + foreach ($methodTag->getParameters() as $parameterName => $parameterTag) { + $i++; + $parameterDescription = sprintf('parameter #%d $%s', $i, $parameterName); + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $parameterDescription, $parameterTag->getType()) as $error) { + $errors[] = $error; + } + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $parameterDescription, $parameterTag->getType(), $node) as $error) { + $errors[] = $error; + } + + if ($parameterTag->getDefaultValue() === null) { + continue; + } + + $defaultValueDescription = sprintf('%s default value', $parameterDescription); + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $defaultValueDescription, $parameterTag->getDefaultValue()) as $error) { + $errors[] = $error; + } + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $defaultValueDescription, $parameterTag->getDefaultValue(), $node) as $error) { + $errors[] = $error; + } + } + + $returnTypeDescription = 'return type'; + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $returnTypeDescription, $methodTag->getReturnType()) as $error) { + $errors[] = $error; + } + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $returnTypeDescription, $methodTag->getReturnType(), $node) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitDefinitionContext(ClassReflection $classReflection): array + { + $errors = []; + foreach ($classReflection->getMethodTags() as $methodName => $methodTag) { + $i = 0; + foreach ($methodTag->getParameters() as $parameterName => $parameterTag) { + $i++; + $parameterDescription = sprintf('parameter #%d $%s', $i, $parameterName); + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $parameterDescription, $parameterTag->getType()) as $error) { + $errors[] = $error; + } + + if ($parameterTag->getDefaultValue() === null) { + continue; + } + + $defaultValueDescription = sprintf('%s default value', $parameterDescription); + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $defaultValueDescription, $parameterTag->getDefaultValue()) as $error) { + $errors[] = $error; + } + } + + $returnTypeDescription = 'return type'; + foreach ($this->checkMethodTypeInTraitDefinitionContext($classReflection, $methodName, $returnTypeDescription, $methodTag->getReturnType()) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitUseContext( + ClassReflection $classReflection, + ClassReflection $implementingClass, + ClassLike $node, + ): array + { + $phpDoc = $classReflection->getTraitContextResolvedPhpDoc($implementingClass); + if ($phpDoc === null) { + return []; + } + + $errors = []; + foreach ($phpDoc->getMethodTags() as $methodName => $methodTag) { + $i = 0; + foreach ($methodTag->getParameters() as $parameterName => $parameterTag) { + $i++; + $parameterDescription = sprintf('parameter #%d $%s', $i, $parameterName); + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $parameterDescription, $parameterTag->getType(), $node) as $error) { + $errors[] = $error; + } + + if ($parameterTag->getDefaultValue() === null) { + continue; + } + + $defaultValueDescription = sprintf('%s default value', $parameterDescription); + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $defaultValueDescription, $parameterTag->getDefaultValue(), $node) as $error) { + $errors[] = $error; + } + } + + $returnTypeDescription = 'return type'; + foreach ($this->checkMethodTypeInTraitUseContext($classReflection, $methodName, $returnTypeDescription, $methodTag->getReturnType(), $node) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classReflection, string $methodName, string $description, Type $type): array + { + if (!$this->checkMissingTypehints) { + return []; + } + + $errors = []; + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains generic %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodName, + $description, + $innerName, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in iterable type %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $methodName, + $description, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @method for method %s() %s with no signature specified for %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $methodName, + $description, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $errors; + } + + /** + * @return list + */ + private function checkMethodTypeInTraitUseContext(ClassReflection $classReflection, string $methodName, string $description, Type $type, ClassLike $node): array + { + $errors = []; + foreach ($type->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @method for method %s::%s() %s contains unknown class %s.', $classReflection->getDisplayName(), $methodName, $description, $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->reflectionProvider->getClass($class)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @method for method %s::%s() %s contains invalid type %s.', $classReflection->getDisplayName(), $methodName, $description, $class)) + ->identifier('methodTag.trait') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains unresolvable type.', + $classReflection->getDisplayName(), + $methodName, + $description, + ))->identifier('methodTag.unresolvableType')->build(); + } + + $escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $escapedMethodName = SprintfHelper::escapeFormatString($methodName); + $escapedDescription = SprintfHelper::escapeFormatString($description); + + return array_merge( + $errors, + $this->genericObjectTypeCheck->check( + $type, + sprintf('PHPDoc tag @method for method %s::%s() %s contains generic type %%s but %%s %%s is not generic.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Generic type %%s in PHPDoc tag @method for method %s::%s() %s does not specify all template types of %%s %%s: %%s', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Generic type %%s in PHPDoc tag @method for method %s::%s() %s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Type %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is not subtype of template type %%s of %%s %%s.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is in conflict with %%s template type %%s of %%s %%s.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is redundant, template type %%s of %%s %%s has the same variance.', $escapedClassName, $escapedMethodName, $escapedDescription), + ), + ); + } + +} diff --git a/src/Rules/Classes/MethodTagRule.php b/src/Rules/Classes/MethodTagRule.php new file mode 100644 index 00000000..16df54f9 --- /dev/null +++ b/src/Rules/Classes/MethodTagRule.php @@ -0,0 +1,34 @@ + + */ +final class MethodTagRule implements Rule +{ + + public function __construct(private MethodTagCheck $check) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check( + $node->getClassReflection(), + $node->getOriginalNode(), + ); + } + +} diff --git a/src/Rules/Classes/MethodTagTraitRule.php b/src/Rules/Classes/MethodTagTraitRule.php new file mode 100644 index 00000000..0a6e785f --- /dev/null +++ b/src/Rules/Classes/MethodTagTraitRule.php @@ -0,0 +1,40 @@ + + */ +final class MethodTagTraitRule implements Rule +{ + + public function __construct(private MethodTagCheck $check, private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->checkInTraitDefinitionContext($this->reflectionProvider->getClass($traitName->toString())); + } + +} diff --git a/src/Rules/Classes/MethodTagTraitUseRule.php b/src/Rules/Classes/MethodTagTraitUseRule.php new file mode 100644 index 00000000..2cb6ffc4 --- /dev/null +++ b/src/Rules/Classes/MethodTagTraitUseRule.php @@ -0,0 +1,35 @@ + + */ +final class MethodTagTraitUseRule implements Rule +{ + + public function __construct(private MethodTagCheck $check) + { + } + + public function getNodeType(): string + { + return InTraitNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->checkInTraitUseContext( + $node->getTraitReflection(), + $node->getImplementingClassReflection(), + $node->getOriginalNode(), + ); + } + +} diff --git a/src/Rules/Classes/MixinCheck.php b/src/Rules/Classes/MixinCheck.php new file mode 100644 index 00000000..add78d24 --- /dev/null +++ b/src/Rules/Classes/MixinCheck.php @@ -0,0 +1,171 @@ + + */ + public function check(ClassReflection $classReflection, ClassLike $node): array + { + $errors = []; + foreach ($this->checkInTraitDefinitionContext($classReflection) as $error) { + $errors[] = $error; + } + + foreach ($this->checkInTraitUseContext($classReflection, $classReflection, $node) as $error) { + $errors[] = $error; + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitDefinitionContext(ClassReflection $classReflection): array + { + $errors = []; + foreach ($classReflection->getMixinTags() as $mixinTag) { + $type = $mixinTag->getType(); + if (!$type->canCallMethods()->yes() || !$type->canAccessProperties()->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @mixin contains non-object type %s.', $type->describe(VerbosityLevel::typeOnly()))) + ->identifier('mixin.nonObject') + ->build(); + continue; + } + + if (!$this->checkMissingTypehints) { + continue; + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @mixin with no value type specified in iterable type %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @mixin contains generic %s but does not specify its types: %s', + $innerName, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @mixin with no signature specified for %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitUseContext( + ClassReflection $reflection, + ClassReflection $implementingClassReflection, + ClassLike $node, + ): array + { + if ($reflection->getNativeReflection()->getName() === $implementingClassReflection->getName()) { + $phpDoc = $reflection->getResolvedPhpDoc(); + } else { + $phpDoc = $reflection->getTraitContextResolvedPhpDoc($implementingClassReflection); + } + if ($phpDoc === null) { + return []; + } + + $errors = []; + foreach ($phpDoc->getMixinTags() as $mixinTag) { + $type = $mixinTag->getType(); + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($type) + ) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @mixin contains unresolvable type.') + ->identifier('mixin.unresolvableType') + ->build(); + continue; + } + + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $type, + 'PHPDoc tag @mixin contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @mixin does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @mixin specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @mixin is not subtype of template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @mixin is in conflict with %s template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @mixin is redundant, template type %s of %s %s has the same variance.', + )); + + foreach ($type->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @mixin contains unknown class %s.', $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->reflectionProvider->getClass($class)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @mixin contains invalid type %s.', $class)) + ->identifier('mixin.trait') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/MixinRule.php b/src/Rules/Classes/MixinRule.php new file mode 100644 index 00000000..9d86fc06 --- /dev/null +++ b/src/Rules/Classes/MixinRule.php @@ -0,0 +1,31 @@ + + */ +final class MixinRule implements Rule +{ + + public function __construct(private MixinCheck $check) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check($node->getClassReflection(), $node->getOriginalNode()); + } + +} diff --git a/src/Rules/Classes/MixinTraitRule.php b/src/Rules/Classes/MixinTraitRule.php new file mode 100644 index 00000000..98b77362 --- /dev/null +++ b/src/Rules/Classes/MixinTraitRule.php @@ -0,0 +1,42 @@ + + */ +final class MixinTraitRule implements Rule +{ + + public function __construct(private MixinCheck $check, private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->checkInTraitDefinitionContext( + $this->reflectionProvider->getClass($traitName->toString()), + ); + } + +} diff --git a/src/Rules/Classes/MixinTraitUseRule.php b/src/Rules/Classes/MixinTraitUseRule.php new file mode 100644 index 00000000..26ba6d63 --- /dev/null +++ b/src/Rules/Classes/MixinTraitUseRule.php @@ -0,0 +1,35 @@ + + */ +final class MixinTraitUseRule implements Rule +{ + + public function __construct(private MixinCheck $check) + { + } + + public function getNodeType(): string + { + return InTraitNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->checkInTraitUseContext( + $node->getTraitReflection(), + $node->getImplementingClassReflection(), + $node->getOriginalNode(), + ); + } + +} diff --git a/src/Rules/Classes/NewStaticRule.php b/src/Rules/Classes/NewStaticRule.php new file mode 100644 index 00000000..7c21ec0b --- /dev/null +++ b/src/Rules/Classes/NewStaticRule.php @@ -0,0 +1,82 @@ + + */ +final class NewStaticRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + if (!$scope->isInClass()) { + return []; + } + + if (strtolower($node->class->toString()) !== 'static') { + return []; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection->isFinal()) { + return []; + } + + $messages = [ + RuleErrorBuilder::message('Unsafe usage of new static().') + ->identifier('new.static') + ->tip('See: https://phpstan.org/blog/solving-phpstan-error-unsafe-usage-of-new-static') + ->build(), + ]; + if (!$classReflection->hasConstructor()) { + return $messages; + } + + $constructor = $classReflection->getConstructor(); + if ($constructor->getPrototype()->getDeclaringClass()->isInterface()) { + return []; + } + + if ($constructor->getDeclaringClass()->hasConsistentConstructor()) { + return []; + } + + foreach ($classReflection->getImmediateInterfaces() as $interface) { + if ($interface->hasConstructor()) { + return []; + } + } + + if ($constructor instanceof PhpMethodReflection) { + if ($constructor->isFinal()->yes()) { + return []; + } + + $prototype = $constructor->getPrototype(); + if ($prototype->isAbstract()) { + return []; + } + } + + return $messages; + } + +} diff --git a/src/Rules/Classes/NonClassAttributeClassRule.php b/src/Rules/Classes/NonClassAttributeClassRule.php new file mode 100644 index 00000000..67d28734 --- /dev/null +++ b/src/Rules/Classes/NonClassAttributeClassRule.php @@ -0,0 +1,84 @@ + + */ +final class NonClassAttributeClassRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + foreach ($originalNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $name = $attr->name->toLowerString(); + if ($name === 'attribute') { + return $this->check($scope); + } + } + } + + return []; + } + + /** + * @return list + */ + private function check(Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + $classReflection = $scope->getClassReflection(); + if (!$classReflection->isClass()) { + return [ + RuleErrorBuilder::message(sprintf( + '%s cannot be an Attribute class.', + $classReflection->getClassTypeDescription(), + )) + ->identifier(sprintf('attribute.%s', strtolower($classReflection->getClassTypeDescription()))) + ->build(), + ]; + } + if ($classReflection->isAbstract()) { + return [ + RuleErrorBuilder::message(sprintf('Abstract class %s cannot be an Attribute class.', $classReflection->getDisplayName())) + ->identifier('attribute.abstract') + ->build(), + ]; + } + + if (!$classReflection->hasConstructor()) { + return []; + } + + if (!$classReflection->getConstructor()->isPublic()) { + return [ + RuleErrorBuilder::message(sprintf('Attribute class %s constructor must be public.', $classReflection->getDisplayName())) + ->identifier('attribute.constructorNotPublic') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Classes/PropertyTagCheck.php b/src/Rules/Classes/PropertyTagCheck.php new file mode 100644 index 00000000..93438275 --- /dev/null +++ b/src/Rules/Classes/PropertyTagCheck.php @@ -0,0 +1,246 @@ + + */ + public function check( + ClassReflection $classReflection, + ClassLike $node, + ): array + { + $errors = []; + foreach ($classReflection->getPropertyTags() as $propertyName => $propertyTag) { + [$types, $tagName] = $this->getTypesAndTagName($propertyTag); + foreach ($types as $type) { + foreach ($this->checkPropertyTypeInTraitDefinitionContext($classReflection, $propertyName, $tagName, $type) as $error) { + $errors[] = $error; + } + foreach ($this->checkPropertyTypeInTraitUseContext($classReflection, $propertyName, $tagName, $type, $node) as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitDefinitionContext(ClassReflection $classReflection): array + { + $errors = []; + foreach ($classReflection->getPropertyTags() as $propertyName => $propertyTag) { + [$types, $tagName] = $this->getTypesAndTagName($propertyTag); + foreach ($types as $type) { + foreach ($this->checkPropertyTypeInTraitDefinitionContext($classReflection, $propertyName, $tagName, $type) as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkInTraitUseContext( + ClassReflection $classReflection, + ClassReflection $implementingClass, + ClassLike $node, + ): array + { + $phpDoc = $classReflection->getTraitContextResolvedPhpDoc($implementingClass); + if ($phpDoc === null) { + return []; + } + + $errors = []; + foreach ($phpDoc->getPropertyTags() as $propertyName => $propertyTag) { + [$types, $tagName] = $this->getTypesAndTagName($propertyTag); + foreach ($types as $type) { + foreach ($this->checkPropertyTypeInTraitUseContext($classReflection, $propertyName, $tagName, $type, $node) as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * @return array{list, string} + */ + private function getTypesAndTagName(PropertyTag $propertyTag): array + { + $readableType = $propertyTag->getReadableType(); + $writableType = $propertyTag->getWritableType(); + + $types = []; + $tagName = '@property'; + if ($readableType !== null) { + if ($writableType !== null) { + if ($writableType->equals($readableType)) { + $types[] = $readableType; + } else { + $types[] = $readableType; + $types[] = $writableType; + } + } else { + $tagName = '@property-read'; + $types[] = $readableType; + } + } elseif ($writableType !== null) { + $tagName = '@property-write'; + $types[] = $writableType; + } else { + throw new ShouldNotHappenException(); + } + + return [$types, $tagName]; + } + + /** + * @return list + */ + private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $classReflection, string $propertyName, string $tagName, Type $type): array + { + if (!$this->checkMissingTypehints) { + return []; + } + + $errors = []; + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for property %s::$%s contains generic %s but does not specify its types: %s', + $tagName, + $classReflection->getDisplayName(), + $propertyName, + $innerName, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag %s for property $%s with no value type specified in iterable type %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $tagName, + $propertyName, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag %s for property $%s with no signature specified for %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $tagName, + $propertyName, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $errors; + } + + /** + * @return list + */ + private function checkPropertyTypeInTraitUseContext(ClassReflection $classReflection, string $propertyName, string $tagName, Type $type, ClassLike $node): array + { + $errors = []; + foreach ($type->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains unknown class %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->reflectionProvider->getClass($class)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains invalid type %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class)) + ->identifier('propertyTag.trait') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for property %s::$%s contains unresolvable type.', + $tagName, + $classReflection->getDisplayName(), + $propertyName, + ))->identifier('propertyTag.unresolvableType')->build(); + } + + $escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $escapedPropertyName = SprintfHelper::escapeFormatString($propertyName); + $escapedTagName = SprintfHelper::escapeFormatString($tagName); + + return array_merge( + $errors, + $this->genericObjectTypeCheck->check( + $type, + sprintf('PHPDoc tag %s for property %s::$%s contains generic type %%s but %%s %%s is not generic.', $escapedTagName, $escapedClassName, $escapedPropertyName), + sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s does not specify all template types of %%s %%s: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName), + sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName), + sprintf('Type %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is not subtype of template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is in conflict with %%s template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is redundant, template type %%s of %%s %%s has the same variance.', $escapedTagName, $escapedClassName, $escapedPropertyName), + ), + ); + } + +} diff --git a/src/Rules/Classes/PropertyTagRule.php b/src/Rules/Classes/PropertyTagRule.php new file mode 100644 index 00000000..446d24ac --- /dev/null +++ b/src/Rules/Classes/PropertyTagRule.php @@ -0,0 +1,31 @@ + + */ +final class PropertyTagRule implements Rule +{ + + public function __construct(private PropertyTagCheck $check) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check($node->getClassReflection(), $node->getOriginalNode()); + } + +} diff --git a/src/Rules/Classes/PropertyTagTraitRule.php b/src/Rules/Classes/PropertyTagTraitRule.php new file mode 100644 index 00000000..4118f752 --- /dev/null +++ b/src/Rules/Classes/PropertyTagTraitRule.php @@ -0,0 +1,40 @@ + + */ +final class PropertyTagTraitRule implements Rule +{ + + public function __construct(private PropertyTagCheck $check, private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->checkInTraitDefinitionContext($this->reflectionProvider->getClass($traitName->toString())); + } + +} diff --git a/src/Rules/Classes/PropertyTagTraitUseRule.php b/src/Rules/Classes/PropertyTagTraitUseRule.php new file mode 100644 index 00000000..aa79efc4 --- /dev/null +++ b/src/Rules/Classes/PropertyTagTraitUseRule.php @@ -0,0 +1,35 @@ + + */ +final class PropertyTagTraitUseRule implements Rule +{ + + public function __construct(private PropertyTagCheck $check) + { + } + + public function getNodeType(): string + { + return InTraitNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->checkInTraitUseContext( + $node->getTraitReflection(), + $node->getImplementingClassReflection(), + $node->getOriginalNode(), + ); + } + +} diff --git a/src/Rules/Classes/ReadOnlyClassRule.php b/src/Rules/Classes/ReadOnlyClassRule.php new file mode 100644 index 00000000..41786933 --- /dev/null +++ b/src/Rules/Classes/ReadOnlyClassRule.php @@ -0,0 +1,59 @@ + + */ +final class ReadOnlyClassRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$classReflection->isReadOnly()) { + return []; + } + if ($classReflection->isAnonymous()) { + if ($this->phpVersion->supportsReadOnlyAnonymousClasses()) { + return []; + } + + return [ + RuleErrorBuilder::message('Anonymous readonly classes are supported only on PHP 8.3 and later.') + ->identifier('classConstant.nativeTypeNotSupported') + ->nonIgnorable() + ->build(), + ]; + } + + if ($this->phpVersion->supportsReadOnlyClasses()) { + return []; + } + + return [ + RuleErrorBuilder::message('Readonly classes are supported only on PHP 8.2 and later.') + ->identifier('classConstant.nativeTypeNotSupported') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Classes/RequireExtendsRule.php b/src/Rules/Classes/RequireExtendsRule.php new file mode 100644 index 00000000..019f54b7 --- /dev/null +++ b/src/Rules/Classes/RequireExtendsRule.php @@ -0,0 +1,88 @@ + + */ +final class RequireExtendsRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if ($classReflection->isInterface()) { + return []; + } + + $errors = []; + foreach ($classReflection->getInterfaces() as $interface) { + $extendsTags = $interface->getRequireExtendsTags(); + foreach ($extendsTags as $extendsTag) { + $type = $extendsTag->getType(); + if (!$type instanceof ObjectType) { + continue; + } + + if ($classReflection->is($type->getClassName())) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Interface %s requires implementing class to extend %s, but %s does not.', + $interface->getDisplayName(), + $type->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + ), + ) + ->identifier('class.missingExtends') + ->build(); + } + } + + foreach ($classReflection->getTraits(true) as $trait) { + $extendsTags = $trait->getRequireExtendsTags(); + foreach ($extendsTags as $extendsTag) { + $type = $extendsTag->getType(); + if (!$type instanceof ObjectType) { + continue; + } + + if ($classReflection->is($type->getClassName())) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Trait %s requires using class to extend %s, but %s does not.', + $trait->getDisplayName(), + $type->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + ), + ) + ->identifier('class.missingExtends') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/RequireImplementsRule.php b/src/Rules/Classes/RequireImplementsRule.php new file mode 100644 index 00000000..caa1123a --- /dev/null +++ b/src/Rules/Classes/RequireImplementsRule.php @@ -0,0 +1,59 @@ + + */ +final class RequireImplementsRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + $errors = []; + foreach ($classReflection->getTraits(true) as $trait) { + $implementsTags = $trait->getRequireImplementsTags(); + foreach ($implementsTags as $implementsTag) { + $type = $implementsTag->getType(); + if (!$type instanceof ObjectType) { + continue; + } + + if ($classReflection->implementsInterface($type->getClassName())) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Trait %s requires using class to implement %s, but %s does not.', + $trait->getDisplayName(), + $type->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + ), + ) + ->identifier('class.missingImplements') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/TraitAttributeClassRule.php b/src/Rules/Classes/TraitAttributeClassRule.php new file mode 100644 index 00000000..45698b77 --- /dev/null +++ b/src/Rules/Classes/TraitAttributeClassRule.php @@ -0,0 +1,40 @@ + + */ +final class TraitAttributeClassRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + foreach ($node->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $name = $attr->name->toLowerString(); + if ($name === 'attribute') { + return [ + RuleErrorBuilder::message('Trait cannot be an Attribute class.') + ->identifier('attribute.trait') + ->build(), + ]; + } + } + } + + return []; + } + +} diff --git a/src/Rules/Classes/UnusedConstructorParametersRule.php b/src/Rules/Classes/UnusedConstructorParametersRule.php new file mode 100644 index 00000000..ab137b3a --- /dev/null +++ b/src/Rules/Classes/UnusedConstructorParametersRule.php @@ -0,0 +1,71 @@ + + */ +final class UnusedConstructorParametersRule implements Rule +{ + + public function __construct(private UnusedFunctionParametersCheck $check) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $originalNode = $node->getOriginalNode(); + if (strtolower($method->getName()) !== '__construct' || $originalNode->stmts === null) { + return []; + } + + if (count($originalNode->params) === 0) { + return []; + } + + $message = sprintf( + 'Constructor of class %s has an unused parameter $%%s.', + SprintfHelper::escapeFormatString($node->getClassReflection()->getDisplayName()), + ); + if ($node->getClassReflection()->isAnonymous()) { + $message = 'Constructor of an anonymous class has an unused parameter $%s.'; + } + + return $this->check->getUnusedParameters( + $scope, + array_map(static function (Param $parameter): Variable { + if (!$parameter->var instanceof Variable) { + throw new ShouldNotHappenException(); + } + return $parameter->var; + }, array_values(array_filter($originalNode->params, static fn (Param $parameter): bool => $parameter->flags === 0))), + $originalNode->stmts, + $message, + 'constructor.unusedParameter', + ); + } + +} diff --git a/src/Rules/Comparison/BooleanAndConstantConditionRule.php b/src/Rules/Comparison/BooleanAndConstantConditionRule.php new file mode 100644 index 00000000..b6121576 --- /dev/null +++ b/src/Rules/Comparison/BooleanAndConstantConditionRule.php @@ -0,0 +1,160 @@ + + */ +final class BooleanAndConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return BooleanAndNode::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $errors = []; + $originalNode = $node->getOriginalNode(); + $nodeText = $originalNode->getOperatorSigil(); + $leftType = $this->helper->getBooleanType($scope, $originalNode->left); + $identifierType = $originalNode instanceof Node\Expr\BinaryOp\BooleanAnd ? 'booleanAnd' : 'logicalAnd'; + if ($leftType instanceof ConstantBooleanType) { + $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $originalNode->left); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$leftType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipLeft(RuleErrorBuilder::message(sprintf( + 'Left side of %s is always %s.', + $nodeText, + $leftType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('%s.leftAlways%s', $identifierType, $leftType->getValue() ? 'True' : 'False')) + ->line($originalNode->left->getStartLine()); + if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $errors[] = $errorBuilder->build(); + } + } + + $rightScope = $node->getRightScope(); + $rightType = $this->helper->getBooleanType( + $rightScope, + $originalNode->right, + ); + if ($rightType instanceof ConstantBooleanType && !$scope->isInFirstLevelStatement()) { + $addTipRight = function (RuleErrorBuilder $ruleErrorBuilder) use ($rightScope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType( + $rightScope, + $originalNode->right, + ); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$rightType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipRight(RuleErrorBuilder::message(sprintf( + 'Right side of %s is always %s.', + $nodeText, + $rightType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('%s.rightAlways%s', $identifierType, $rightType->getValue() ? 'True' : 'False')) + ->line($originalNode->right->getStartLine()); + if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $errors[] = $errorBuilder->build(); + } + } + + if (count($errors) === 0 && !$scope->isInFirstLevelStatement()) { + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($originalNode) : $scope->getNativeType($originalNode); + if ($nodeType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $scope->getNativeType($originalNode); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$nodeType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Result of %s is always %s.', + $nodeText, + $nodeType->getValue() ? 'true' : 'false', + ))); + if ($nodeType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier(sprintf('%s.always%s', $identifierType, $nodeType->getValue() ? 'True' : 'False')); + + $errors[] = $errorBuilder->build(); + } + } + } + + return $errors; + } + +} diff --git a/src/Rules/Comparison/BooleanNotConstantConditionRule.php b/src/Rules/Comparison/BooleanNotConstantConditionRule.php new file mode 100644 index 00000000..36cd5673 --- /dev/null +++ b/src/Rules/Comparison/BooleanNotConstantConditionRule.php @@ -0,0 +1,78 @@ + + */ +final class BooleanNotConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\BooleanNot::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $exprType = $this->helper->getBooleanType($scope, $node->expr); + if ($exprType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->expr); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($exprType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Negated boolean expression is always %s.', + $exprType->getValue() ? 'false' : 'true', + )))->line($node->expr->getStartLine()); + if (!$exprType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier(sprintf('booleanNot.always%s', $exprType->getValue() ? 'False' : 'True')); + + return [ + $errorBuilder->build(), + ]; + } + } + + return []; + } + +} diff --git a/src/Rules/Comparison/BooleanOrConstantConditionRule.php b/src/Rules/Comparison/BooleanOrConstantConditionRule.php new file mode 100644 index 00000000..7c5fc5e9 --- /dev/null +++ b/src/Rules/Comparison/BooleanOrConstantConditionRule.php @@ -0,0 +1,160 @@ + + */ +final class BooleanOrConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return BooleanOrNode::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $originalNode = $node->getOriginalNode(); + $nodeText = $originalNode->getOperatorSigil(); + $messages = []; + $leftType = $this->helper->getBooleanType($scope, $originalNode->left); + $identifierType = $originalNode instanceof Node\Expr\BinaryOp\BooleanOr ? 'booleanOr' : 'logicalOr'; + if ($leftType instanceof ConstantBooleanType) { + $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $originalNode->left); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$leftType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipLeft(RuleErrorBuilder::message(sprintf( + 'Left side of %s is always %s.', + $nodeText, + $leftType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('%s.leftAlways%s', $identifierType, $leftType->getValue() ? 'True' : 'False')) + ->line($originalNode->left->getStartLine()); + if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $messages[] = $errorBuilder->build(); + } + } + + $rightScope = $node->getRightScope(); + $rightType = $this->helper->getBooleanType( + $rightScope, + $originalNode->right, + ); + if ($rightType instanceof ConstantBooleanType && !$scope->isInFirstLevelStatement()) { + $addTipRight = function (RuleErrorBuilder $ruleErrorBuilder) use ($rightScope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType( + $rightScope, + $originalNode->right, + ); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$rightType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipRight(RuleErrorBuilder::message(sprintf( + 'Right side of %s is always %s.', + $nodeText, + $rightType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('%s.rightAlways%s', $identifierType, $rightType->getValue() ? 'True' : 'False')) + ->line($originalNode->right->getStartLine()); + if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $messages[] = $errorBuilder->build(); + } + } + + if (count($messages) === 0 && !$scope->isInFirstLevelStatement()) { + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($originalNode) : $scope->getNativeType($originalNode); + if ($nodeType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $scope->getNativeType($originalNode); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$nodeType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Result of %s is always %s.', + $nodeText, + $nodeType->getValue() ? 'true' : 'false', + ))); + if ($nodeType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier(sprintf('%s.always%s', $identifierType, $nodeType->getValue() ? 'True' : 'False')); + + $messages[] = $errorBuilder->build(); + } + } + } + + return $messages; + } + +} diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php new file mode 100644 index 00000000..32f4f1b8 --- /dev/null +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -0,0 +1,86 @@ +impossibleCheckTypeHelper->findSpecifiedType($scope, $expr); + if ($isAlways !== null) { + return true; + } + } + + return false; + } + + public function getBooleanType(Scope $scope, Expr $expr): BooleanType + { + if ($this->shouldSkip($scope, $expr)) { + return new BooleanType(); + } + + if ($this->treatPhpDocTypesAsCertain) { + return $scope->getType($expr)->toBoolean(); + } + + return $scope->getNativeType($expr)->toBoolean(); + } + + public function getNativeBooleanType(Scope $scope, Expr $expr): BooleanType + { + if ($this->shouldSkip($scope, $expr)) { + return new BooleanType(); + } + + return $scope->getNativeType($expr)->toBoolean(); + } + +} diff --git a/src/Rules/Comparison/ConstantLooseComparisonRule.php b/src/Rules/Comparison/ConstantLooseComparisonRule.php new file mode 100644 index 00000000..4b2ae0cc --- /dev/null +++ b/src/Rules/Comparison/ConstantLooseComparisonRule.php @@ -0,0 +1,92 @@ + + */ +final class ConstantLooseComparisonRule implements Rule +{ + + public function __construct( + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\BinaryOp::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof Node\Expr\BinaryOp\Equal && !$node instanceof Node\Expr\BinaryOp\NotEqual) { + return []; + } + + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); + if (!$nodeType instanceof ConstantBooleanType) { + return []; + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $instanceofTypeWithoutPhpDocs = $scope->getNativeType($node); + if ($instanceofTypeWithoutPhpDocs instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + if (!$nodeType->getValue()) { + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Loose comparison using %s between %s and %s will always evaluate to false.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Equal ? 'equal' : 'notEqual'))->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Loose comparison using %s between %s and %s will always evaluate to true.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier(sprintf('%s.alwaysTrue', $node instanceof Node\Expr\BinaryOp\Equal ? 'equal' : 'notEqual')); + + return [$errorBuilder->build()]; + } + +} diff --git a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php new file mode 100644 index 00000000..93a0fd9f --- /dev/null +++ b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php @@ -0,0 +1,96 @@ + + */ +final class DoWhileLoopConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return DoWhileLoopConditionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $exprType = $this->helper->getBooleanType($scope, $node->getCond()); + if ($exprType instanceof ConstantBooleanType) { + if ($exprType->getValue()) { + foreach ($node->getExitPoints() as $exitPoint) { + $statement = $exitPoint->getStatement(); + if ($statement instanceof Break_) { + return []; + } + if (!$statement instanceof Continue_) { + return []; + } + if ($statement->num === null) { + continue; + } + if (!$statement->num instanceof Int_) { + continue; + } + $value = $statement->num->value; + if ($value === 1) { + continue; + } + + if ($value > 1) { + return []; + } + } + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->getCond()); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Do-while loop condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + ))) + ->line($node->getCond()->getStartLine()) + ->identifier(sprintf('doWhile.always%s', $exprType->getValue() ? 'True' : 'False')) + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Comparison/ElseIfConstantConditionRule.php b/src/Rules/Comparison/ElseIfConstantConditionRule.php new file mode 100644 index 00000000..8b9a5274 --- /dev/null +++ b/src/Rules/Comparison/ElseIfConstantConditionRule.php @@ -0,0 +1,77 @@ + + */ +final class ElseIfConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ElseIf_::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $exprType = $this->helper->getBooleanType($scope, $node->cond); + if ($exprType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->cond); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->cond->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$exprType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Elseif condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + )))->line($node->cond->getStartLine()); + + if ($exprType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier(sprintf('elseif.always%s', $exprType->getValue() ? 'True' : 'False')); + + return [$errorBuilder->build()]; + } + } + + return []; + } + +} diff --git a/src/Rules/Comparison/IfConstantConditionRule.php b/src/Rules/Comparison/IfConstantConditionRule.php new file mode 100644 index 00000000..52030271 --- /dev/null +++ b/src/Rules/Comparison/IfConstantConditionRule.php @@ -0,0 +1,68 @@ + + */ +final class IfConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\If_::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $exprType = $this->helper->getBooleanType($scope, $node->cond); + if ($exprType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->cond); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'If condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('if.always%s', $exprType->getValue() ? 'True' : 'False')) + ->line($node->cond->getStartLine())->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php new file mode 100644 index 00000000..ab437ab6 --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -0,0 +1,94 @@ + + */ +final class ImpossibleCheckTypeFunctionCallRule implements Rule +{ + + public function __construct( + private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + $functionName = (string) $node->name; + if (strtolower($functionName) === 'is_a') { + return []; + } + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + if ($isAlways === null) { + return []; + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + if ($isAlways !== null) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + if (!$isAlways) { + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Call to function %s()%s will always evaluate to false.', + $functionName, + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('function.impossibleType')->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to function %s()%s will always evaluate to true.', + $functionName, + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier('function.alreadyNarrowedType'); + + return [$errorBuilder->build()]; + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php new file mode 100644 index 00000000..fbe28cc2 --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -0,0 +1,423 @@ +isFirstClassCallable()) { + return null; + } + $argsCount = count($node->getArgs()); + if ($node->name instanceof Node\Name) { + $functionName = strtolower((string) $node->name); + if ($functionName === 'assert' && $argsCount >= 1) { + $arg = $node->getArgs()[0]->value; + $assertValue = ($this->treatPhpDocTypesAsCertain ? $scope->getType($arg) : $scope->getNativeType($arg))->toBoolean(); + if (!$assertValue instanceof ConstantBooleanType) { + return null; + } + + return $assertValue->getValue(); + } + if (in_array($functionName, [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true)) { + return null; + } + if (in_array($functionName, ['count', 'sizeof'], true)) { + return null; + } elseif ($functionName === 'defined') { + return null; + } elseif ($functionName === 'array_search') { + return null; + } elseif ($functionName === 'in_array' && $argsCount >= 2) { + $haystackArg = $node->getArgs()[1]->value; + $haystackType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($haystackArg) : $scope->getNativeType($haystackArg)); + if ($haystackType instanceof MixedType) { + return null; + } + + if (!$haystackType->isArray()->yes()) { + return null; + } + + $needleArg = $node->getArgs()[0]->value; + $needleType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($needleArg) : $scope->getNativeType($needleArg)); + + $isStrictComparison = false; + if ($argsCount >= 3) { + $strictNodeType = $scope->getType($node->getArgs()[2]->value); + $isStrictComparison = $strictNodeType->isTrue()->yes(); + } + + $isStrictComparison = $isStrictComparison + || $needleType->isEnum()->yes() + || $haystackType->getIterableValueType()->isEnum()->yes(); + + if (!$isStrictComparison) { + return null; + } + + $valueType = $haystackType->getIterableValueType(); + $constantNeedleTypesCount = count($needleType->getFiniteTypes()); + $constantHaystackTypesCount = count($valueType->getFiniteTypes()); + $isNeedleSupertype = $needleType->isSuperTypeOf($valueType); + if ($haystackType->isConstantArray()->no()) { + if ($haystackType->isIterableAtLeastOnce()->yes()) { + // In this case the generic implementation via typeSpecifier fails, because the argument types cannot be narrowed down. + if ($constantNeedleTypesCount === 1 && $constantHaystackTypesCount === 1) { + if ($isNeedleSupertype->yes()) { + return true; + } + if ($isNeedleSupertype->no()) { + return false; + } + } + + return null; + } + } + + if (!$haystackType instanceof ConstantArrayType || count($haystackType->getValueTypes()) > 0) { + $haystackArrayTypes = $haystackType->getArrays(); + if (count($haystackArrayTypes) === 1 && $haystackArrayTypes[0]->getIterableValueType() instanceof NeverType) { + return null; + } + + if ($isNeedleSupertype->maybe() || $isNeedleSupertype->yes()) { + foreach ($haystackArrayTypes as $haystackArrayType) { + if ($haystackArrayType instanceof ConstantArrayType) { + foreach ($haystackArrayType->getValueTypes() as $i => $haystackArrayValueType) { + if ($haystackArrayType->isOptionalKey($i)) { + continue; + } + + foreach ($haystackArrayValueType->getConstantScalarTypes() as $constantScalarType) { + if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { + continue 3; + } + } + } + } else { + foreach ($haystackArrayType->getIterableValueType()->getConstantScalarTypes() as $constantScalarType) { + if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { + continue 2; + } + } + } + + return null; + } + } + + if ($isNeedleSupertype->yes()) { + $hasConstantNeedleTypes = $constantNeedleTypesCount > 0; + $hasConstantHaystackTypes = $constantHaystackTypesCount > 0; + if ( + (!$hasConstantNeedleTypes && !$hasConstantHaystackTypes) + || $hasConstantNeedleTypes !== $hasConstantHaystackTypes + ) { + return null; + } + } + } + } elseif ($functionName === 'method_exists' && $argsCount >= 2) { + $objectArg = $node->getArgs()[0]->value; + $objectType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($objectArg) : $scope->getNativeType($objectArg)); + + if ($objectType instanceof ConstantStringType + && !$this->reflectionProvider->hasClass($objectType->getValue()) + ) { + return false; + } + + $methodArg = $node->getArgs()[1]->value; + $methodType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($methodArg) : $scope->getNativeType($methodArg)); + + if ($methodType instanceof ConstantStringType) { + if ($objectType instanceof ConstantStringType) { + $objectType = new ObjectType($objectType->getValue()); + } + + if ($objectType->getObjectClassNames() !== []) { + if ($objectType->hasMethod($methodType->getValue())->yes()) { + return true; + } + + if ($objectType->hasMethod($methodType->getValue())->no()) { + return false; + } + } + + $genericType = TypeTraverser::map($objectType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type instanceof GenericClassStringType) { + return $type->getGenericType(); + } + return new MixedType(); + }); + + if ($genericType instanceof TypeWithClassName) { + if ($genericType->hasMethod($methodType->getValue())->yes()) { + return true; + } + + $classReflection = $genericType->getClassReflection(); + if ( + $classReflection !== null + && $classReflection->isFinal() + && $genericType->hasMethod($methodType->getValue())->no()) { + return false; + } + } + } + } + } + } + + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $typeSpecifierScope = $this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(); + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($typeSpecifierScope, $node, $this->determineContext($typeSpecifierScope, $node)); + + // don't validate types on overwrite + if ($specifiedTypes->shouldOverwrite()) { + return null; + } + + $sureTypes = $specifiedTypes->getSureTypes(); + $sureNotTypes = $specifiedTypes->getSureNotTypes(); + + $rootExpr = $specifiedTypes->getRootExpr(); + if ($rootExpr !== null) { + if (self::isSpecified($typeSpecifierScope, $node, $rootExpr)) { + return null; + } + + $rootExprType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($rootExpr) : $scope->getNativeType($rootExpr)); + if ($rootExprType instanceof ConstantBooleanType) { + return $rootExprType->getValue(); + } + + return null; + } + + $results = []; + + foreach ($sureTypes as $sureType) { + if (self::isSpecified($typeSpecifierScope, $node, $sureType[0])) { + $results[] = TrinaryLogic::createMaybe(); + continue; + } + + if ($this->treatPhpDocTypesAsCertain) { + $argumentType = $scope->getType($sureType[0]); + } else { + $argumentType = $scope->getNativeType($sureType[0]); + } + + /** @var Type $resultType */ + $resultType = $sureType[1]; + + $results[] = $resultType->isSuperTypeOf($argumentType)->result; + } + + foreach ($sureNotTypes as $sureNotType) { + if (self::isSpecified($typeSpecifierScope, $node, $sureNotType[0])) { + $results[] = TrinaryLogic::createMaybe(); + continue; + } + + if ($this->treatPhpDocTypesAsCertain) { + $argumentType = $scope->getType($sureNotType[0]); + } else { + $argumentType = $scope->getNativeType($sureNotType[0]); + } + + /** @var Type $resultType */ + $resultType = $sureNotType[1]; + + $results[] = $resultType->isSuperTypeOf($argumentType)->negate()->result; + } + + if (count($results) === 0) { + return null; + } + + $result = TrinaryLogic::createYes()->and(...$results); + return $result->maybe() ? null : $result->yes(); + } + + private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool + { + if ($expr === $node) { + return true; + } + + if ($expr instanceof Expr\Variable) { + return is_string($expr->name) && !$scope->hasVariableType($expr->name)->yes(); + } + + if ($expr instanceof Expr\BooleanNot) { + return self::isSpecified($scope, $node, $expr->expr); + } + + if ($expr instanceof Expr\BinaryOp) { + return self::isSpecified($scope, $node, $expr->left) || self::isSpecified($scope, $node, $expr->right); + } + + return ( + $node instanceof FuncCall + || $node instanceof MethodCall + || $node instanceof Expr\StaticCall + ) && $scope->hasExpressionType($expr)->yes(); + } + + /** + * @param Node\Arg[] $args + */ + public function getArgumentsDescription( + Scope $scope, + array $args, + ): string + { + if (count($args) === 0) { + return ''; + } + + $descriptions = array_map(fn (Arg $arg): string => ($this->treatPhpDocTypesAsCertain ? $scope->getType($arg->value) : $scope->getNativeType($arg->value))->describe(VerbosityLevel::value()), $args); + + if (count($descriptions) < 3) { + return sprintf(' with %s', implode(' and ', $descriptions)); + } + + $lastDescription = array_pop($descriptions); + + return sprintf( + ' with arguments %s and %s', + implode(', ', $descriptions), + $lastDescription, + ); + } + + public function doNotTreatPhpDocTypesAsCertain(): self + { + if (!$this->treatPhpDocTypesAsCertain) { + return $this; + } + + return new self( + $this->reflectionProvider, + $this->typeSpecifier, + $this->universalObjectCratesClasses, + false, + ); + } + + private function determineContext(Scope $scope, Expr $node): TypeSpecifierContext + { + if ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + return TypeSpecifierContext::createTruthy(); + } + + if ($node instanceof FuncCall && $node->name instanceof Node\Name) { + if ($this->reflectionProvider->hasFunction($node->name, $scope)) { + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + $returnType = TypeUtils::resolveLateResolvableTypes($parametersAcceptor->getReturnType()); + + return $returnType->isVoid()->yes() ? TypeSpecifierContext::createNull() : TypeSpecifierContext::createTruthy(); + } + } elseif ($node instanceof MethodCall && $node->name instanceof Node\Identifier) { + $methodCalledOnType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($methodCalledOnType, $node->name->name); + if ($methodReflection !== null) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); + $returnType = TypeUtils::resolveLateResolvableTypes($parametersAcceptor->getReturnType()); + + return $returnType->isVoid()->yes() ? TypeSpecifierContext::createNull() : TypeSpecifierContext::createTruthy(); + } + } elseif ($node instanceof StaticCall && $node->name instanceof Node\Identifier) { + if ($node->class instanceof Node\Name) { + $calleeType = $scope->resolveTypeByName($node->class); + } else { + $calleeType = $scope->getType($node->class); + } + + $staticMethodReflection = $scope->getMethodReflection($calleeType, $node->name->name); + if ($staticMethodReflection !== null) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); + $returnType = TypeUtils::resolveLateResolvableTypes($parametersAcceptor->getReturnType()); + + return $returnType->isVoid()->yes() ? TypeSpecifierContext::createNull() : TypeSpecifierContext::createTruthy(); + } + } + + return TypeSpecifierContext::createTruthy(); + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php new file mode 100644 index 00000000..aa2a8d3b --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -0,0 +1,111 @@ + + */ +final class ImpossibleCheckTypeMethodCallRule implements Rule +{ + + public function __construct( + private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + if ($isAlways === null) { + return []; + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + if ($isAlways !== null) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + if (!$isAlways) { + $method = $this->getMethod($node->var, $node->name->name, $scope); + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Call to method %s::%s()%s will always evaluate to false.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('method.impossibleType')->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $method = $this->getMethod($node->var, $node->name->name, $scope); + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to method %s::%s()%s will always evaluate to true.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier('method.alreadyNarrowedType'); + + return [$errorBuilder->build()]; + } + + private function getMethod( + Expr $var, + string $methodName, + Scope $scope, + ): MethodReflection + { + $calledOnType = $scope->getType($var); + $method = $scope->getMethodReflection($calledOnType, $methodName); + if ($method === null) { + throw new ShouldNotHappenException(); + } + + return $method; + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php new file mode 100644 index 00000000..9fc10fea --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -0,0 +1,121 @@ + + */ +final class ImpossibleCheckTypeStaticMethodCallRule implements Rule +{ + + public function __construct( + private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + if ($isAlways === null) { + return []; + } + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + if ($isAlways !== null) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + if (!$isAlways) { + $method = $this->getMethod($node->class, $node->name->name, $scope); + + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Call to static method %s::%s()%s will always evaluate to false.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('staticMethod.impossibleType')->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $method = $this->getMethod($node->class, $node->name->name, $scope); + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to static method %s::%s()%s will always evaluate to true.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier('staticMethod.alreadyNarrowedType'); + + return [$errorBuilder->build()]; + } + + /** + * @param Node\Name|Expr $class + * @throws ShouldNotHappenException + */ + private function getMethod( + $class, + string $methodName, + Scope $scope, + ): MethodReflection + { + if ($class instanceof Node\Name) { + $calledOnType = $scope->resolveTypeByName($class); + } else { + $calledOnType = $scope->getType($class); + } + + $method = $scope->getMethodReflection($calledOnType, $methodName); + if ($method === null) { + throw new ShouldNotHappenException(); + } + + return $method; + } + +} diff --git a/src/Rules/Comparison/LogicalXorConstantConditionRule.php b/src/Rules/Comparison/LogicalXorConstantConditionRule.php new file mode 100644 index 00000000..6c78bb4d --- /dev/null +++ b/src/Rules/Comparison/LogicalXorConstantConditionRule.php @@ -0,0 +1,110 @@ + + */ +final class LogicalXorConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return LogicalXor::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $leftType = $this->helper->getBooleanType($scope, $node->left); + if ($leftType instanceof ConstantBooleanType) { + $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->left); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$leftType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipLeft(RuleErrorBuilder::message(sprintf( + 'Left side of xor is always %s.', + $leftType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('logicalXor.leftAlways%s', $leftType->getValue() ? 'True' : 'False')) + ->line($node->left->getStartLine()); + if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $errors[] = $errorBuilder->build(); + } + } + + $rightType = $this->helper->getBooleanType($scope, $node->right); + if ($rightType instanceof ConstantBooleanType) { + $addTipRight = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType( + $scope, + $node->right, + ); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if (!$rightType->getValue() || $isLast !== true || $this->reportAlwaysTrueInLastCondition) { + $errorBuilder = $addTipRight(RuleErrorBuilder::message(sprintf( + 'Right side of xor is always %s.', + $rightType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('logicalXor.rightAlways%s', $rightType->getValue() ? 'True' : 'False')) + ->line($node->right->getStartLine()); + if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + $errors[] = $errorBuilder->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Comparison/MatchExpressionRule.php b/src/Rules/Comparison/MatchExpressionRule.php new file mode 100644 index 00000000..a3d866ad --- /dev/null +++ b/src/Rules/Comparison/MatchExpressionRule.php @@ -0,0 +1,175 @@ + + */ +final class MatchExpressionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $constantConditionRuleHelper, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertain, + ) + { + } + + public function getNodeType(): string + { + return MatchExpressionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $matchCondition = $node->getCondition(); + $matchConditionType = $scope->getType($matchCondition); + $nextArmIsDeadForType = false; + $nextArmIsDeadForNativeType = false; + $errors = []; + $armsCount = count($node->getArms()); + $hasDefault = false; + foreach ($node->getArms() as $i => $arm) { + if ( + $nextArmIsDeadForNativeType + || ($nextArmIsDeadForType && $this->treatPhpDocTypesAsCertain) + ) { + continue; + } + $armConditions = $arm->getConditions(); + if (count($armConditions) === 0) { + $hasDefault = true; + } + foreach ($armConditions as $armCondition) { + $armConditionScope = $armCondition->getScope(); + $armConditionExpr = new Node\Expr\BinaryOp\Identical( + $matchCondition, + $armCondition->getCondition(), + ); + + $armConditionResult = $armConditionScope->getType($armConditionExpr); + if (!$armConditionResult instanceof ConstantBooleanType) { + continue; + } + if ($armConditionResult->getValue()) { + $nextArmIsDeadForType = true; + } + + if (!$this->treatPhpDocTypesAsCertain) { + $armConditionNativeResult = $armConditionScope->getNativeType($armConditionExpr); + if (!$armConditionNativeResult instanceof ConstantBooleanType) { + continue; + } + if ($armConditionNativeResult->getValue()) { + $nextArmIsDeadForNativeType = true; + } + } + + if ($matchConditionType instanceof ConstantBooleanType) { + $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $armCondition->getCondition()); + if (!$armConditionStandaloneResult instanceof ConstantBooleanType) { + continue; + } + } + + $armLine = $armCondition->getLine(); + if (!$armConditionResult->getValue()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Match arm comparison between %s and %s is always false.', + $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), + $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + ))->line($armLine)->identifier('match.alwaysFalse')->build(); + continue; + } + + if ($i === $armsCount - 1 && !$this->reportAlwaysTrueInLastCondition) { + continue; + } + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Match arm comparison between %s and %s is always true.', + $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), + $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + ))->line($armLine); + if ($i !== $armsCount - 1 && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); + } + + $errorBuilder->identifier('match.alwaysTrue'); + + $errors[] = $errorBuilder->build(); + } + } + + if (!$hasDefault && !$nextArmIsDeadForType) { + $remainingType = $node->getEndScope()->getType($matchCondition); + $cases = $remainingType->getEnumCases(); + $casesCount = count($cases); + if ($casesCount > 1) { + $remainingType = new UnionType($cases); + } + if ($casesCount === 1) { + $remainingType = $cases[0]; + } + if ( + !$remainingType instanceof NeverType + && !$this->isUnhandledMatchErrorCaught($node) + && !$this->hasUnhandledMatchErrorThrowsTag($scope) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Match expression does not handle remaining %s: %s', + $remainingType instanceof UnionType ? 'values' : 'value', + $remainingType->describe(VerbosityLevel::value()), + ))->identifier('match.unhandled')->build(); + } + } + + return $errors; + } + + private function isUnhandledMatchErrorCaught(Node $node): bool + { + $tryCatchTypes = $node->getAttribute(TryCatchTypeVisitor::ATTRIBUTE_NAME); + if ($tryCatchTypes === null) { + return false; + } + + $tryCatchType = TypeCombinator::union(...array_map(static fn (string $class) => new ObjectType($class), $tryCatchTypes)); + + return $tryCatchType->isSuperTypeOf(new ObjectType(UnhandledMatchError::class))->yes(); + } + + private function hasUnhandledMatchErrorThrowsTag(Scope $scope): bool + { + $function = $scope->getFunction(); + if ($function === null) { + return false; + } + + $throwsType = $function->getThrowType(); + if ($throwsType === null) { + return false; + } + + return $throwsType->isSuperTypeOf(new ObjectType(UnhandledMatchError::class))->yes(); + } + +} diff --git a/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php new file mode 100644 index 00000000..7b0c4be6 --- /dev/null +++ b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php @@ -0,0 +1,98 @@ + + */ +final class NumberComparisonOperatorsConstantConditionRule implements Rule +{ + + public function __construct( + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return BinaryOp::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + if ( + !$node instanceof BinaryOp\Greater + && !$node instanceof BinaryOp\GreaterOrEqual + && !$node instanceof BinaryOp\Smaller + && !$node instanceof BinaryOp\SmallerOrEqual + ) { + return []; + } + + $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); + if ($exprType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $scope->getNativeType($node); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + switch (get_class($node)) { + case BinaryOp\Greater::class: + $nodeType = 'greater'; + break; + case BinaryOp\GreaterOrEqual::class: + $nodeType = 'greaterOrEqual'; + break; + case BinaryOp\Smaller::class: + $nodeType = 'smaller'; + break; + case BinaryOp\SmallerOrEqual::class: + $nodeType = 'smallerOrEqual'; + break; + default: + throw new ShouldNotHappenException(); + } + + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Comparison operation "%s" between %s and %s is always %s.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + $exprType->getValue() ? 'true' : 'false', + )))->identifier(sprintf('%s.always%s', $nodeType, $exprType->getValue() ? 'True' : 'False'))->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php new file mode 100644 index 00000000..6e43de25 --- /dev/null +++ b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php @@ -0,0 +1,150 @@ + + */ +final class StrictComparisonOfDifferentTypesRule implements Rule +{ + + public function __construct( + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, + private bool $treatPhpDocTypesAsCertain, + private bool $reportAlwaysTrueInLastCondition, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\BinaryOp::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + if ($node instanceof Node\Expr\BinaryOp\Identical) { + $nodeTypeResult = $this->richerScopeGetTypeHelper->getIdenticalResult($this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(), $node); + } elseif ($node instanceof Node\Expr\BinaryOp\NotIdentical) { + $nodeTypeResult = $this->richerScopeGetTypeHelper->getNotIdenticalResult($this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(), $node); + } else { + return []; + } + + $nodeType = $nodeTypeResult->type; + if (!$nodeType instanceof ConstantBooleanType) { + return []; + } + + $leftType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->left) : $scope->getNativeType($node->left); + $rightType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->right) : $scope->getNativeType($node->right); + + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $nodeTypeResult): RuleErrorBuilder { + $reasons = $nodeTypeResult->reasons; + if (count($reasons) > 0) { + return $ruleErrorBuilder->acceptsReasonsTip($reasons); + } + + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $instanceofTypeWithoutPhpDocs = $scope->getNativeType($node); + if ($instanceofTypeWithoutPhpDocs instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $verbosity = VerbosityLevel::value(); + + if ( + ( + $leftType->isConstantScalarValue()->yes() + && !$leftType->isString()->no() + && !$rightType->isConstantScalarValue()->yes() + && !$rightType->isString()->no() + && ( + TrinaryLogic::extremeIdentity($leftType->isLowercaseString(), $rightType->isLowercaseString())->maybe() + || TrinaryLogic::extremeIdentity($leftType->isUppercaseString(), $rightType->isUppercaseString())->maybe() + ) + ) || ( + $rightType->isConstantScalarValue()->yes() + && !$rightType->isString()->no() + && !$leftType->isConstantScalarValue()->yes() + && !$leftType->isString()->no() + && ( + TrinaryLogic::extremeIdentity($leftType->isLowercaseString(), $rightType->isLowercaseString())->maybe() + || TrinaryLogic::extremeIdentity($leftType->isUppercaseString(), $rightType->isUppercaseString())->maybe() + ) + ) + ) { + $verbosity = VerbosityLevel::precise(); + } + + if (!$nodeType->getValue()) { + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Strict comparison using %s between %s and %s will always evaluate to false.', + $node->getOperatorSigil(), + $leftType->describe($verbosity), + $rightType->describe($verbosity), + )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(), + ]; + } + + $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + return []; + } + + $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( + 'Strict comparison using %s between %s and %s will always evaluate to true.', + $node->getOperatorSigil(), + $leftType->describe($verbosity), + $rightType->describe($verbosity), + ))); + if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { + $errorBuilder->addTip('Remove remaining cases below this one and this error will disappear too.'); + } + + if ( + $leftType->isEnum()->yes() + && $rightType->isEnum()->yes() + && $node->getAttribute(LastConditionVisitor::ATTRIBUTE_IS_MATCH_NAME, false) !== true + ) { + $errorBuilder->addTip('Use match expression instead. PHPStan will report unhandled enum cases.'); + } + + $errorBuilder->identifier(sprintf('%s.alwaysTrue', $node instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical')); + + return [ + $errorBuilder->build(), + ]; + } + +} diff --git a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php new file mode 100644 index 00000000..3bf0ec6a --- /dev/null +++ b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php @@ -0,0 +1,65 @@ + + */ +final class TernaryOperatorConstantConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Ternary::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $exprType = $this->helper->getBooleanType($scope, $node->cond); + if ($exprType instanceof ConstantBooleanType) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->cond); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + return [ + $addTip(RuleErrorBuilder::message(sprintf( + 'Ternary operator condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + )))->identifier(sprintf('ternary.always%s', $exprType->getValue() ? 'True' : 'False'))->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Comparison/UsageOfVoidMatchExpressionRule.php b/src/Rules/Comparison/UsageOfVoidMatchExpressionRule.php new file mode 100644 index 00000000..15c0c7d4 --- /dev/null +++ b/src/Rules/Comparison/UsageOfVoidMatchExpressionRule.php @@ -0,0 +1,34 @@ + + */ +final class UsageOfVoidMatchExpressionRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\Match_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInFirstLevelStatement()) { + $matchResultType = $scope->getKeepVoidType($node); + if ($matchResultType->isVoid()->yes()) { + return [RuleErrorBuilder::message('Result of match expression (void) is used.')->identifier('match.void')->build()]; + } + } + + return []; + } + +} diff --git a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php new file mode 100644 index 00000000..ee07001c --- /dev/null +++ b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php @@ -0,0 +1,65 @@ + + */ +final class WhileLoopAlwaysFalseConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return While_::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + $exprType = $this->helper->getBooleanType($scope, $node->cond); + if ($exprType->isFalse()->yes()) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $node->cond); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + return [ + $addTip(RuleErrorBuilder::message('While loop condition is always false.'))->line($node->cond->getStartLine()) + ->identifier('while.alwaysFalse') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php new file mode 100644 index 00000000..da6c285b --- /dev/null +++ b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php @@ -0,0 +1,92 @@ + + */ +final class WhileLoopAlwaysTrueConditionRule implements Rule +{ + + public function __construct( + private ConstantConditionRuleHelper $helper, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return BreaklessWhileLoopNode::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + foreach ($node->getExitPoints() as $exitPoint) { + $statement = $exitPoint->getStatement(); + if ($statement instanceof Break_) { + return []; + } + if (!$statement instanceof Continue_) { + return []; + } + if ($statement->num === null) { + continue; + } + if (!$statement->num instanceof Int_) { + continue; + } + $value = $statement->num->value; + if ($value === 1) { + continue; + } + + if ($value > 1) { + return []; + } + } + $originalNode = $node->getOriginalNode(); + $exprType = $this->helper->getBooleanType($scope, $originalNode->cond); + if ($exprType->isTrue()->yes()) { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain) { + return $ruleErrorBuilder; + } + + $booleanNativeType = $this->helper->getNativeBooleanType($scope, $originalNode->cond); + if ($booleanNativeType instanceof ConstantBooleanType) { + return $ruleErrorBuilder; + } + if (!$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + return [ + $addTip(RuleErrorBuilder::message('While loop condition is always true.'))->line($originalNode->cond->getStartLine()) + ->identifier('while.alwaysTrue') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Constants/AlwaysUsedClassConstantsExtension.php b/src/Rules/Constants/AlwaysUsedClassConstantsExtension.php new file mode 100644 index 00000000..489a2749 --- /dev/null +++ b/src/Rules/Constants/AlwaysUsedClassConstantsExtension.php @@ -0,0 +1,31 @@ + + */ +final class ClassAsClassConstantRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->consts as $const) { + if ($const->name->toLowerString() !== 'class') { + continue; + } + + $errors[] = RuleErrorBuilder::message('A class constant must not be called \'class\'; it is reserved for class name fetching.') + ->line($const->getStartLine()) + ->identifier('classConstant.class') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Constants/ConstantRule.php b/src/Rules/Constants/ConstantRule.php new file mode 100644 index 00000000..4222c8b3 --- /dev/null +++ b/src/Rules/Constants/ConstantRule.php @@ -0,0 +1,40 @@ + + */ +final class ConstantRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\ConstFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->hasConstant($node->name)) { + return [ + RuleErrorBuilder::message(sprintf( + 'Constant %s not found.', + (string) $node->name, + )) + ->identifier('constant.notFound') + ->discoveringSymbolsTip() + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Constants/DynamicClassConstantFetchRule.php b/src/Rules/Constants/DynamicClassConstantFetchRule.php new file mode 100644 index 00000000..93b3ab46 --- /dev/null +++ b/src/Rules/Constants/DynamicClassConstantFetchRule.php @@ -0,0 +1,70 @@ + + */ +final class DynamicClassConstantFetchRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion, private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return ClassConstFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Expr) { + return []; + } + + if (!$this->phpVersion->supportsDynamicClassConstantFetch()) { + return [ + RuleErrorBuilder::message('Fetching class constants with a dynamic name is supported only on PHP 8.3 and later.') + ->identifier('classConstant.dynamicFetch') + ->nonIgnorable() + ->build(), + ]; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->name, + '', + static fn (Type $type): bool => $type->isString()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return []; + } + if ($type->isString()->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Class constant name in dynamic fetch can only be a string, %s given.', + $type->describe(VerbosityLevel::typeOnly()), + ))->identifier('classConstant.nameType')->build(), + ]; + } + +} diff --git a/src/Rules/Constants/FinalConstantRule.php b/src/Rules/Constants/FinalConstantRule.php new file mode 100644 index 00000000..8d8c5d12 --- /dev/null +++ b/src/Rules/Constants/FinalConstantRule.php @@ -0,0 +1,44 @@ + */ +final class FinalConstantRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->isFinal()) { + return []; + } + + if ($this->phpVersion->supportsFinalConstants()) { + return []; + } + + return [ + RuleErrorBuilder::message('Final class constants are supported only on PHP 8.1 and later.') + ->identifier('classConstant.finalNotSupported') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Constants/LazyAlwaysUsedClassConstantsExtensionProvider.php b/src/Rules/Constants/LazyAlwaysUsedClassConstantsExtensionProvider.php new file mode 100644 index 00000000..78d9dbfe --- /dev/null +++ b/src/Rules/Constants/LazyAlwaysUsedClassConstantsExtensionProvider.php @@ -0,0 +1,27 @@ +extensions === null) { + $this->extensions = $this->container->getServicesByTag(AlwaysUsedClassConstantsExtensionProvider::EXTENSION_TAG); + } + + return $this->extensions; + } + +} diff --git a/src/Rules/Constants/MagicConstantContextRule.php b/src/Rules/Constants/MagicConstantContextRule.php new file mode 100644 index 00000000..31b59780 --- /dev/null +++ b/src/Rules/Constants/MagicConstantContextRule.php @@ -0,0 +1,76 @@ + */ +final class MagicConstantContextRule implements Rule +{ + + public function getNodeType(): string + { + return MagicConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + // test cases https://3v4l.org/ZUvvr + + if ($node instanceof MagicConst\Class_) { + if ($scope->isInClass()) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('Magic constant %s is always empty outside a class.', $node->getName()), + )->identifier('magicConstant.outOfClass')->build(), + ]; + } elseif ($node instanceof MagicConst\Trait_) { + if ($scope->isInTrait()) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('Magic constant %s is always empty outside a trait.', $node->getName()), + )->identifier('magicConstant.outOfTrait')->build(), + ]; + } elseif ($node instanceof MagicConst\Method || $node instanceof MagicConst\Function_) { + if ($scope->getFunctionName() !== null) { + return []; + } + if ($scope->isInAnonymousFunction()) { + return []; + } + + if ((bool) $node->getAttribute(MagicConstantParamDefaultVisitor::ATTRIBUTE_NAME)) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf('Magic constant %s is always empty outside a function.', $node->getName()), + )->identifier('magicConstant.outOfFunction')->build(), + ]; + } elseif ($node instanceof MagicConst\Namespace_) { + if ($scope->getNamespace() === null) { + return [ + RuleErrorBuilder::message( + sprintf('Magic constant %s is always empty in global namespace.', $node->getName()), + )->identifier('magicConstant.outOfNamespace')->build(), + ]; + } + } + return []; + } + +} diff --git a/src/Rules/Constants/MissingClassConstantTypehintRule.php b/src/Rules/Constants/MissingClassConstantTypehintRule.php new file mode 100644 index 00000000..89d215fc --- /dev/null +++ b/src/Rules/Constants/MissingClassConstantTypehintRule.php @@ -0,0 +1,97 @@ + + */ +final class MissingClassConstantTypehintRule implements Rule +{ + + public function __construct(private MissingTypehintCheck $missingTypehintCheck) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($node->consts as $const) { + $constantName = $const->name->toString(); + $errors = array_merge($errors, $this->processSingleConstant($scope->getClassReflection(), $constantName)); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleConstant(ClassReflection $classReflection, string $constantName): array + { + $constantReflection = $classReflection->getConstant($constantName); + $constantType = $constantReflection->getPhpDocType(); + if ($constantType === null) { + return []; + } + + $errors = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($constantType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s type has no value type specified in iterable type %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($constantType) as [$name, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s with generic %s does not specify its types: %s', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($constantType) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s type has no signature specified for %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Constants/NativeTypedClassConstantRule.php b/src/Rules/Constants/NativeTypedClassConstantRule.php new file mode 100644 index 00000000..e262a2c6 --- /dev/null +++ b/src/Rules/Constants/NativeTypedClassConstantRule.php @@ -0,0 +1,45 @@ + + */ +final class NativeTypedClassConstantRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->type === null) { + return []; + } + + if ($this->phpVersion->supportsNativeTypesInClassConstants()) { + return []; + } + + return [ + RuleErrorBuilder::message('Class constants with native types are supported only on PHP 8.3 and later.') + ->identifier('classConstant.nativeTypeNotSupported') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Constants/OverridingConstantRule.php b/src/Rules/Constants/OverridingConstantRule.php new file mode 100644 index 00000000..7239cfaa --- /dev/null +++ b/src/Rules/Constants/OverridingConstantRule.php @@ -0,0 +1,173 @@ + + */ +final class OverridingConstantRule implements Rule +{ + + public function __construct( + private bool $checkPhpDocMethodSignatures, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($node->consts as $const) { + $constantName = $const->name->toString(); + $errors = array_merge($errors, $this->processSingleConstant($scope->getClassReflection(), $constantName)); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleConstant(ClassReflection $classReflection, string $constantName): array + { + $prototype = $this->findPrototype($classReflection, $constantName); + if ($prototype === null) { + return []; + } + + $constantReflection = $classReflection->getConstant($constantName); + $errors = []; + if ($prototype->isFinal()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s overrides final constant %s::%s.', + $classReflection->getDisplayName(), + $constantReflection->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + ))->identifier('classConstant.final')->nonIgnorable()->build(); + } + + if ($prototype->isPublic()) { + if (!$constantReflection->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s constant %s::%s overriding public constant %s::%s should also be public.', + $constantReflection->isPrivate() ? 'Private' : 'Protected', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantReflection->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + ))->identifier('classConstant.visibility')->nonIgnorable()->build(); + } + } elseif ($constantReflection->isPrivate()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Private constant %s::%s overriding protected constant %s::%s should be protected or public.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantReflection->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + ))->identifier('classConstant.visibility')->nonIgnorable()->build(); + } + + if (!$this->checkPhpDocMethodSignatures) { + return $errors; + } + + $prototypeNativeType = $prototype->getNativeType(); + $constantNativeType = $constantReflection->getNativeType(); + if ($prototypeNativeType !== null) { + if ($constantNativeType !== null) { + if (!$prototypeNativeType->isSuperTypeOf($constantNativeType)->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Native type %s of constant %s::%s is not covariant with native type %s of constant %s::%s.', + $constantNativeType->describe(VerbosityLevel::typeOnly()), + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantReflection->getName(), + $prototypeNativeType->describe(VerbosityLevel::typeOnly()), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + ))->identifier('classConstant.nativeType')->nonIgnorable()->build(); + } + } else { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s overriding constant %s::%s (%s) should also have native type %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantReflection->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + $prototypeNativeType->describe(VerbosityLevel::typeOnly()), + $prototypeNativeType->describe(VerbosityLevel::typeOnly()), + ))->identifier('classConstant.missingNativeType')->nonIgnorable()->build(); + } + } + + if (!$prototype->hasPhpDocType()) { + return $errors; + } + + if (!$constantReflection->hasPhpDocType()) { + return $errors; + } + + if (!$prototype->getValueType()->isSuperTypeOf($constantReflection->getValueType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of constant %s::%s is not covariant with type %s of constant %s::%s.', + $constantReflection->getValueType()->describe(VerbosityLevel::value()), + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantReflection->getName(), + $prototype->getValueType()->describe(VerbosityLevel::value()), + $prototype->getDeclaringClass()->getDisplayName(), + $prototype->getName(), + ))->identifier('classConstant.type')->build(); + } + + return $errors; + } + + private function findPrototype(ClassReflection $classReflection, string $constantName): ?ClassConstantReflection + { + foreach ($classReflection->getImmediateInterfaces() as $immediateInterface) { + if ($immediateInterface->hasConstant($constantName)) { + return $immediateInterface->getConstant($constantName); + } + } + + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null) { + return null; + } + + if (!$parentClass->hasConstant($constantName)) { + return null; + } + + $constant = $parentClass->getConstant($constantName); + if ($constant->isPrivate()) { + return null; + } + + return $constant; + } + +} diff --git a/src/Rules/Constants/ValueAssignedToClassConstantRule.php b/src/Rules/Constants/ValueAssignedToClassConstantRule.php new file mode 100644 index 00000000..d449746a --- /dev/null +++ b/src/Rules/Constants/ValueAssignedToClassConstantRule.php @@ -0,0 +1,129 @@ + + */ +final class ValueAssignedToClassConstantRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $nativeType = null; + if ($node->type !== null) { + $nativeType = ParserNodeTypeToPHPStanType::resolve($node->type, $scope->getClassReflection()); + } + + $errors = []; + foreach ($node->consts as $const) { + $constantName = $const->name->toString(); + $errors = array_merge($errors, $this->processSingleConstant( + $scope->getClassReflection(), + $constantName, + $scope->getType($const->value), + $nativeType, + )); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleConstant(ClassReflection $classReflection, string $constantName, Type $valueExprType, ?Type $nativeType): array + { + $constantReflection = $classReflection->getConstant($constantName); + $phpDocType = $constantReflection->getPhpDocType(); + if ($phpDocType === null) { + if ($nativeType === null) { + return []; + } + + $accepts = $nativeType->accepts($valueExprType, true); + if ($accepts->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Constant %s::%s (%s) does not accept value %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $nativeType->describe(VerbosityLevel::typeOnly()), + $valueExprType->describe(VerbosityLevel::value()), + ))->acceptsReasonsTip($accepts->reasons)->nonIgnorable()->identifier('classConstant.value')->build(), + ]; + } elseif ($nativeType === null) { + $isSuperType = $phpDocType->isSuperTypeOf($valueExprType); + $verbosity = VerbosityLevel::getRecommendedLevelByType($phpDocType, $valueExprType); + if ($isSuperType->no()) { + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var for constant %s::%s with type %s is incompatible with value %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $phpDocType->describe($verbosity), + $valueExprType->describe(VerbosityLevel::value()), + ))->identifier('classConstant.phpDocType')->build(), + ]; + + } elseif ($isSuperType->maybe()) { + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var for constant %s::%s with type %s is not subtype of value %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $phpDocType->describe($verbosity), + $valueExprType->describe(VerbosityLevel::value()), + ))->identifier('classConstant.phpDocType')->build(), + ]; + } + + return []; + } + + $type = $constantReflection->getValueType(); + $accepts = $type->accepts($valueExprType, true); + if ($accepts->yes()) { + return []; + } + + $verbosity = VerbosityLevel::getRecommendedLevelByType($type, $valueExprType); + + return [ + RuleErrorBuilder::message(sprintf( + 'Constant %s::%s (%s) does not accept value %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $type->describe(VerbosityLevel::typeOnly()), + $valueExprType->describe($verbosity), + ))->acceptsReasonsTip($accepts->reasons)->identifier('classConstant.value')->build(), + ]; + } + +} diff --git a/src/Rules/DateTimeInstantiationRule.php b/src/Rules/DateTimeInstantiationRule.php new file mode 100644 index 00000000..0a683179 --- /dev/null +++ b/src/Rules/DateTimeInstantiationRule.php @@ -0,0 +1,72 @@ + + */ +final class DateTimeInstantiationRule implements Rule +{ + + public function getNodeType(): string + { + return New_::class; + } + + /** + * @param New_ $node + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + $lowerClassName = strtolower((string) $node->class); + if ( + count($node->getArgs()) === 0 + || !in_array($lowerClassName, ['datetime', 'datetimeimmutable'], true) + ) { + return []; + } + + $arg = $scope->getType($node->getArgs()[0]->value); + $errors = []; + + foreach ($arg->getConstantStrings() as $constantString) { + $dateString = $constantString->getValue(); + try { + new DateTime($dateString); + } catch (Throwable) { + // an exception is thrown for errors only but we want to catch warnings too + } + $lastErrors = DateTime::getLastErrors(); + if ($lastErrors === false) { + continue; + } + + foreach ($lastErrors['errors'] as $error) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Instantiating %s with %s produces an error: %s', + $lowerClassName === 'datetime' ? 'DateTime' : 'DateTimeImmutable', + $dateString, + $error, + ))->identifier(sprintf('new.%s', $lowerClassName === 'datetime' ? 'dateTime' : 'dateTimeImmutable'))->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/CallToConstructorStatementWithoutImpurePointsRule.php b/src/Rules/DeadCode/CallToConstructorStatementWithoutImpurePointsRule.php new file mode 100644 index 00000000..90f78d7b --- /dev/null +++ b/src/Rules/DeadCode/CallToConstructorStatementWithoutImpurePointsRule.php @@ -0,0 +1,55 @@ + + */ +final class CallToConstructorStatementWithoutImpurePointsRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classesWithConstructors = []; + foreach ($node->get(ConstructorWithoutImpurePointsCollector::class) as [$class]) { + $classesWithConstructors[strtolower($class)] = $class; + } + + $errors = []; + foreach ($node->get(PossiblyPureNewCollector::class) as $filePath => $data) { + foreach ($data as [$class, $line]) { + $lowerClass = strtolower($class); + if (!array_key_exists($lowerClass, $classesWithConstructors)) { + continue; + } + + $originalClassName = $classesWithConstructors[$lowerClass]; + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to new %s() on a separate line has no effect.', + $originalClassName, + ))->file($filePath) + ->line($line) + ->identifier('new.resultUnused') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/CallToFunctionStatementWithoutImpurePointsRule.php b/src/Rules/DeadCode/CallToFunctionStatementWithoutImpurePointsRule.php new file mode 100644 index 00000000..86c60583 --- /dev/null +++ b/src/Rules/DeadCode/CallToFunctionStatementWithoutImpurePointsRule.php @@ -0,0 +1,55 @@ + + */ +final class CallToFunctionStatementWithoutImpurePointsRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functions = []; + foreach ($node->get(FunctionWithoutImpurePointsCollector::class) as [$functionName]) { + $functions[strtolower($functionName)] = $functionName; + } + + $errors = []; + foreach ($node->get(PossiblyPureFuncCallCollector::class) as $filePath => $data) { + foreach ($data as [$func, $line]) { + $lowerFunc = strtolower($func); + if (!array_key_exists($lowerFunc, $functions)) { + continue; + } + + $originalFunctionName = $functions[$lowerFunc]; + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to function %s() on a separate line has no effect.', + $originalFunctionName, + ))->file($filePath) + ->line($line) + ->identifier('function.resultUnused') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRule.php b/src/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRule.php new file mode 100644 index 00000000..8de2cde4 --- /dev/null +++ b/src/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRule.php @@ -0,0 +1,72 @@ + + */ +final class CallToMethodStatementWithoutImpurePointsRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methods = []; + foreach ($node->get(MethodWithoutImpurePointsCollector::class) as $collected) { + foreach ($collected as [$className, $methodName, $classDisplayName]) { + $className = strtolower($className); + + if (!array_key_exists($className, $methods)) { + $methods[$className] = []; + } + $methods[$className][strtolower($methodName)] = $classDisplayName . '::' . $methodName; + } + } + + $errors = []; + foreach ($node->get(PossiblyPureMethodCallCollector::class) as $filePath => $data) { + foreach ($data as [$classNames, $method, $line]) { + $originalMethodName = null; + foreach ($classNames as $className) { + $className = strtolower($className); + + if (!array_key_exists($className, $methods)) { + continue 2; + } + + $lowerMethod = strtolower($method); + if (!array_key_exists($lowerMethod, $methods[$className])) { + continue 2; + } + + $originalMethodName = $methods[$className][$lowerMethod]; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to method %s() on a separate line has no effect.', + $originalMethodName, + ))->file($filePath) + ->line($line) + ->identifier('method.resultUnused') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/CallToStaticMethodStatementWithoutImpurePointsRule.php b/src/Rules/DeadCode/CallToStaticMethodStatementWithoutImpurePointsRule.php new file mode 100644 index 00000000..e6ad104a --- /dev/null +++ b/src/Rules/DeadCode/CallToStaticMethodStatementWithoutImpurePointsRule.php @@ -0,0 +1,69 @@ + + */ +final class CallToStaticMethodStatementWithoutImpurePointsRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methods = []; + foreach ($node->get(MethodWithoutImpurePointsCollector::class) as $collected) { + foreach ($collected as [$className, $methodName, $classDisplayName]) { + $lowerClassName = strtolower($className); + + if (!array_key_exists($lowerClassName, $methods)) { + $methods[$lowerClassName] = []; + } + $methods[$lowerClassName][strtolower($methodName)] = $classDisplayName . '::' . $methodName; + } + } + + $errors = []; + foreach ($node->get(PossiblyPureStaticCallCollector::class) as $filePath => $data) { + foreach ($data as [$className, $method, $line]) { + $lowerClassName = strtolower($className); + + if (!array_key_exists($lowerClassName, $methods)) { + continue; + } + + $lowerMethod = strtolower($method); + if (!array_key_exists($lowerMethod, $methods[$lowerClassName])) { + continue; + } + + $originalMethodName = $methods[$lowerClassName][$lowerMethod]; + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to %s() on a separate line has no effect.', + $originalMethodName, + ))->file($filePath) + ->line($line) + ->identifier('staticMethod.resultUnused') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/ConstructorWithoutImpurePointsCollector.php b/src/Rules/DeadCode/ConstructorWithoutImpurePointsCollector.php new file mode 100644 index 00000000..1a377b60 --- /dev/null +++ b/src/Rules/DeadCode/ConstructorWithoutImpurePointsCollector.php @@ -0,0 +1,57 @@ + + */ +final class ConstructorWithoutImpurePointsCollector implements Collector +{ + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope) + { + $method = $node->getMethodReflection(); + if (!$method->isConstructor()) { + return null; + } + + if (!$method->isPure()->maybe()) { + return null; + } + + if (count($node->getImpurePoints()) !== 0) { + return null; + } + + if (count($node->getStatementResult()->getThrowPoints()) !== 0) { + return null; + } + + foreach ($method->getParameters() as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + return null; + } + + if (count($method->getAsserts()->getAll()) !== 0) { + return null; + } + + return $method->getDeclaringClass()->getName(); + } + +} diff --git a/src/Rules/DeadCode/FunctionWithoutImpurePointsCollector.php b/src/Rules/DeadCode/FunctionWithoutImpurePointsCollector.php new file mode 100644 index 00000000..74182ab6 --- /dev/null +++ b/src/Rules/DeadCode/FunctionWithoutImpurePointsCollector.php @@ -0,0 +1,56 @@ + + */ +final class FunctionWithoutImpurePointsCollector implements Collector +{ + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope) + { + $function = $node->getFunctionReflection(); + if (!$function->isPure()->maybe()) { + return null; + } + if (!$function->hasSideEffects()->maybe()) { + return null; + } + + if (count($node->getImpurePoints()) !== 0) { + return null; + } + + if (count($node->getStatementResult()->getThrowPoints()) !== 0) { + return null; + } + + foreach ($function->getParameters() as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + return null; + } + + if (count($function->getAsserts()->getAll()) !== 0) { + return null; + } + + return $function->getName(); + } + +} diff --git a/src/Rules/DeadCode/MethodWithoutImpurePointsCollector.php b/src/Rules/DeadCode/MethodWithoutImpurePointsCollector.php new file mode 100644 index 00000000..3379e934 --- /dev/null +++ b/src/Rules/DeadCode/MethodWithoutImpurePointsCollector.php @@ -0,0 +1,60 @@ + + */ +final class MethodWithoutImpurePointsCollector implements Collector +{ + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope) + { + $method = $node->getMethodReflection(); + if (!$method->isPure()->maybe()) { + return null; + } + if (!$method->hasSideEffects()->maybe()) { + return null; + } + + if (count($node->getImpurePoints()) !== 0) { + return null; + } + + if (count($node->getStatementResult()->getThrowPoints()) !== 0) { + return null; + } + + foreach ($method->getParameters() as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + return null; + } + + if (count($method->getAsserts()->getAll()) !== 0) { + return null; + } + + if ($method->isConstructor()) { + return null; + } + + return [$method->getDeclaringClass()->getName(), $method->getName(), $method->getDeclaringClass()->getDisplayName()]; + } + +} diff --git a/src/Rules/DeadCode/NoopRule.php b/src/Rules/DeadCode/NoopRule.php new file mode 100644 index 00000000..7b4f8f6d --- /dev/null +++ b/src/Rules/DeadCode/NoopRule.php @@ -0,0 +1,139 @@ + + */ +final class NoopRule implements Rule +{ + + public function __construct(private ExprPrinter $exprPrinter) + { + } + + public function getNodeType(): string + { + return NoopExpressionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $expr = $node->getOriginalExpr(); + if ($expr instanceof Node\Expr\BinaryOp\LogicalXor) { + return [ + RuleErrorBuilder::message( + 'Unused result of "xor" operator.', + )->line($expr->getStartLine()) + ->tip('This operator has unexpected precedence, try disambiguating the logic with parentheses ().') + ->identifier('logicalXor.resultUnused') + ->build(), + ]; + } + + if ($expr instanceof Node\Expr\BinaryOp\LogicalAnd || $expr instanceof Node\Expr\BinaryOp\LogicalOr) { + $identifierType = $expr instanceof Node\Expr\BinaryOp\LogicalAnd ? 'logicalAnd' : 'logicalOr'; + + return [ + RuleErrorBuilder::message(sprintf( + 'Unused result of "%s" operator.', + $expr->getOperatorSigil(), + ))->line($expr->getStartLine()) + ->tip('This operator has unexpected precedence, try disambiguating the logic with parentheses ().') + ->identifier(sprintf('%s.resultUnused', $identifierType)) + ->build(), + ]; + } + + if ($node->hasAssign()) { + return []; + } + + if ($expr instanceof Node\Expr\BinaryOp\BooleanAnd || $expr instanceof Node\Expr\BinaryOp\BooleanOr) { + $identifierType = $expr instanceof Node\Expr\BinaryOp\BooleanAnd ? 'booleanAnd' : 'booleanOr'; + + return [ + RuleErrorBuilder::message(sprintf( + 'Unused result of "%s" operator.', + $expr->getOperatorSigil(), + ))->line($expr->getStartLine()) + ->identifier(sprintf('%s.resultUnused', $identifierType)) + ->build(), + ]; + } + + if ($expr instanceof Node\Expr\Ternary) { + return [ + RuleErrorBuilder::message('Unused result of ternary operator.') + ->line($expr->getStartLine()) + ->identifier('ternary.resultUnused') + ->build(), + ]; + } + + if ($expr instanceof Node\Expr\FuncCall) { + if ($expr->name instanceof Node\Name) { + // handled by CallToFunctionStatementWithoutSideEffectsRule + return []; + } + + $nameType = $scope->getType($expr->name); + if (!$nameType->isCallable()->yes()) { + return []; + } + } + + if ($expr instanceof Node\Expr\New_ && $expr->class instanceof Node\Name) { + // handled by CallToConstructorStatementWithoutSideEffectsRule + return []; + } + + if ( + $expr instanceof Node\Expr\NullsafeMethodCall + || $expr instanceof Node\Expr\MethodCall + || $expr instanceof Node\Expr\StaticCall + ) { + // handled by *WithoutSideEffectsRule rules + return []; + } + + if ( + $expr instanceof Node\Expr\Assign + || $expr instanceof Node\Expr\AssignOp + || $expr instanceof Node\Expr\AssignRef + ) { + return []; + } + + if ($expr instanceof Node\Expr\Closure) { + return []; + } + + $exprString = $this->exprPrinter->printExpr($expr); + $exprStringLines = preg_split('~\R~', $exprString, 2); + if ($exprStringLines !== false && count($exprStringLines) > 1) { + $exprString = $exprStringLines[0] . '…'; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Expression "%s" on a separate line does not do anything.', + $exprString, + ))->line($expr->getStartLine()) + ->identifier('expr.resultUnused') + ->build(), + ]; + } + +} diff --git a/src/Rules/DeadCode/PossiblyPureFuncCallCollector.php b/src/Rules/DeadCode/PossiblyPureFuncCallCollector.php new file mode 100644 index 00000000..b1f31ff6 --- /dev/null +++ b/src/Rules/DeadCode/PossiblyPureFuncCallCollector.php @@ -0,0 +1,51 @@ + + */ +final class PossiblyPureFuncCallCollector implements Collector +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Expression::class; + } + + public function processNode(Node $node, Scope $scope) + { + if (!$node->expr instanceof Node\Expr\FuncCall) { + return null; + } + if (!$node->expr->name instanceof Node\Name) { + return null; + } + + if (!$this->reflectionProvider->hasFunction($node->expr->name, $scope)) { + return null; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->expr->name, $scope); + if (!$functionReflection->isPure()->maybe()) { + return null; + } + if (!$functionReflection->hasSideEffects()->maybe()) { + return null; + } + + return [$functionReflection->getName(), $node->getStartLine()]; + } + +} diff --git a/src/Rules/DeadCode/PossiblyPureMethodCallCollector.php b/src/Rules/DeadCode/PossiblyPureMethodCallCollector.php new file mode 100644 index 00000000..a0adce06 --- /dev/null +++ b/src/Rules/DeadCode/PossiblyPureMethodCallCollector.php @@ -0,0 +1,75 @@ +, string, int}> + */ +final class PossiblyPureMethodCallCollector implements Collector +{ + + public function __construct() + { + } + + public function getNodeType(): string + { + return Expression::class; + } + + public function processNode(Node $node, Scope $scope) + { + if (!$node->expr instanceof Node\Expr\MethodCall) { + return null; + } + if (!$node->expr->name instanceof Node\Identifier) { + return null; + } + + $methodName = $node->expr->name->toString(); + $calledOnType = $scope->getType($node->expr->var); + if (!$calledOnType->hasMethod($methodName)->yes()) { + return null; + } + + $classNames = []; + $methodReflection = null; + foreach ($calledOnType->getObjectClassReflections() as $classReflection) { + if (!$classReflection->hasMethod($methodName)) { + return null; + } + + $methodReflection = $classReflection->getMethod($methodName, $scope); + if ( + !$methodReflection->isPrivate() + && !$methodReflection->isFinal()->yes() + && !$methodReflection->getDeclaringClass()->isFinal() + ) { + if (!$classReflection->isFinal()) { + return null; + } + } + if (!$methodReflection->isPure()->maybe()) { + return null; + } + if (!$methodReflection->hasSideEffects()->maybe()) { + return null; + } + + $classNames[] = $methodReflection->getDeclaringClass()->getName(); + } + + if ($methodReflection === null) { + return null; + } + + return [$classNames, $methodReflection->getName(), $node->getStartLine()]; + } + +} diff --git a/src/Rules/DeadCode/PossiblyPureNewCollector.php b/src/Rules/DeadCode/PossiblyPureNewCollector.php new file mode 100644 index 00000000..7efa11c5 --- /dev/null +++ b/src/Rules/DeadCode/PossiblyPureNewCollector.php @@ -0,0 +1,61 @@ + + */ +final class PossiblyPureNewCollector implements Collector +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Expression::class; + } + + public function processNode(Node $node, Scope $scope) + { + if (!$node->expr instanceof Node\Expr\New_) { + return null; + } + + if (!$node->expr->class instanceof Node\Name) { + return null; + } + + $className = $node->expr->class->toString(); + + if (!$this->reflectionProvider->hasClass($className)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$classReflection->hasConstructor()) { + return null; + } + + $constructor = $classReflection->getConstructor(); + if (strtolower($constructor->getName()) !== '__construct') { + return null; + } + + if (!$constructor->isPure()->maybe()) { + return null; + } + + return [$constructor->getDeclaringClass()->getName(), $node->getStartLine()]; + } + +} diff --git a/src/Rules/DeadCode/PossiblyPureStaticCallCollector.php b/src/Rules/DeadCode/PossiblyPureStaticCallCollector.php new file mode 100644 index 00000000..68be003d --- /dev/null +++ b/src/Rules/DeadCode/PossiblyPureStaticCallCollector.php @@ -0,0 +1,56 @@ + + */ +final class PossiblyPureStaticCallCollector implements Collector +{ + + public function __construct() + { + } + + public function getNodeType(): string + { + return Expression::class; + } + + public function processNode(Node $node, Scope $scope) + { + if (!$node->expr instanceof Node\Expr\StaticCall) { + return null; + } + if (!$node->expr->name instanceof Node\Identifier) { + return null; + } + + if (!$node->expr->class instanceof Node\Name) { + return null; + } + + $methodName = $node->expr->name->toString(); + $calledOnType = $scope->resolveTypeByName($node->expr->class); + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + + if ($methodReflection === null) { + return null; + } + if (!$methodReflection->isPure()->maybe()) { + return null; + } + if (!$methodReflection->hasSideEffects()->maybe()) { + return null; + } + + return [$methodReflection->getDeclaringClass()->getName(), $methodReflection->getName(), $node->getStartLine()]; + } + +} diff --git a/src/Rules/DeadCode/UnreachableStatementRule.php b/src/Rules/DeadCode/UnreachableStatementRule.php new file mode 100644 index 00000000..aa5df90e --- /dev/null +++ b/src/Rules/DeadCode/UnreachableStatementRule.php @@ -0,0 +1,32 @@ + + */ +final class UnreachableStatementRule implements Rule +{ + + public function getNodeType(): string + { + return UnreachableStatementNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message('Unreachable statement - code above always terminates.') + ->identifier('deadCode.unreachable') + ->build(), + ]; + } + +} diff --git a/src/Rules/DeadCode/UnusedPrivateConstantRule.php b/src/Rules/DeadCode/UnusedPrivateConstantRule.php new file mode 100644 index 00000000..9f7dd21f --- /dev/null +++ b/src/Rules/DeadCode/UnusedPrivateConstantRule.php @@ -0,0 +1,107 @@ + + */ +final class UnusedPrivateConstantRule implements Rule +{ + + public function __construct(private AlwaysUsedClassConstantsExtensionProvider $extensionProvider) + { + } + + public function getNodeType(): string + { + return ClassConstantsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getClass() instanceof Node\Stmt\Class_ && !$node->getClass() instanceof Node\Stmt\Enum_) { + return []; + } + + $classReflection = $node->getClassReflection(); + $classType = new ObjectType($classReflection->getName(), null, $classReflection); + + $constants = []; + foreach ($node->getConstants() as $constant) { + if (!$constant->isPrivate()) { + continue; + } + + foreach ($constant->consts as $const) { + $constantName = $const->name->toString(); + + $constantReflection = $classReflection->getConstant($constantName); + foreach ($this->extensionProvider->getExtensions() as $extension) { + if ($extension->isAlwaysUsed($constantReflection)) { + continue 2; + } + } + + $constants[$constantName] = $const; + } + } + + foreach ($node->getFetches() as $fetch) { + $fetchNode = $fetch->getNode(); + + $fetchScope = $fetch->getScope(); + if ($fetchNode->class instanceof Node\Name) { + $fetchedOnClass = $fetchScope->resolveTypeByName($fetchNode->class); + } else { + $fetchedOnClass = $fetchScope->getType($fetchNode->class); + } + + if (!$fetchNode->name instanceof Node\Identifier) { + if (!$classType->isSuperTypeOf($fetchedOnClass)->no()) { + $constants = []; + break; + } + continue; + } + + $constantReflection = $fetchScope->getConstantReflection($fetchedOnClass, $fetchNode->name->toString()); + if ($constantReflection === null) { + if (!$classType->isSuperTypeOf($fetchedOnClass)->no()) { + unset($constants[$fetchNode->name->toString()]); + } + continue; + } + + if ($constantReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + if (!$classType->isSuperTypeOf($fetchedOnClass)->no()) { + unset($constants[$fetchNode->name->toString()]); + } + continue; + } + + unset($constants[$fetchNode->name->toString()]); + } + + $errors = []; + foreach ($constants as $constantName => $constantNode) { + $errors[] = RuleErrorBuilder::message(sprintf('Constant %s::%s is unused.', $classReflection->getDisplayName(), $constantName)) + ->line($constantNode->getStartLine()) + ->identifier('classConstant.unused') + ->tip(sprintf('See: %s', 'https://phpstan.org/developing-extensions/always-used-class-constants')) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/UnusedPrivateMethodRule.php b/src/Rules/DeadCode/UnusedPrivateMethodRule.php new file mode 100644 index 00000000..30e300be --- /dev/null +++ b/src/Rules/DeadCode/UnusedPrivateMethodRule.php @@ -0,0 +1,196 @@ + + */ +final class UnusedPrivateMethodRule implements Rule +{ + + public function __construct(private AlwaysUsedMethodExtensionProvider $extensionProvider) + { + } + + public function getNodeType(): string + { + return ClassMethodsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getClass() instanceof Node\Stmt\Class_ && !$node->getClass() instanceof Node\Stmt\Enum_) { + return []; + } + $classReflection = $node->getClassReflection(); + $classType = new ObjectType($classReflection->getName(), null, $classReflection); + $constructor = null; + if ($classReflection->hasConstructor()) { + $constructor = $classReflection->getConstructor(); + } + + $methods = []; + foreach ($node->getMethods() as $method) { + if (!$method->getNode()->isPrivate()) { + continue; + } + if ($method->isDeclaredInTrait()) { + continue; + } + $methodName = $method->getNode()->name->toString(); + if ($constructor !== null && $constructor->getName() === $methodName) { + continue; + } + if (strtolower($methodName) === '__clone') { + continue; + } + + $methodReflection = $classReflection->getNativeMethod($methodName); + foreach ($this->extensionProvider->getExtensions() as $extension) { + if ($extension->isAlwaysUsed($methodReflection)) { + continue 2; + } + } + + $methods[strtolower($methodName)] = $method; + } + + $arrayCalls = []; + foreach ($node->getMethodCalls() as $methodCall) { + $methodCallNode = $methodCall->getNode(); + if ($methodCallNode instanceof Node\Expr\Array_) { + $arrayCalls[] = $methodCall; + continue; + } + $callScope = $methodCall->getScope(); + if ($methodCallNode->name instanceof Identifier) { + $methodNames = [$methodCallNode->name->toString()]; + } else { + $methodNameType = $callScope->getType($methodCallNode->name); + $strings = $methodNameType->getConstantStrings(); + if (count($strings) === 0) { + // handle subtractions of a dynamic method call + foreach ($methods as $lowerMethodName => $method) { + if ((new ConstantStringType($method->getNode()->name->toString()))->isSuperTypeOf($methodNameType)->no()) { + continue; + } + + unset($methods[$lowerMethodName]); + } + + continue; + } + + $methodNames = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $strings); + } + + if ($methodCallNode instanceof Node\Expr\MethodCall) { + $calledOnType = $callScope->getType($methodCallNode->var); + } else { + if ($methodCallNode->class instanceof Node\Name) { + $calledOnType = $callScope->resolveTypeByName($methodCallNode->class); + } else { + $calledOnType = $callScope->getType($methodCallNode->class); + } + } + + $inMethod = $callScope->getFunction(); + if (!$inMethod instanceof MethodReflection) { + continue; + } + + foreach ($methodNames as $methodName) { + $methodReflection = $callScope->getMethodReflection($calledOnType, $methodName); + if ($methodReflection === null) { + if (!$classType->isSuperTypeOf($calledOnType)->no()) { + unset($methods[strtolower($methodName)]); + } + continue; + } + if ($methodReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + if (!$classType->isSuperTypeOf($calledOnType)->no()) { + unset($methods[strtolower($methodName)]); + } + continue; + } + if ($inMethod->getName() === $methodName) { + continue; + } + unset($methods[strtolower($methodName)]); + } + } + + if (count($methods) > 0) { + foreach ($arrayCalls as $arrayCall) { + /** @var Node\Expr\Array_ $array */ + $array = $arrayCall->getNode(); + $arrayScope = $arrayCall->getScope(); + $arrayType = $arrayScope->getType($array); + if (!$arrayType->isCallable()->yes()) { + continue; + } + foreach ($arrayType->getConstantArrays() as $constantArray) { + foreach ($constantArray->findTypeAndMethodNames() as $typeAndMethod) { + if ($typeAndMethod->isUnknown()) { + return []; + } + if (!$typeAndMethod->getCertainty()->yes()) { + return []; + } + + $calledOnType = $typeAndMethod->getType(); + $methodReflection = $arrayScope->getMethodReflection($calledOnType, $typeAndMethod->getMethod()); + if ($methodReflection === null) { + continue; + } + + if ($methodReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + + $inMethod = $arrayScope->getFunction(); + if (!$inMethod instanceof MethodReflection) { + continue; + } + if ($inMethod->getName() === $typeAndMethod->getMethod()) { + continue; + } + unset($methods[strtolower($typeAndMethod->getMethod())]); + } + } + } + } + + $errors = []; + foreach ($methods as $method) { + $originalMethodName = $method->getNode()->name->toString(); + $methodType = 'Method'; + if ($method->getNode()->isStatic()) { + $methodType = 'Static method'; + } + $errors[] = RuleErrorBuilder::message(sprintf('%s %s::%s() is unused.', $methodType, $classReflection->getDisplayName(), $originalMethodName)) + ->line($method->getNode()->getStartLine()) + ->identifier('method.unused') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php new file mode 100644 index 00000000..b22bf4fb --- /dev/null +++ b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php @@ -0,0 +1,240 @@ + + */ +final class UnusedPrivatePropertyRule implements Rule +{ + + /** + * @param string[] $alwaysWrittenTags + * @param string[] $alwaysReadTags + */ + public function __construct( + private ReadWritePropertiesExtensionProvider $extensionProvider, + private array $alwaysWrittenTags, + private array $alwaysReadTags, + private bool $checkUninitializedProperties, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getClass() instanceof Node\Stmt\Class_) { + return []; + } + $classReflection = $node->getClassReflection(); + $classType = new ObjectType($classReflection->getName(), null, $classReflection); + $properties = []; + foreach ($node->getProperties() as $property) { + if (!$property->isPrivate()) { + continue; + } + if ($property->isDeclaredInTrait()) { + continue; + } + + $alwaysRead = false; + $alwaysWritten = false; + if ($property->getPhpDoc() !== null) { + $text = $property->getPhpDoc(); + foreach ($this->alwaysReadTags as $tag) { + if (!str_contains($text, $tag)) { + continue; + } + + $alwaysRead = true; + break; + } + + foreach ($this->alwaysWrittenTags as $tag) { + if (!str_contains($text, $tag)) { + continue; + } + + $alwaysWritten = true; + break; + } + } + + $propertyName = $property->getName(); + if (!$alwaysRead || !$alwaysWritten) { + if (!$classReflection->hasNativeProperty($propertyName)) { + continue; + } + + $propertyReflection = $classReflection->getNativeProperty($propertyName); + + foreach ($this->extensionProvider->getExtensions() as $extension) { + if ($alwaysRead && $alwaysWritten) { + break; + } + if (!$alwaysRead && $extension->isAlwaysRead($propertyReflection, $propertyName)) { + $alwaysRead = true; + } + if ($alwaysWritten || !$extension->isAlwaysWritten($propertyReflection, $propertyName)) { + continue; + } + + $alwaysWritten = true; + } + } + + $read = $alwaysRead; + $written = $alwaysWritten || $property->getDefault() !== null; + $properties[$propertyName] = [ + 'read' => $read, + 'written' => $written, + 'node' => $property, + ]; + } + + foreach ($node->getPropertyUsages() as $usage) { + $usageScope = $usage->getScope(); + $fetch = $usage->getFetch(); + if ($fetch->name instanceof Node\Identifier) { + $propertyName = $fetch->name->toString(); + $propertyNames = [$propertyName]; + if ( + $usageScope->getFunction() !== null + && $fetch instanceof Node\Expr\PropertyFetch + && $fetch->var instanceof Node\Expr\Variable + && is_string($fetch->var->name) + && $fetch->var->name === 'this' + ) { + $methodReflection = $usageScope->getFunction(); + if ( + $methodReflection instanceof PhpMethodFromParserNodeReflection + && $methodReflection->isPropertyHook() + && $methodReflection->getHookedPropertyName() === $propertyName + ) { + continue; + } + } + } else { + $propertyNameType = $usageScope->getType($fetch->name); + $strings = $propertyNameType->getConstantStrings(); + if (count($strings) === 0) { + // handle subtractions of a dynamic property fetch + foreach ($properties as $propertyName => $data) { + if ((new ConstantStringType($propertyName))->isSuperTypeOf($propertyNameType)->no()) { + continue; + } + + unset($properties[$propertyName]); + } + + continue; + } + + $propertyNames = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $strings); + } + + if ($fetch instanceof Node\Expr\PropertyFetch) { + $fetchedOnType = $usageScope->getType($fetch->var); + } else { + if ($fetch->class instanceof Node\Name) { + $fetchedOnType = $usageScope->resolveTypeByName($fetch->class); + } else { + $fetchedOnType = $usageScope->getType($fetch->class); + } + } + + foreach ($propertyNames as $propertyName) { + if (!array_key_exists($propertyName, $properties)) { + continue; + } + $propertyReflection = $usageScope->getPropertyReflection($fetchedOnType, $propertyName); + if ($propertyReflection === null) { + if (!$classType->isSuperTypeOf($fetchedOnType)->no()) { + if ($usage instanceof PropertyRead) { + $properties[$propertyName]['read'] = true; + } else { + $properties[$propertyName]['written'] = true; + } + } + continue; + } + if ($propertyReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + if (!$classType->isSuperTypeOf($fetchedOnType)->no()) { + if ($usage instanceof PropertyRead) { + $properties[$propertyName]['read'] = true; + } else { + $properties[$propertyName]['written'] = true; + } + } + continue; + } + + if ($usage instanceof PropertyRead) { + $properties[$propertyName]['read'] = true; + } else { + $properties[$propertyName]['written'] = true; + } + } + } + + [$uninitializedProperties] = $node->getUninitializedProperties($scope, []); + + $errors = []; + foreach ($properties as $name => $data) { + $propertyNode = $data['node']; + if ($propertyNode->isStatic()) { + $propertyName = sprintf('Static property %s::$%s', $classReflection->getDisplayName(), $name); + } else { + $propertyName = sprintf('Property %s::$%s', $classReflection->getDisplayName(), $name); + } + $tip = sprintf('See: %s', 'https://phpstan.org/developing-extensions/always-read-written-properties'); + if (!$data['read']) { + if (!$data['written']) { + $errors[] = RuleErrorBuilder::message(sprintf('%s is unused.', $propertyName)) + ->line($propertyNode->getStartLine()) + ->tip($tip) + ->identifier('property.unused') + ->build(); + } else { + $errors[] = RuleErrorBuilder::message(sprintf('%s is never read, only written.', $propertyName)) + ->line($propertyNode->getStartLine()) + ->identifier('property.onlyWritten') + ->tip($tip) + ->build(); + } + } elseif (!$data['written'] && (!array_key_exists($name, $uninitializedProperties) || !$this->checkUninitializedProperties)) { + $errors[] = RuleErrorBuilder::message(sprintf('%s is never written, only read.', $propertyName)) + ->line($propertyNode->getStartLine()) + ->identifier('property.onlyRead') + ->tip($tip) + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Debug/DebugScopeRule.php b/src/Rules/Debug/DebugScopeRule.php new file mode 100644 index 00000000..19d7144b --- /dev/null +++ b/src/Rules/Debug/DebugScopeRule.php @@ -0,0 +1,67 @@ + + */ +final class DebugScopeRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + $functionName = $this->reflectionProvider->resolveFunctionName($node->name, $scope); + if ($functionName === null) { + return []; + } + + if (strtolower($functionName) !== 'phpstan\debugscope') { + return []; + } + + if (!$scope instanceof MutatingScope) { + return []; + } + + $parts = []; + foreach ($scope->debug() as $key => $row) { + $parts[] = sprintf('%s: %s', $key, $row); + } + + if (count($parts) === 0) { + $parts[] = 'Scope is empty'; + } + + return [ + RuleErrorBuilder::message( + implode("\n", $parts), + )->nonIgnorable()->identifier('phpstan.debugScope')->build(), + ]; + } + +} diff --git a/src/Rules/Debug/DumpPhpDocTypeRule.php b/src/Rules/Debug/DumpPhpDocTypeRule.php new file mode 100644 index 00000000..ff550638 --- /dev/null +++ b/src/Rules/Debug/DumpPhpDocTypeRule.php @@ -0,0 +1,60 @@ + + */ +final class DumpPhpDocTypeRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider, private Printer $printer) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + $functionName = $this->reflectionProvider->resolveFunctionName($node->name, $scope); + if ($functionName === null) { + return []; + } + + if (strtolower($functionName) !== 'phpstan\dumpphpdoctype') { + return []; + } + + if (count($node->getArgs()) === 0) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Dumped type: %s', + $this->printer->print($scope->getType($node->getArgs()[0]->value)->toPhpDocNode()), + ), + )->nonIgnorable()->identifier('phpstan.dumpPhpDocType')->build(), + ]; + } + +} diff --git a/src/Rules/Debug/DumpTypeRule.php b/src/Rules/Debug/DumpTypeRule.php new file mode 100644 index 00000000..36e1e645 --- /dev/null +++ b/src/Rules/Debug/DumpTypeRule.php @@ -0,0 +1,60 @@ + + */ +final class DumpTypeRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + $functionName = $this->reflectionProvider->resolveFunctionName($node->name, $scope); + if ($functionName === null) { + return []; + } + + if (strtolower($functionName) !== 'phpstan\dumptype') { + return []; + } + + if (count($node->getArgs()) === 0) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Dumped type: %s', + $scope->getType($node->getArgs()[0]->value)->describe(VerbosityLevel::precise()), + ), + )->nonIgnorable()->identifier('phpstan.dumpType')->build(), + ]; + } + +} diff --git a/src/Rules/Debug/FileAssertRule.php b/src/Rules/Debug/FileAssertRule.php new file mode 100644 index 00000000..0d62430d --- /dev/null +++ b/src/Rules/Debug/FileAssertRule.php @@ -0,0 +1,203 @@ + + */ +final class FileAssertRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $function = $this->reflectionProvider->getFunction($node->name, $scope); + if ($function->getName() === 'PHPStan\\Testing\\assertType') { + return $this->processAssertType($node->getArgs(), $scope); + } + + if ($function->getName() === 'PHPStan\\Testing\\assertNativeType') { + return $this->processAssertNativeType($node->getArgs(), $scope); + } + + if ($function->getName() === 'PHPStan\\Testing\\assertVariableCertainty') { + return $this->processAssertVariableCertainty($node->getArgs(), $scope); + } + + return []; + } + + /** + * @param Node\Arg[] $args + * @return list + */ + private function processAssertType(array $args, Scope $scope): array + { + if (count($args) !== 2) { + return []; + } + + $expectedTypeStrings = $scope->getType($args[0]->value)->getConstantStrings(); + if (count($expectedTypeStrings) !== 1) { + return [ + RuleErrorBuilder::message('Expected type must be a literal string.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + $expressionType = $scope->getType($args[1]->value)->describe(VerbosityLevel::precise()); + if ($expectedTypeStrings[0]->getValue() === $expressionType) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Expected type %s, actual: %s', $expectedTypeStrings[0]->getValue(), $expressionType)) + ->nonIgnorable() + ->identifier('phpstan.type') + ->build(), + ]; + } + + /** + * @param Node\Arg[] $args + * @return list + */ + private function processAssertNativeType(array $args, Scope $scope): array + { + if (count($args) !== 2) { + return []; + } + + $expectedTypeStrings = $scope->getNativeType($args[0]->value)->getConstantStrings(); + if (count($expectedTypeStrings) !== 1) { + return [ + RuleErrorBuilder::message('Expected native type must be a literal string.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + $expressionType = $scope->getNativeType($args[1]->value)->describe(VerbosityLevel::precise()); + if ($expectedTypeStrings[0]->getValue() === $expressionType) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Expected native type %s, actual: %s', $expectedTypeStrings[0]->getValue(), $expressionType)) + ->nonIgnorable() + ->identifier('phpstan.nativeType') + ->build(), + ]; + } + + /** + * @param Node\Arg[] $args + * @return list + */ + private function processAssertVariableCertainty(array $args, Scope $scope): array + { + if (count($args) !== 2) { + return []; + } + + $certainty = $args[0]->value; + if (!$certainty instanceof StaticCall) { + return [ + RuleErrorBuilder::message('First argument of %s() must be TrinaryLogic call') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + if (!$certainty->class instanceof Node\Name) { + return [ + RuleErrorBuilder::message('Invalid TrinaryLogic call.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + if ($certainty->class->toString() !== 'PHPStan\\TrinaryLogic') { + return [ + RuleErrorBuilder::message('Invalid TrinaryLogic call.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + if (!$certainty->name instanceof Node\Identifier) { + return [ + RuleErrorBuilder::message('Invalid TrinaryLogic call.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + // @phpstan-ignore staticMethod.dynamicName + $expectedCertaintyValue = TrinaryLogic::{$certainty->name->toString()}(); + $variable = $args[1]->value; + if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) { + $actualCertaintyValue = $scope->hasVariableType($variable->name); + $variableDescription = sprintf('variable $%s', $variable->name); + } elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) { + $offset = $scope->getType($variable->dim); + $actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset); + $variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise())); + } else { + return [ + RuleErrorBuilder::message('Invalid assertVariableCertainty call.') + ->nonIgnorable() + ->identifier('phpstan.unknownExpectation') + ->build(), + ]; + } + + if ($expectedCertaintyValue->equals($actualCertaintyValue)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Expected %s certainty %s, actual: %s', $variableDescription, $expectedCertaintyValue->describe(), $actualCertaintyValue->describe())) + ->nonIgnorable() + ->identifier('phpstan.variable') + ->build(), + ]; + } + +} diff --git a/src/Rules/DirectRegistry.php b/src/Rules/DirectRegistry.php new file mode 100644 index 00000000..a8ea6114 --- /dev/null +++ b/src/Rules/DirectRegistry.php @@ -0,0 +1,57 @@ +rules[$rule->getNodeType()][] = $rule; + } + } + + /** + * @template TNodeType of Node + * @param class-string $nodeType + * @return array> + */ + public function getRules(string $nodeType): array + { + if (!isset($this->cache[$nodeType])) { + $parentNodeTypes = [$nodeType] + class_parents($nodeType) + class_implements($nodeType); + + $rules = []; + foreach ($parentNodeTypes as $parentNodeType) { + foreach ($this->rules[$parentNodeType] ?? [] as $rule) { + $rules[] = $rule; + } + } + + $this->cache[$nodeType] = $rules; + } + + /** + * @var array> $selectedRules + */ + $selectedRules = $this->cache[$nodeType]; + + return $selectedRules; + } + +} diff --git a/src/Rules/EnumCases/EnumCaseAttributesRule.php b/src/Rules/EnumCases/EnumCaseAttributesRule.php new file mode 100644 index 00000000..becc81b7 --- /dev/null +++ b/src/Rules/EnumCases/EnumCaseAttributesRule.php @@ -0,0 +1,37 @@ + + */ +final class EnumCaseAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return Node\Stmt\EnumCase::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->attrGroups, + Attribute::TARGET_CLASS_CONSTANT, + 'class constant', + ); + } + +} diff --git a/src/Rules/Exceptions/CatchWithUnthrownExceptionRule.php b/src/Rules/Exceptions/CatchWithUnthrownExceptionRule.php new file mode 100644 index 00000000..b409bdd7 --- /dev/null +++ b/src/Rules/Exceptions/CatchWithUnthrownExceptionRule.php @@ -0,0 +1,70 @@ + + */ +final class CatchWithUnthrownExceptionRule implements Rule +{ + + public function __construct( + private ExceptionTypeResolver $exceptionTypeResolver, + private bool $reportUncheckedExceptionDeadCatch, + ) + { + } + + public function getNodeType(): string + { + return CatchWithUnthrownExceptionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->getCaughtType() instanceof NeverType) { + return [ + RuleErrorBuilder::message( + sprintf('Dead catch - %s is already caught above.', $node->getOriginalCaughtType()->describe(VerbosityLevel::typeOnly())), + ) + ->line($node->getStartLine()) + ->identifier('catch.alreadyCaught') + ->build(), + ]; + } + + if (!$this->reportUncheckedExceptionDeadCatch) { + $isCheckedException = false; + foreach ($node->getCaughtType()->getObjectClassNames() as $objectClassName) { + if ($this->exceptionTypeResolver->isCheckedException($objectClassName, $scope)) { + $isCheckedException = true; + break; + } + } + + if (!$isCheckedException) { + return []; + } + } + + return [ + RuleErrorBuilder::message( + sprintf('Dead catch - %s is never thrown in the try block.', $node->getCaughtType()->describe(VerbosityLevel::typeOnly())), + ) + ->line($node->getStartLine()) + ->identifier('catch.neverThrown') + ->build(), + ]; + } + +} diff --git a/src/Rules/Exceptions/CaughtExceptionExistenceRule.php b/src/Rules/Exceptions/CaughtExceptionExistenceRule.php new file mode 100644 index 00000000..631bebf3 --- /dev/null +++ b/src/Rules/Exceptions/CaughtExceptionExistenceRule.php @@ -0,0 +1,74 @@ + + */ +final class CaughtExceptionExistenceRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private bool $checkClassCaseSensitivity, + ) + { + } + + public function getNodeType(): string + { + return Catch_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->types as $class) { + $className = (string) $class; + if (!$this->reflectionProvider->hasClass($className)) { + if ($scope->isInClassExists($className)) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf('Caught class %s not found.', $className)) + ->line($class->getStartLine()) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + continue; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$classReflection->isInterface() && !$classReflection->implementsInterface(Throwable::class)) { + $errors[] = RuleErrorBuilder::message(sprintf('Caught class %s is not an exception.', $classReflection->getDisplayName())) + ->line($class->getStartLine()) + ->identifier('catch.notThrowable') + ->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + [new ClassNameNodePair($className, $class)], + $this->checkClassCaseSensitivity, + ), + ); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/DefaultExceptionTypeResolver.php b/src/Rules/Exceptions/DefaultExceptionTypeResolver.php new file mode 100644 index 00000000..07fdba36 --- /dev/null +++ b/src/Rules/Exceptions/DefaultExceptionTypeResolver.php @@ -0,0 +1,101 @@ +uncheckedExceptionRegexes as $regex) { + if (Strings::match($className, $regex) !== null) { + return false; + } + } + + foreach ($this->uncheckedExceptionClasses as $uncheckedExceptionClass) { + if ($className === $uncheckedExceptionClass) { + return false; + } + } + + if (!$this->reflectionProvider->hasClass($className)) { + return $this->isCheckedExceptionInternal($className); + } + + $classReflection = $this->reflectionProvider->getClass($className); + foreach ($this->uncheckedExceptionClasses as $uncheckedExceptionClass) { + if ($classReflection->getName() === $uncheckedExceptionClass) { + return false; + } + + if (!$classReflection->isSubclassOf($uncheckedExceptionClass)) { + continue; + } + + return false; + } + + return $this->isCheckedExceptionInternal($className); + } + + private function isCheckedExceptionInternal(string $className): bool + { + foreach ($this->checkedExceptionRegexes as $regex) { + if (Strings::match($className, $regex) !== null) { + return true; + } + } + + foreach ($this->checkedExceptionClasses as $checkedExceptionClass) { + if ($className === $checkedExceptionClass) { + return true; + } + } + + if (!$this->reflectionProvider->hasClass($className)) { + return count($this->checkedExceptionRegexes) === 0 && count($this->checkedExceptionClasses) === 0; + } + + $classReflection = $this->reflectionProvider->getClass($className); + foreach ($this->checkedExceptionClasses as $checkedExceptionClass) { + if ($classReflection->getName() === $checkedExceptionClass) { + return true; + } + + if (!$classReflection->isSubclassOf($checkedExceptionClass)) { + continue; + } + + return true; + } + + return count($this->checkedExceptionRegexes) === 0 && count($this->checkedExceptionClasses) === 0; + } + +} diff --git a/src/Rules/Exceptions/ExceptionTypeResolver.php b/src/Rules/Exceptions/ExceptionTypeResolver.php new file mode 100644 index 00000000..a449fdae --- /dev/null +++ b/src/Rules/Exceptions/ExceptionTypeResolver.php @@ -0,0 +1,40 @@ + + */ +final class MissingCheckedExceptionInFunctionThrowsRule implements Rule +{ + + public function __construct(private MissingCheckedExceptionInThrowsCheck $check) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $functionReflection = $node->getFunctionReflection(); + + $errors = []; + foreach ($this->check->check($functionReflection->getThrowType(), $statementResult->getThrowPoints()) as [$className, $throwPointNode]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Function %s() throws checked exception %s but it\'s missing from the PHPDoc @throws tag.', + $functionReflection->getName(), + $className, + )) + ->line($throwPointNode->getStartLine()) + ->identifier('missingType.checkedException') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRule.php b/src/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRule.php new file mode 100644 index 00000000..9698f8ce --- /dev/null +++ b/src/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRule.php @@ -0,0 +1,49 @@ + + */ +final class MissingCheckedExceptionInMethodThrowsRule implements Rule +{ + + public function __construct(private MissingCheckedExceptionInThrowsCheck $check) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $methodReflection = $node->getMethodReflection(); + + $errors = []; + foreach ($this->check->check($methodReflection->getThrowType(), $statementResult->getThrowPoints()) as [$className, $throwPointNode]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() throws checked exception %s but it\'s missing from the PHPDoc @throws tag.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $className, + )) + ->line($throwPointNode->getStartLine()) + ->identifier('missingType.checkedException') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php b/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php new file mode 100644 index 00000000..c7a95f7f --- /dev/null +++ b/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php @@ -0,0 +1,56 @@ + + */ +final class MissingCheckedExceptionInPropertyHookThrowsRule implements Rule +{ + + public function __construct(private MissingCheckedExceptionInThrowsCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($this->check->check($hookReflection->getThrowType(), $statementResult->getThrowPoints()) as [$className, $throwPointNode]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s throws checked exception %s but it\'s missing from the PHPDoc @throws tag.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $className, + )) + ->line($throwPointNode->getStartLine()) + ->identifier('missingType.checkedException') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php new file mode 100644 index 00000000..9f836b57 --- /dev/null +++ b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php @@ -0,0 +1,62 @@ + + */ + public function check(?Type $throwType, array $throwPoints): array + { + if ($throwType === null) { + $throwType = new NeverType(); + } + + $classes = []; + foreach ($throwPoints as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { + continue; + } + if ($throwType->isSuperTypeOf($throwPointType)->yes()) { + continue; + } + + $isCheckedException = TrinaryLogic::createNo()->lazyOr( + $throwPointType->getObjectClassNames(), + fn (string $objectClassName) => TrinaryLogic::createFromBoolean($this->exceptionTypeResolver->isCheckedException($objectClassName, $throwPoint->getScope())), + ); + if ($isCheckedException->no()) { + continue; + } + + $classes[] = [$throwPointType->describe(VerbosityLevel::typeOnly()), $throwPoint->getNode()]; + } + } + + return $classes; + } + +} diff --git a/src/Rules/Exceptions/NoncapturingCatchRule.php b/src/Rules/Exceptions/NoncapturingCatchRule.php new file mode 100644 index 00000000..f2c4e479 --- /dev/null +++ b/src/Rules/Exceptions/NoncapturingCatchRule.php @@ -0,0 +1,43 @@ + + */ +final class NoncapturingCatchRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Catch_::class; + } + + /** + * @param Node\Stmt\Catch_ $node + */ + public function processNode(Node $node, Scope $scope): array + { + if ($scope->getPhpVersion()->supportsNoncapturingCatches()->yes()) { + return []; + } + + if ($node->var !== null) { + return []; + } + + return [ + RuleErrorBuilder::message('Non-capturing catch is supported only on PHP 8.0 and later.') + ->nonIgnorable() + ->identifier('catch.nonCapturingNotSupported') + ->build(), + ]; + } + +} diff --git a/src/Rules/Exceptions/OverwrittenExitPointByFinallyRule.php b/src/Rules/Exceptions/OverwrittenExitPointByFinallyRule.php new file mode 100644 index 00000000..c052471c --- /dev/null +++ b/src/Rules/Exceptions/OverwrittenExitPointByFinallyRule.php @@ -0,0 +1,70 @@ + + */ +final class OverwrittenExitPointByFinallyRule implements Rule +{ + + public function getNodeType(): string + { + return FinallyExitPointsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($node->getTryCatchExitPoints()) === 0) { + return []; + } + + $errors = []; + foreach ($node->getTryCatchExitPoints() as $exitPoint) { + $errors[] = RuleErrorBuilder::message(sprintf('This %s is overwritten by a different one in the finally block below.', $this->describeExitPoint($exitPoint->getStatement()))) + ->line($exitPoint->getStatement()->getStartLine()) + ->identifier('finally.exitPoint') + ->build(); + } + + foreach ($node->getFinallyExitPoints() as $exitPoint) { + $errors[] = RuleErrorBuilder::message(sprintf('The overwriting %s is on this line.', $this->describeExitPoint($exitPoint->getStatement()))) + ->line($exitPoint->getStatement()->getStartLine()) + ->identifier('finally.exitPoint') + ->build(); + } + + return $errors; + } + + private function describeExitPoint(Node\Stmt $stmt): string + { + if ($stmt instanceof Node\Stmt\Return_) { + return 'return'; + } + + if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Throw_) { + return 'throw'; + } + + if ($stmt instanceof Node\Stmt\Continue_) { + return 'continue'; + } + + if ($stmt instanceof Node\Stmt\Break_) { + return 'break'; + } + + return 'exit point'; + } + +} diff --git a/src/Rules/Exceptions/ThrowExprTypeRule.php b/src/Rules/Exceptions/ThrowExprTypeRule.php new file mode 100644 index 00000000..6a426767 --- /dev/null +++ b/src/Rules/Exceptions/ThrowExprTypeRule.php @@ -0,0 +1,63 @@ + + */ +final class ThrowExprTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Throw_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $throwableType = new ObjectType(Throwable::class); + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + 'Throwing object of an unknown class %s.', + static fn (Type $type): bool => $throwableType->isSuperTypeOf($type)->yes(), + ); + + $foundType = $typeResult->getType(); + if ($foundType instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $isSuperType = $throwableType->isSuperTypeOf($foundType); + if ($isSuperType->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Invalid type %s to throw.', + $foundType->describe(VerbosityLevel::typeOnly()), + ))->identifier('throw.notThrowable')->build(), + ]; + } + +} diff --git a/src/Rules/Exceptions/ThrowExpressionRule.php b/src/Rules/Exceptions/ThrowExpressionRule.php new file mode 100644 index 00000000..8a5889dd --- /dev/null +++ b/src/Rules/Exceptions/ThrowExpressionRule.php @@ -0,0 +1,45 @@ + + */ +final class ThrowExpressionRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\Expr\Throw_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($this->phpVersion->supportsThrowExpression()) { + return []; + } + + if ($node->getAttribute(StandaloneThrowExprVisitor::ATTRIBUTE_NAME) === true) { + return []; + } + + return [ + RuleErrorBuilder::message('Throw expression is supported only on PHP 8.0 and later.')->nonIgnorable() + ->identifier('throw.notSupported') + ->build(), + ]; + } + +} diff --git a/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php new file mode 100644 index 00000000..917842b0 --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php @@ -0,0 +1,72 @@ + + */ +final class ThrowsVoidFunctionWithExplicitThrowPointRule implements Rule +{ + + public function __construct( + private ExceptionTypeResolver $exceptionTypeResolver, + private bool $missingCheckedExceptionInThrows, + ) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $functionReflection = $node->getFunctionReflection(); + + if ($functionReflection->getThrowType() === null || !$functionReflection->getThrowType()->isVoid()->yes()) { + return []; + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + $isCheckedException = TrinaryLogic::createFromBoolean($this->missingCheckedExceptionInThrows)->lazyAnd( + $throwPointType->getObjectClassNames(), + fn (string $objectClassName) => TrinaryLogic::createFromBoolean($this->exceptionTypeResolver->isCheckedException($objectClassName, $throwPoint->getScope())), + ); + if ($isCheckedException->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Function %s() throws exception %s but the PHPDoc contains @throws void.', + $functionReflection->getName(), + $throwPointType->describe(VerbosityLevel::typeOnly()), + )) + ->line($throwPoint->getNode()->getStartLine()) + ->identifier('throws.void') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php new file mode 100644 index 00000000..dc423d03 --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php @@ -0,0 +1,73 @@ + + */ +final class ThrowsVoidMethodWithExplicitThrowPointRule implements Rule +{ + + public function __construct( + private ExceptionTypeResolver $exceptionTypeResolver, + private bool $missingCheckedExceptionInThrows, + ) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $methodReflection = $node->getMethodReflection(); + + if ($methodReflection->getThrowType() === null || !$methodReflection->getThrowType()->isVoid()->yes()) { + return []; + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + $isCheckedException = TrinaryLogic::createFromBoolean($this->missingCheckedExceptionInThrows)->lazyAnd( + $throwPointType->getObjectClassNames(), + fn (string $objectClassName) => TrinaryLogic::createFromBoolean($this->exceptionTypeResolver->isCheckedException($objectClassName, $throwPoint->getScope())), + ); + if ($isCheckedException->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() throws exception %s but the PHPDoc contains @throws void.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $throwPointType->describe(VerbosityLevel::typeOnly()), + )) + ->line($throwPoint->getNode()->getStartLine()) + ->identifier('throws.void') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php new file mode 100644 index 00000000..b9a8c1d9 --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php @@ -0,0 +1,80 @@ + + */ +final class ThrowsVoidPropertyHookWithExplicitThrowPointRule implements Rule +{ + + public function __construct( + private ExceptionTypeResolver $exceptionTypeResolver, + private bool $missingCheckedExceptionInThrows, + ) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + + if ($hookReflection->getThrowType() === null || !$hookReflection->getThrowType()->isVoid()->yes()) { + return []; + } + + if ($hookReflection->getPropertyHookName() === null) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + $isCheckedException = TrinaryLogic::createFromBoolean($this->missingCheckedExceptionInThrows)->lazyAnd( + $throwPointType->getObjectClassNames(), + fn (string $objectClassName) => TrinaryLogic::createFromBoolean($this->exceptionTypeResolver->isCheckedException($objectClassName, $throwPoint->getScope())), + ); + if ($isCheckedException->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s throws exception %s but the PHPDoc contains @throws void.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $throwPointType->describe(VerbosityLevel::typeOnly()), + )) + ->line($throwPoint->getNode()->getStartLine()) + ->identifier('throws.void') + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/TooWideFunctionThrowTypeRule.php b/src/Rules/Exceptions/TooWideFunctionThrowTypeRule.php new file mode 100644 index 00000000..1e6a233b --- /dev/null +++ b/src/Rules/Exceptions/TooWideFunctionThrowTypeRule.php @@ -0,0 +1,52 @@ + + */ +final class TooWideFunctionThrowTypeRule implements Rule +{ + + public function __construct(private TooWideThrowTypeCheck $check) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $functionReflection = $node->getFunctionReflection(); + + $throwType = $functionReflection->getThrowType(); + if ($throwType === null) { + return []; + } + + $errors = []; + foreach ($this->check->check($throwType, $statementResult->getThrowPoints()) as $throwClass) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s in PHPDoc @throws tag but it\'s not thrown.', + $functionReflection->getName(), + $throwClass, + )) + ->identifier('throws.unusedType') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/TooWideMethodThrowTypeRule.php b/src/Rules/Exceptions/TooWideMethodThrowTypeRule.php new file mode 100644 index 00000000..f65a44ff --- /dev/null +++ b/src/Rules/Exceptions/TooWideMethodThrowTypeRule.php @@ -0,0 +1,68 @@ + + */ +final class TooWideMethodThrowTypeRule implements Rule +{ + + public function __construct(private FileTypeMapper $fileTypeMapper, private TooWideThrowTypeCheck $check) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $statementResult = $node->getStatementResult(); + $methodReflection = $node->getMethodReflection(); + $classReflection = $node->getClassReflection(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $methodReflection->getName(), + $docComment->getText(), + ); + + if ($resolvedPhpDoc->getThrowsTag() === null) { + return []; + } + + $throwType = $resolvedPhpDoc->getThrowsTag()->getType(); + + $errors = []; + foreach ($this->check->check($throwType, $statementResult->getThrowPoints()) as $throwClass) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s in PHPDoc @throws tag but it\'s not thrown.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $throwClass, + )) + ->identifier('throws.unusedType') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php b/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php new file mode 100644 index 00000000..c516caa9 --- /dev/null +++ b/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php @@ -0,0 +1,75 @@ + + */ +final class TooWidePropertyHookThrowTypeRule implements Rule +{ + + public function __construct(private FileTypeMapper $fileTypeMapper, private TooWideThrowTypeCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + if ($hookReflection->getPropertyHookName() === null) { + throw new ShouldNotHappenException(); + } + + $classReflection = $node->getClassReflection(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $hookReflection->getName(), + $docComment->getText(), + ); + + if ($resolvedPhpDoc->getThrowsTag() === null) { + return []; + } + + $throwType = $resolvedPhpDoc->getThrowsTag()->getType(); + + $errors = []; + foreach ($this->check->check($throwType, $statementResult->getThrowPoints()) as $throwClass) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s has %s in PHPDoc @throws tag but it\'s not thrown.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $throwClass, + )) + ->identifier('throws.unusedType') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/TooWideThrowTypeCheck.php b/src/Rules/Exceptions/TooWideThrowTypeCheck.php new file mode 100644 index 00000000..68c7fe86 --- /dev/null +++ b/src/Rules/Exceptions/TooWideThrowTypeCheck.php @@ -0,0 +1,51 @@ +isVoid()->yes()) { + return []; + } + + $throwPointType = TypeCombinator::union(...array_map(function (ThrowPoint $throwPoint): Type { + if (!$this->implicitThrows && !$throwPoint->isExplicit()) { + return new NeverType(); + } + + return $throwPoint->getType(); + }, $throwPoints)); + + $throwClasses = []; + foreach (TypeUtils::flattenTypes($throwType) as $type) { + if (!$throwPointType instanceof NeverType && !$type->isSuperTypeOf($throwPointType)->no()) { + continue; + } + + $throwClasses[] = $type->describe(VerbosityLevel::typeOnly()); + } + + return $throwClasses; + } + +} diff --git a/src/Rules/FileRuleError.php b/src/Rules/FileRuleError.php new file mode 100644 index 00000000..9fb8fd57 --- /dev/null +++ b/src/Rules/FileRuleError.php @@ -0,0 +1,14 @@ + $unknownClassErrors + */ + public function __construct( + private Type $type, + private array $referencedClasses, + private array $unknownClassErrors, + private ?string $tip, + ) + { + } + + public function getType(): Type + { + return $this->type; + } + + /** + * @return string[] + */ + public function getReferencedClasses(): array + { + return $this->referencedClasses; + } + + /** + * @return list + */ + public function getUnknownClassErrors(): array + { + return $this->unknownClassErrors; + } + + public function getTip(): ?string + { + return $this->tip; + } + +} diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php new file mode 100644 index 00000000..30501d2d --- /dev/null +++ b/src/Rules/FunctionCallParametersCheck.php @@ -0,0 +1,659 @@ + + */ + public function check( + ParametersAcceptor $parametersAcceptor, + Scope $scope, + bool $isBuiltin, + Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall, + string $nodeType, + TrinaryLogic $acceptsNamedArguments, + string $singleInsufficientParameterMessage, + string $pluralInsufficientParametersMessage, + string $singleInsufficientParameterInVariadicFunctionMessage, + string $pluralInsufficientParametersInVariadicFunctionMessage, + string $singleInsufficientParameterWithOptionalParametersMessage, + string $pluralInsufficientParametersWithOptionalParametersMessage, + string $wrongArgumentTypeMessage, + string $voidReturnTypeUsed, + string $parameterPassedByReferenceMessage, + string $unresolvableTemplateTypeMessage, + string $missingParameterMessage, + string $unknownParameterMessage, + string $unresolvableReturnTypeMessage, + string $unresolvableParameterTypeMessage, + string $namedArgumentMessage, + ): array + { + $functionParametersMinCount = 0; + $functionParametersMaxCount = 0; + foreach ($parametersAcceptor->getParameters() as $parameter) { + if (!$parameter->isOptional()) { + $functionParametersMinCount++; + } + + $functionParametersMaxCount++; + } + + if ($parametersAcceptor->isVariadic()) { + $functionParametersMaxCount = -1; + } + + /** @var array $arguments */ + $arguments = []; + /** @var array $args */ + $args = $funcCall->getArgs(); + $hasNamedArguments = false; + $hasUnpackedArgument = false; + $errors = []; + foreach ($args as $arg) { + $argumentName = null; + if ($arg->name !== null) { + $hasNamedArguments = true; + $argumentName = $arg->name->toString(); + } + + if ($hasNamedArguments && $arg->unpack) { + $errors[] = RuleErrorBuilder::message('Named argument cannot be followed by an unpacked (...) argument.') + ->identifier('argument.unpackAfterNamed') + ->line($arg->getStartLine()) + ->nonIgnorable() + ->build(); + } + if ($hasUnpackedArgument && !$arg->unpack) { + if ($argumentName === null || !$scope->getPhpVersion()->supportsNamedArgumentAfterUnpackedArgument()->yes()) { + $errors[] = RuleErrorBuilder::message('Unpacked argument (...) cannot be followed by a non-unpacked argument.') + ->identifier('argument.nonUnpackAfterUnpacked') + ->line($arg->getStartLine()) + ->nonIgnorable() + ->build(); + } + } + if ($arg->unpack) { + $hasUnpackedArgument = true; + } + if ($arg->unpack) { + $type = $scope->getType($arg->value); + $arrays = $type->getConstantArrays(); + if (count($arrays) > 0) { + $maxKeys = null; + foreach ($arrays as $array) { + $countType = $array->getArraySize(); + if ($countType instanceof ConstantIntegerType) { + $keysCount = $countType->getValue(); + } elseif ($countType instanceof IntegerRangeType) { + $keysCount = $countType->getMax(); + if ($keysCount === null) { + throw new ShouldNotHappenException(); + } + } else { + throw new ShouldNotHappenException(); + } + if ($maxKeys !== null && $keysCount >= $maxKeys) { + continue; + } + + $maxKeys = $keysCount; + } + + for ($j = 0; $j < $maxKeys; $j++) { + $types = []; + $commonKey = null; + $isOptionalKey = false; + foreach ($arrays as $constantArray) { + $isOptionalKey = in_array($j, $constantArray->getOptionalKeys(), true); + $types[] = $constantArray->getValueTypes()[$j]; + $keyType = $constantArray->getKeyTypes()[$j]; + if ($commonKey === null) { + $commonKey = $keyType->getValue(); + } elseif ($commonKey !== $keyType->getValue()) { + $commonKey = false; + } + } + $keyArgumentName = null; + if (is_string($commonKey)) { + $keyArgumentName = $commonKey; + $hasNamedArguments = true; + } + if ($isOptionalKey) { + continue; + } + + $arguments[] = [ + $arg->value, + TypeCombinator::union(...$types), + false, + $keyArgumentName, + $arg->getStartLine(), + ]; + } + } else { + $arguments[] = [ + $arg->value, + $type->getIterableValueType(), + true, + null, + $arg->getStartLine(), + ]; + } + continue; + } + + $arguments[] = [ + $arg->value, + null, + false, + $argumentName, + $arg->getStartLine(), + ]; + } + + if ($hasNamedArguments && !$scope->getPhpVersion()->supportsNamedArguments()->yes() && !(bool) $funcCall->getAttribute('isAttribute', false)) { + $errors[] = RuleErrorBuilder::message('Named arguments are supported only on PHP 8.0 and later.') + ->identifier('argument.namedNotSupported') + ->line($funcCall->getStartLine()) + ->nonIgnorable() + ->build(); + } + + if (!$hasNamedArguments) { + $invokedParametersCount = count($arguments); + foreach ($arguments as [$argumentValue, $argumentValueType, $unpack, $argumentName]) { + if ($unpack) { + $invokedParametersCount = max($functionParametersMinCount, $functionParametersMaxCount); + break; + } + } + + if ( + $invokedParametersCount < $functionParametersMinCount + || ($this->checkExtraArguments && $invokedParametersCount > $functionParametersMaxCount) + ) { + if ($functionParametersMinCount === $functionParametersMaxCount) { + $errors[] = RuleErrorBuilder::message(sprintf( + $invokedParametersCount === 1 ? $singleInsufficientParameterMessage : $pluralInsufficientParametersMessage, + $invokedParametersCount, + $functionParametersMinCount, + )) + ->identifier('arguments.count') + ->line($funcCall->getStartLine()) + ->build(); + } elseif ($functionParametersMaxCount === -1 && $invokedParametersCount < $functionParametersMinCount) { + $errors[] = RuleErrorBuilder::message(sprintf( + $invokedParametersCount === 1 ? $singleInsufficientParameterInVariadicFunctionMessage : $pluralInsufficientParametersInVariadicFunctionMessage, + $invokedParametersCount, + $functionParametersMinCount, + )) + ->identifier('arguments.count') + ->line($funcCall->getStartLine()) + ->build(); + } elseif ($functionParametersMaxCount !== -1) { + $errors[] = RuleErrorBuilder::message(sprintf( + $invokedParametersCount === 1 ? $singleInsufficientParameterWithOptionalParametersMessage : $pluralInsufficientParametersWithOptionalParametersMessage, + $invokedParametersCount, + $functionParametersMinCount, + $functionParametersMaxCount, + )) + ->identifier('arguments.count') + ->line($funcCall->getStartLine()) + ->build(); + } + } + } + + if ( + !$funcCall instanceof Node\Expr\New_ + && !$scope->isInFirstLevelStatement() + && $scope->getKeepVoidType($funcCall)->isVoid()->yes() + ) { + $errors[] = RuleErrorBuilder::message($voidReturnTypeUsed) + ->identifier(sprintf('%s.void', $nodeType)) + ->line($funcCall->getStartLine()) + ->build(); + } + + [$addedErrors, $argumentsWithParameters] = $this->processArguments($parametersAcceptor, $funcCall->getStartLine(), $isBuiltin, $arguments, $hasNamedArguments, $missingParameterMessage, $unknownParameterMessage); + foreach ($addedErrors as $error) { + $errors[] = $error; + } + + if (!$this->checkArgumentTypes && !$this->checkArgumentsPassedByReference) { + return $errors; + } + + foreach ($argumentsWithParameters as $i => [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, $parameter, $originalParameter]) { + if ($this->checkArgumentTypes && $unpack) { + $iterableTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $argumentValue, + '', + static fn (Type $type): bool => $type->isIterable()->yes(), + ); + $iterableTypeResultType = $iterableTypeResult->getType(); + if ( + !$iterableTypeResultType instanceof ErrorType + && !$iterableTypeResultType->isIterable()->yes() + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Only iterables can be unpacked, %s given in argument #%d.', + $iterableTypeResultType->describe(VerbosityLevel::typeOnly()), + $i + 1, + ))->identifier('argument.unpackNonIterable')->line($argumentLine)->build(); + } + } + + if ($parameter === null) { + continue; + } + + if ($argumentValueType === null) { + if ($scope instanceof MutatingScope) { + $scope = $scope->pushInFunctionCall(null, $parameter); + } + $argumentValueType = $scope->getType($argumentValue); + + if ($scope instanceof MutatingScope) { + $scope = $scope->popInFunctionCall(); + } + } + + if (!$acceptsNamedArguments->yes()) { + if ($argumentName !== null) { + $errors[] = RuleErrorBuilder::message(sprintf($namedArgumentMessage, sprintf('named argument $%s', $argumentName))) + ->identifier('argument.named') + ->line($argumentLine) + ->build(); + } elseif ($unpack) { + $unpackedArrayType = $scope->getType($argumentValue); + $hasStringKey = $unpackedArrayType->getIterableKeyType()->isString(); + if (!$hasStringKey->no()) { + $errors[] = RuleErrorBuilder::message(sprintf($namedArgumentMessage, sprintf('unpacked array with %s', $hasStringKey->yes() ? 'string key' : 'possibly string key'))) + ->identifier('argument.named') + ->line($argumentLine) + ->build(); + } + } + } + + if ($this->checkArgumentTypes) { + $parameterType = TypeUtils::resolveLateResolvableTypes($parameter->getType()); + + if ( + !$parameter->passedByReference()->createsNewVariable() + || (!$isBuiltin && !$argumentValueType instanceof ErrorType) + ) { + $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $scope->isDeclareStrictTypes()); + + if (!$accepts->result) { + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $argumentValueType); + $errors[] = RuleErrorBuilder::message(sprintf( + $wrongArgumentTypeMessage, + $this->describeParameter($parameter, $argumentName ?? $i + 1), + $parameterType->describe($verbosityLevel), + $argumentValueType->describe($verbosityLevel), + )) + ->identifier('argument.type') + ->line($argumentLine) + ->acceptsReasonsTip($accepts->reasons) + ->build(); + } + } + + if ( + $originalParameter !== null + && !$this->unresolvableTypeHelper->containsUnresolvableType($originalParameter->getType()) + && $this->unresolvableTypeHelper->containsUnresolvableType($parameterType) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + $unresolvableParameterTypeMessage, + $this->describeParameter($parameter, $argumentName === null ? $i + 1 : null), + ))->identifier('argument.unresolvableType')->line($argumentLine)->build(); + } + + if ( + $parameter instanceof ExtendedParameterReflection + && $parameter->getClosureThisType() !== null + && ($argumentValue instanceof Expr\Closure || $argumentValue instanceof Expr\ArrowFunction) + && $argumentValue->static + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + $wrongArgumentTypeMessage, + $this->describeParameter($parameter, $argumentName === null ? $i + 1 : null), + 'bindable closure', + 'static closure', + )) + ->identifier('argument.staticClosure') + ->line($argumentLine) + ->build(); + } + } + + if ( + !$this->checkArgumentsPassedByReference + || !$parameter->passedByReference()->yes() + ) { + continue; + } + + if ($this->nullsafeCheck->containsNullSafe($argumentValue)) { + $errors[] = RuleErrorBuilder::message(sprintf( + $parameterPassedByReferenceMessage, + $this->describeParameter($parameter, $argumentName === null ? $i + 1 : null), + )) + ->identifier('argument.byRef') + ->line($argumentLine) + ->build(); + continue; + } + + if ( + $argumentValue instanceof Node\Expr\PropertyFetch + || $argumentValue instanceof Node\Expr\StaticPropertyFetch) { + $propertyReflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($argumentValue, $scope); + foreach ($propertyReflections as $propertyReflection) { + $nativePropertyReflection = $propertyReflection->getNativeReflection(); + if ($nativePropertyReflection === null) { + continue; + } + if (!$nativePropertyReflection->isReadOnly()) { + continue; + } + + if ($nativePropertyReflection->isStatic()) { + $propertyDescription = sprintf('static readonly property %s::$%s', $propertyReflection->getDeclaringClass()->getDisplayName(), $propertyReflection->getName()); + } else { + $propertyDescription = sprintf('readonly property %s::$%s', $propertyReflection->getDeclaringClass()->getDisplayName(), $propertyReflection->getName()); + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is passed by reference so it does not accept %s.', + $this->describeParameter($parameter, $argumentName === null ? $i + 1 : null), + $propertyDescription, + ))->identifier('argument.byRef')->line($argumentLine)->build(); + } + } + + if ($argumentValue instanceof Node\Expr\Variable + || $argumentValue instanceof Node\Expr\ArrayDimFetch + || $argumentValue instanceof Node\Expr\PropertyFetch + || $argumentValue instanceof Node\Expr\StaticPropertyFetch) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + $parameterPassedByReferenceMessage, + $this->describeParameter($parameter, $argumentName === null ? $i + 1 : null), + ))->identifier('argument.byRef')->line($argumentLine)->build(); + } + + if ($this->checkMissingTypehints && $parametersAcceptor instanceof ResolvedFunctionVariant) { + $originalParametersAcceptor = $parametersAcceptor->getOriginalParametersAcceptor(); + $resolvedTypes = $parametersAcceptor->getResolvedTemplateTypeMap()->getTypes(); + if (count($resolvedTypes) > 0) { + $returnTemplateTypes = []; + TypeTraverser::map( + $parametersAcceptor->getReturnTypeWithUnresolvableTemplateTypes(), + static function (Type $type, callable $traverse) use (&$returnTemplateTypes): Type { + while ($type instanceof ConditionalType && $type->isResolvable()) { + $type = $type->resolve(); + } + + if ($type instanceof TemplateType && $type->getDefault() === null) { + $returnTemplateTypes[$type->getName()] = true; + return $type; + } + + return $traverse($type); + }, + ); + + $parameterTemplateTypes = []; + foreach ($originalParametersAcceptor->getParameters() as $parameter) { + TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use (&$parameterTemplateTypes): Type { + if ($type instanceof TemplateType && $type->getDefault() === null) { + $parameterTemplateTypes[$type->getName()] = true; + return $type; + } + + return $traverse($type); + }); + } + + foreach ($resolvedTypes as $name => $type) { + if ( + !($type instanceof ErrorType) + && ( + !$type instanceof NeverType + || $type->isExplicit() + ) + ) { + continue; + } + + if (!array_key_exists($name, $returnTemplateTypes)) { + continue; + } + + if (!array_key_exists($name, $parameterTemplateTypes)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($unresolvableTemplateTypeMessage, $name)) + ->identifier('argument.templateType') + ->line($funcCall->getStartLine()) + ->tip('See: https://phpstan.org/blog/solving-phpstan-error-unable-to-resolve-template-type') + ->build(); + } + } + + if ( + !$this->unresolvableTypeHelper->containsUnresolvableType($originalParametersAcceptor->getReturnType()) + && $this->unresolvableTypeHelper->containsUnresolvableType($parametersAcceptor->getReturnType()) + ) { + $errors[] = RuleErrorBuilder::message($unresolvableReturnTypeMessage) + ->identifier(sprintf('%s.unresolvableReturnType', $nodeType)) + ->line($funcCall->getStartLine()) + ->build(); + } + } + + return $errors; + } + + /** + * @param array $arguments + * @return array{list, array} + */ + private function processArguments( + ParametersAcceptor $parametersAcceptor, + int $line, + bool $isBuiltin, + array $arguments, + bool $hasNamedArguments, + string $missingParameterMessage, + string $unknownParameterMessage, + ): array + { + $parameters = $parametersAcceptor->getParameters(); + $originalParameters = $parametersAcceptor instanceof ResolvedFunctionVariant + ? $parametersAcceptor->getOriginalParametersAcceptor()->getParameters() + : array_fill(0, count($parameters), null); + $parametersByName = []; + $originalParametersByName = []; + $unusedParametersByName = []; + $errors = []; + foreach ($parameters as $i => $parameter) { + $parametersByName[$parameter->getName()] = $parameter; + $originalParametersByName[$parameter->getName()] = $originalParameters[$i]; + + if ($parameter->isVariadic()) { + continue; + } + + $unusedParametersByName[$parameter->getName()] = $parameter; + } + + $newArguments = []; + + $namedArgumentAlreadyOccurred = false; + foreach ($arguments as $i => [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine]) { + if ($argumentName === null) { + if (!isset($parameters[$i])) { + if (!$parametersAcceptor->isVariadic() || count($parameters) === 0) { + $newArguments[$i] = [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, null, null]; + break; + } + + $parameter = $parameters[count($parameters) - 1]; + $originalParameter = $originalParameters[count($originalParameters) - 1]; + if (!$parameter->isVariadic()) { + $newArguments[$i] = [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, null, null]; + break; // func_get_args + } + } else { + $parameter = $parameters[$i]; + $originalParameter = $originalParameters[$i]; + } + } elseif (array_key_exists($argumentName, $parametersByName)) { + $namedArgumentAlreadyOccurred = true; + $parameter = $parametersByName[$argumentName]; + $originalParameter = $originalParametersByName[$argumentName]; + } else { + $namedArgumentAlreadyOccurred = true; + + $parametersCount = count($parameters); + if ( + !$parametersAcceptor->isVariadic() + || $parametersCount <= 0 + || $isBuiltin + ) { + $errors[] = RuleErrorBuilder::message(sprintf($unknownParameterMessage, $argumentName)) + ->identifier('argument.unknown') + ->line($argumentLine) + ->build(); + $newArguments[$i] = [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, null, null]; + continue; + } + + $parameter = $parameters[$parametersCount - 1]; + $originalParameter = $originalParameters[$parametersCount - 1]; + } + + if ($namedArgumentAlreadyOccurred && $argumentName === null && !$unpack) { + $errors[] = RuleErrorBuilder::message('Named argument cannot be followed by a positional argument.') + ->identifier('argument.positionalAfterNamed') + ->line($argumentLine) + ->nonIgnorable() + ->build(); + $newArguments[$i] = [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, null, null]; + continue; + } + + $newArguments[$i] = [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, $parameter, $originalParameter]; + + if ( + $hasNamedArguments + && !$parameter->isVariadic() + && !array_key_exists($parameter->getName(), $unusedParametersByName) + ) { + $errors[] = RuleErrorBuilder::message(sprintf('Argument for parameter $%s has already been passed.', $parameter->getName())) + ->identifier('argument.duplicate') + ->line($argumentLine) + ->build(); + continue; + } + + unset($unusedParametersByName[$parameter->getName()]); + } + + if ($hasNamedArguments) { + foreach ($unusedParametersByName as $parameter) { + if ($parameter->isOptional()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($missingParameterMessage, sprintf('%s (%s)', $parameter->getName(), $parameter->getType()->describe(VerbosityLevel::typeOnly())))) + ->identifier('argument.missing') + ->line($line) + ->build(); + } + } + + return [$errors, $newArguments]; + } + + private function describeParameter(ParameterReflection $parameter, int|string|null $positionOrNamed): string + { + $parts = []; + if (is_int($positionOrNamed)) { + $parts[] = 'Parameter #' . $positionOrNamed; + } elseif ($parameter->isVariadic() && is_string($positionOrNamed)) { + $parts[] = 'Named argument ' . $positionOrNamed . ' for variadic parameter'; + } else { + $parts[] = 'Parameter'; + } + + $name = $parameter->getName(); + if ($name !== '') { + $parts[] = ($parameter->isVariadic() ? '...$' : '$') . $name; + } + + return implode(' ', $parts); + } + +} diff --git a/src/Rules/FunctionDefinitionCheck.php b/src/Rules/FunctionDefinitionCheck.php new file mode 100644 index 00000000..82141656 --- /dev/null +++ b/src/Rules/FunctionDefinitionCheck.php @@ -0,0 +1,733 @@ + + */ + public function checkFunction( + Function_ $function, + PhpFunctionFromParserNodeReflection $functionReflection, + string $parameterMessage, + string $returnMessage, + string $unionTypesMessage, + string $templateTypeMissingInParameterMessage, + string $unresolvableParameterTypeMessage, + string $unresolvableReturnTypeMessage, + ): array + { + return $this->checkParametersAcceptor( + $functionReflection, + $function, + $parameterMessage, + $returnMessage, + $unionTypesMessage, + $templateTypeMissingInParameterMessage, + $unresolvableParameterTypeMessage, + $unresolvableReturnTypeMessage, + ); + } + + /** + * @param Node\Param[] $parameters + * @param Node\Identifier|Node\Name|Node\ComplexType|null $returnTypeNode + * @return list + */ + public function checkAnonymousFunction( + Scope $scope, + array $parameters, + $returnTypeNode, + string $parameterMessage, + string $returnMessage, + string $unionTypesMessage, + string $unresolvableParameterTypeMessage, + string $unresolvableReturnTypeMessage, + ): array + { + $errors = []; + $unionTypeReported = false; + foreach ($parameters as $i => $param) { + if ($param->type === null) { + continue; + } + if ( + !$unionTypeReported + && $param->type instanceof UnionType + && !$this->phpVersion->supportsNativeUnionTypes() + ) { + $errors[] = RuleErrorBuilder::message($unionTypesMessage) + ->line($param->getStartLine()) + ->identifier('parameter.unionTypeNotSupported') + ->nonIgnorable() + ->build(); + $unionTypeReported = true; + } + + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + + $implicitlyNullableTypeError = $this->checkImplicitlyNullableType( + $param->type, + $param->default, + $i + 1, + $param->getStartLine(), + $param->var->name, + ); + if ($implicitlyNullableTypeError !== null) { + $errors[] = $implicitlyNullableTypeError; + } + + $type = $scope->getFunctionType($param->type, false, false); + if ($type->isVoid()->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $param->var->name, 'void')) + ->line($param->type->getStartLine()) + ->identifier('parameter.void') + ->nonIgnorable() + ->build(); + } + if ( + $this->phpVersion->supportsPureIntersectionTypes() + && $this->unresolvableTypeHelper->containsUnresolvableType($type) + ) { + $errors[] = RuleErrorBuilder::message(sprintf($unresolvableParameterTypeMessage, $param->var->name)) + ->line($param->type->getStartLine()) + ->identifier('parameter.unresolvableNativeType') + ->nonIgnorable() + ->build(); + } + + foreach ($type->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $param->var->name, $class)) + ->line($param->type->getStartLine()) + ->identifier('class.notFound') + ->build(); + continue; + } + + $classReflection = $this->reflectionProvider->getClass($class); + if ($classReflection->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $param->var->name, $class)) + ->line($param->type->getStartLine()) + ->identifier('parameter.trait') + ->build(); + continue; + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $param->type), + ], $this->checkClassCaseSensitivity), + ); + } + } + + if ($this->phpVersion->deprecatesRequiredParameterAfterOptional()) { + $errors = array_merge($errors, $this->checkRequiredParameterAfterOptional($parameters)); + } + + if ($returnTypeNode === null) { + return $errors; + } + + if ( + !$unionTypeReported + && $returnTypeNode instanceof UnionType + && !$this->phpVersion->supportsNativeUnionTypes() + ) { + $errors[] = RuleErrorBuilder::message($unionTypesMessage) + ->line($returnTypeNode->getStartLine()) + ->identifier('return.unionTypeNotSupported') + ->nonIgnorable() + ->build(); + } + + $returnType = $scope->getFunctionType($returnTypeNode, false, false); + if ( + $this->phpVersion->supportsPureIntersectionTypes() + && $this->unresolvableTypeHelper->containsUnresolvableType($returnType) + ) { + $errors[] = RuleErrorBuilder::message($unresolvableReturnTypeMessage) + ->line($returnTypeNode->getStartLine()) + ->identifier('return.unresolvableNativeType') + ->nonIgnorable() + ->build(); + } + + foreach ($returnType->getReferencedClasses() as $returnTypeClass) { + if (!$this->reflectionProvider->hasClass($returnTypeClass)) { + $errors[] = RuleErrorBuilder::message(sprintf($returnMessage, $returnTypeClass)) + ->line($returnTypeNode->getStartLine()) + ->identifier('class.notFound') + ->build(); + continue; + } + + if ($this->reflectionProvider->getClass($returnTypeClass)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf($returnMessage, $returnTypeClass)) + ->line($returnTypeNode->getStartLine()) + ->identifier('return.trait') + ->build(); + continue; + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($returnTypeClass, $returnTypeNode), + ], $this->checkClassCaseSensitivity), + ); + } + + return $errors; + } + + /** + * @return list + */ + public function checkClassMethod( + PhpMethodFromParserNodeReflection $methodReflection, + ClassMethod|Node\PropertyHook $methodNode, + string $parameterMessage, + string $returnMessage, + string $unionTypesMessage, + string $templateTypeMissingInParameterMessage, + string $unresolvableParameterTypeMessage, + string $unresolvableReturnTypeMessage, + string $selfOutMessage, + ): array + { + $errors = $this->checkParametersAcceptor( + $methodReflection, + $methodNode, + $parameterMessage, + $returnMessage, + $unionTypesMessage, + $templateTypeMissingInParameterMessage, + $unresolvableParameterTypeMessage, + $unresolvableReturnTypeMessage, + ); + + $selfOutType = $methodReflection->getSelfOutType(); + if ($selfOutType !== null) { + $selfOutTypeReferencedClasses = $selfOutType->getReferencedClasses(); + + foreach ($selfOutTypeReferencedClasses as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf($selfOutMessage, $class)) + ->line($methodNode->getStartLine()) + ->identifier('class.notFound') + ->build(); + continue; + } + if (!$this->reflectionProvider->getClass($class)->isTrait()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($selfOutMessage, $class)) + ->line($methodNode->getStartLine()) + ->identifier('selfOut.trait') + ->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $methodNode), $selfOutTypeReferencedClasses), + $this->checkClassCaseSensitivity, + ), + ); + } + + return $errors; + } + + /** + * @return list + */ + private function checkParametersAcceptor( + ParametersAcceptor $parametersAcceptor, + FunctionLike $functionNode, + string $parameterMessage, + string $returnMessage, + string $unionTypesMessage, + string $templateTypeMissingInParameterMessage, + string $unresolvableParameterTypeMessage, + string $unresolvableReturnTypeMessage, + ): array + { + $errors = []; + $parameterNodes = $functionNode->getParams(); + if (!$this->phpVersion->supportsNativeUnionTypes()) { + $unionTypeReported = false; + foreach ($parameterNodes as $parameterNode) { + if (!$parameterNode->type instanceof UnionType) { + continue; + } + + $errors[] = RuleErrorBuilder::message($unionTypesMessage) + ->line($parameterNode->getStartLine()) + ->identifier('parameter.unionTypeNotSupported') + ->nonIgnorable() + ->build(); + $unionTypeReported = true; + break; + } + + if (!$unionTypeReported && $functionNode->getReturnType() instanceof UnionType) { + $errors[] = RuleErrorBuilder::message($unionTypesMessage) + ->line($functionNode->getReturnType()->getStartLine()) + ->identifier('return.unionTypeNotSupported') + ->nonIgnorable() + ->build(); + } + } + + foreach ($parameterNodes as $i => $parameterNode) { + if (!$parameterNode->var instanceof Variable || !is_string($parameterNode->var->name)) { + throw new ShouldNotHappenException(); + } + $implicitlyNullableTypeError = $this->checkImplicitlyNullableType($parameterNode->type, $parameterNode->default, $i + 1, $parameterNode->getStartLine(), $parameterNode->var->name); + if ($implicitlyNullableTypeError === null) { + continue; + } + + $errors[] = $implicitlyNullableTypeError; + } + + if ($this->phpVersion->deprecatesRequiredParameterAfterOptional()) { + $errors = array_merge($errors, $this->checkRequiredParameterAfterOptional($parameterNodes)); + } + + $returnTypeNode = $functionNode->getReturnType() ?? $functionNode; + foreach ($parametersAcceptor->getParameters() as $parameter) { + $referencedClasses = $this->getParameterReferencedClasses($parameter); + $parameterNode = null; + $parameterNodeCallback = function () use ($parameter, $parameterNodes, &$parameterNode): Param { + if ($parameterNode === null) { + $parameterNode = $this->getParameterNode($parameter->getName(), $parameterNodes); + } + + return $parameterNode; + }; + if ($parameter instanceof ExtendedParameterReflection) { + $parameterVar = $parameterNodeCallback()->var; + if (!$parameterVar instanceof Variable || !is_string($parameterVar->name)) { + throw new ShouldNotHappenException(); + } + if ($parameter->getNativeType()->isVoid()->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $parameterVar->name, 'void')) + ->line($parameterNodeCallback()->getStartLine()) + ->identifier('parameter.void') + ->nonIgnorable() + ->build(); + } + if ( + $this->phpVersion->supportsPureIntersectionTypes() + && $this->unresolvableTypeHelper->containsUnresolvableType($parameter->getNativeType()) + ) { + $errors[] = RuleErrorBuilder::message(sprintf($unresolvableParameterTypeMessage, $parameterVar->name)) + ->line($parameterNodeCallback()->getStartLine()) + ->identifier('parameter.unresolvableNativeType') + ->nonIgnorable() + ->build(); + } + } + foreach ($referencedClasses as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf( + $parameterMessage, + $parameter->getName(), + $class, + )) + ->line($parameterNodeCallback()->getStartLine()) + ->identifier('class.notFound') + ->build(); + continue; + } + if (!$this->reflectionProvider->getClass($class)->isTrait()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + $parameterMessage, + $parameter->getName(), + $class, + )) + ->line($parameterNodeCallback()->getStartLine()) + ->identifier('parameter.trait') + ->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $parameterNodeCallback()), $referencedClasses), + $this->checkClassCaseSensitivity, + ), + ); + if (!($parameter->getType() instanceof NonexistentParentClassType)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $parameter->getName(), $parameter->getType()->describe(VerbosityLevel::typeOnly()))) + ->line($parameterNodeCallback()->getStartLine()) + ->identifier('parameter.noParent') + ->build(); + } + + if ($this->phpVersion->supportsPureIntersectionTypes() && $functionNode->getReturnType() !== null) { + $nativeReturnType = ParserNodeTypeToPHPStanType::resolve($functionNode->getReturnType(), null); + if ($this->unresolvableTypeHelper->containsUnresolvableType($nativeReturnType)) { + $errors[] = RuleErrorBuilder::message($unresolvableReturnTypeMessage) + ->nonIgnorable() + ->line($returnTypeNode->getStartLine()) + ->identifier('return.unresolvableNativeType') + ->build(); + } + } + + $returnTypeReferencedClasses = $this->getReturnTypeReferencedClasses($parametersAcceptor); + + foreach ($returnTypeReferencedClasses as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf($returnMessage, $class)) + ->line($returnTypeNode->getStartLine()) + ->identifier('class.notFound') + ->build(); + continue; + } + if (!$this->reflectionProvider->getClass($class)->isTrait()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($returnMessage, $class)) + ->line($returnTypeNode->getStartLine()) + ->identifier('return.trait') + ->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $returnTypeNode), $returnTypeReferencedClasses), + $this->checkClassCaseSensitivity, + ), + ); + if ($parametersAcceptor->getReturnType() instanceof NonexistentParentClassType) { + $errors[] = RuleErrorBuilder::message(sprintf($returnMessage, $parametersAcceptor->getReturnType()->describe(VerbosityLevel::typeOnly()))) + ->line($returnTypeNode->getStartLine()) + ->identifier('return.noParent') + ->build(); + } + + $templateTypeMap = $parametersAcceptor->getTemplateTypeMap(); + $templateTypes = $templateTypeMap->getTypes(); + if (count($templateTypes) > 0) { + foreach ($parametersAcceptor->getParameters() as $parameter) { + TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use (&$templateTypes): Type { + if ($type instanceof TemplateType) { + unset($templateTypes[$type->getName()]); + return $traverse($type); + } + + return $traverse($type); + }); + } + + $returnType = $parametersAcceptor->getReturnType(); + if ($returnType instanceof ConditionalTypeForParameter && !$returnType->isNegated()) { + TypeTraverser::map($returnType, static function (Type $type, callable $traverse) use (&$templateTypes): Type { + if ($type instanceof TemplateType) { + unset($templateTypes[$type->getName()]); + return $traverse($type); + } + + return $traverse($type); + }); + } + + foreach (array_keys($templateTypes) as $templateTypeName) { + $errors[] = RuleErrorBuilder::message(sprintf($templateTypeMissingInParameterMessage, $templateTypeName)) + ->identifier('method.templateTypeNotInParameter') + ->build(); + } + } + + return $errors; + } + + /** + * @param Param[] $parameterNodes + * @return list + */ + private function checkRequiredParameterAfterOptional(array $parameterNodes): array + { + /** @var string|null $optionalParameter */ + $optionalParameter = null; + $errors = []; + $targetPhpVersion = null; + foreach ($parameterNodes as $parameterNode) { + if (!$parameterNode->var instanceof Variable) { + throw new ShouldNotHappenException(); + } + if (!is_string($parameterNode->var->name)) { + throw new ShouldNotHappenException(); + } + $parameterName = $parameterNode->var->name; + if ($optionalParameter !== null && $parameterNode->default === null && !$parameterNode->variadic) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Deprecated in PHP %s: Required parameter $%s follows optional parameter $%s.', + $targetPhpVersion ?? '8.0', + $parameterName, + $optionalParameter, + ), + )->line($parameterNode->getStartLine()) + ->identifier('parameter.requiredAfterOptional') + ->build(); + $targetPhpVersion = null; + continue; + } + if ($parameterNode->default === null) { + continue; + } + if ($parameterNode->type === null) { + $optionalParameter = $parameterName; + continue; + } + + $defaultValue = $parameterNode->default; + if (!$defaultValue instanceof ConstFetch) { + $optionalParameter = $parameterName; + continue; + } + + $constantName = $defaultValue->name->toLowerString(); + if ($constantName === 'null') { + if (!$this->phpVersion->deprecatesRequiredParameterAfterOptionalNullableAndDefaultNull()) { + continue; + } + + $parameterNodeType = $parameterNode->type; + + if ($parameterNodeType instanceof NullableType) { + $targetPhpVersion = '8.1'; + } + + if ($this->phpVersion->deprecatesRequiredParameterAfterOptionalUnionOrMixed()) { + $types = []; + + if ($parameterNodeType instanceof UnionType) { + $types = $parameterNodeType->types; + } elseif ($parameterNodeType instanceof Identifier) { + $types = [$parameterNodeType]; + } + + $nullOrMixed = array_filter($types, static fn (Identifier|Name|IntersectionType $type): bool => $type instanceof Identifier && (in_array($type->name, ['null', 'mixed'], true))); + + if (0 < count($nullOrMixed)) { + $targetPhpVersion = '8.3'; + } + } + + if ($targetPhpVersion === null) { + continue; + } + } + + $optionalParameter = $parameterName; + } + + return $errors; + } + + /** + * @param Param[] $parameterNodes + */ + private function getParameterNode( + string $parameterName, + array $parameterNodes, + ): Param + { + foreach ($parameterNodes as $param) { + if ($param->var instanceof Node\Expr\Error) { + continue; + } + + if (!is_string($param->var->name)) { + continue; + } + + if ($param->var->name === $parameterName) { + return $param; + } + } + + throw new ShouldNotHappenException(sprintf('Parameter %s not found.', $parameterName)); + } + + /** + * @return string[] + */ + private function getParameterReferencedClasses(ParameterReflection $parameter): array + { + if (!$parameter instanceof ExtendedParameterReflection) { + return $parameter->getType()->getReferencedClasses(); + } + + if ($this->checkThisOnly) { + return $parameter->getNativeType()->getReferencedClasses(); + } + + $moreClasses = []; + if ($parameter->getOutType() !== null) { + $moreClasses = array_merge($moreClasses, $parameter->getOutType()->getReferencedClasses()); + } + if ($parameter->getClosureThisType() !== null) { + $moreClasses = array_merge($moreClasses, $parameter->getClosureThisType()->getReferencedClasses()); + } + + return array_merge( + $parameter->getNativeType()->getReferencedClasses(), + $parameter->getPhpDocType()->getReferencedClasses(), + $moreClasses, + ); + } + + /** + * @return string[] + */ + private function getReturnTypeReferencedClasses(ParametersAcceptor $parametersAcceptor): array + { + if (!$parametersAcceptor instanceof ExtendedParametersAcceptor) { + return $parametersAcceptor->getReturnType()->getReferencedClasses(); + } + + if ($this->checkThisOnly) { + return $parametersAcceptor->getNativeReturnType()->getReferencedClasses(); + } + + return array_merge( + $parametersAcceptor->getNativeReturnType()->getReferencedClasses(), + $parametersAcceptor->getPhpDocReturnType()->getReferencedClasses(), + ); + } + + private function checkImplicitlyNullableType( + Identifier|Name|ComplexType|null $type, + ?Node\Expr $default, + int $order, + int $line, + string $name, + ): ?IdentifierRuleError + { + if (!$default instanceof ConstFetch) { + return null; + } + + if ($default->name->toLowerString() !== 'null') { + return null; + } + + if ($type === null) { + return null; + } + + if ($type instanceof NullableType || $type instanceof IntersectionType) { + return null; + } + + if (!$this->phpVersion->deprecatesImplicitlyNullableParameterTypes()) { + return null; + } + + if ($type instanceof Identifier && strtolower($type->name) === 'mixed') { + return null; + } + + if ($type instanceof Identifier && strtolower($type->name) === 'null') { + return null; + } + if ($type instanceof Name && $type->toLowerString() === 'null') { + return null; + } + + if ($type instanceof UnionType) { + foreach ($type->types as $innerType) { + if ($innerType instanceof Identifier && strtolower($innerType->name) === 'null') { + return null; + } + } + } + + return RuleErrorBuilder::message(sprintf( + 'Deprecated in PHP 8.4: Parameter #%d $%s (%s) is implicitly nullable via default value null.', + $order, + $name, + NodeTypePrinter::printType($type), + ))->line($line) + ->identifier('parameter.implicitlyNullable') + ->build(); + } + +} diff --git a/src/Rules/FunctionReturnTypeCheck.php b/src/Rules/FunctionReturnTypeCheck.php new file mode 100644 index 00000000..be52c5d9 --- /dev/null +++ b/src/Rules/FunctionReturnTypeCheck.php @@ -0,0 +1,112 @@ + + */ + public function checkReturnType( + Scope $scope, + Type $returnType, + ?Expr $returnValue, + Node $returnNode, + string $emptyReturnStatementMessage, + string $voidMessage, + string $typeMismatchMessage, + string $neverMessage, + bool $isGenerator, + ): array + { + $returnType = TypeUtils::resolveLateResolvableTypes($returnType); + + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + return [ + RuleErrorBuilder::message($neverMessage) + ->line($returnNode->getStartLine()) + ->identifier('return.never') + ->build(), + ]; + } + + if ($isGenerator) { + $returnType = $returnType->getTemplateType(Generator::class, 'TReturn'); + if ($returnType instanceof ErrorType) { + return []; + } + } + + $isVoidSuperType = $returnType->isVoid(); + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType, null); + if ($returnValue === null) { + if (!$isVoidSuperType->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + $emptyReturnStatementMessage, + $returnType->describe($verbosityLevel), + )) + ->line($returnNode->getStartLine()) + ->identifier('return.empty') + ->build(), + ]; + } + + if ($returnNode instanceof Expr\Yield_ || $returnNode instanceof Expr\YieldFrom) { + return []; + } + + $returnValueType = $scope->getType($returnValue); + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType, $returnValueType); + + if ($isVoidSuperType->yes()) { + return [ + RuleErrorBuilder::message(sprintf( + $voidMessage, + $returnValueType->describe($verbosityLevel), + )) + ->line($returnNode->getStartLine()) + ->identifier('return.void') + ->build(), + ]; + } + + $accepts = $this->ruleLevelHelper->accepts($returnType, $returnValueType, $scope->isDeclareStrictTypes()); + if (!$accepts->result) { + return [ + RuleErrorBuilder::message(sprintf( + $typeMismatchMessage, + $returnType->describe($verbosityLevel), + $returnValueType->describe($verbosityLevel), + )) + ->line($returnNode->getStartLine()) + ->identifier('return.type') + ->acceptsReasonsTip($accepts->reasons) + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/ArrayFilterRule.php b/src/Rules/Functions/ArrayFilterRule.php new file mode 100644 index 00000000..1df75b67 --- /dev/null +++ b/src/Rules/Functions/ArrayFilterRule.php @@ -0,0 +1,139 @@ + + */ +final class ArrayFilterRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'array_filter') { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (count($args) !== 1) { + return []; + } + + if ($this->treatPhpDocTypesAsCertain) { + $arrayType = $scope->getType($args[0]->value); + } else { + $arrayType = $scope->getNativeType($args[0]->value); + } + + if ($arrayType->isIterableAtLeastOnce()->no()) { + $message = 'Parameter #1 $array (%s) to function array_filter is empty, call has no effect.'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + $message, + $arrayType->describe(VerbosityLevel::value()), + ))->identifier('arrayFilter.empty'); + if ($this->treatPhpDocTypesAsCertain) { + $nativeArrayType = $scope->getNativeType($args[0]->value); + if ($this->treatPhpDocTypesAsCertainTip && !$nativeArrayType->isIterableAtLeastOnce()->no()) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + return [ + $errorBuilder->build(), + ]; + } + + $falsyType = StaticTypeFactory::falsey(); + $isSuperType = $falsyType->isSuperTypeOf($arrayType->getIterableValueType()); + + if ($isSuperType->no()) { + $message = 'Parameter #1 $array (%s) to function array_filter does not contain falsy values, the array will always stay the same.'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + $message, + $arrayType->describe(VerbosityLevel::value()), + ))->identifier('arrayFilter.same'); + + if ($this->treatPhpDocTypesAsCertain) { + $nativeArrayType = $scope->getNativeType($args[0]->value); + $isNativeSuperType = $falsyType->isSuperTypeOf($nativeArrayType->getIterableValueType()); + if ($this->treatPhpDocTypesAsCertainTip && !$isNativeSuperType->no()) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + + return [ + $errorBuilder->build(), + ]; + } + + if ($isSuperType->yes()) { + $message = 'Parameter #1 $array (%s) to function array_filter contains falsy values only, the result will always be an empty array.'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + $message, + $arrayType->describe(VerbosityLevel::value()), + ))->identifier('arrayFilter.alwaysEmpty'); + + if ($this->treatPhpDocTypesAsCertain) { + $nativeArrayType = $scope->getNativeType($args[0]->value); + $isNativeSuperType = $falsyType->isSuperTypeOf($nativeArrayType->getIterableValueType()); + if ($this->treatPhpDocTypesAsCertainTip && !$isNativeSuperType->yes()) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + + return [ + $errorBuilder->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/ArrayValuesRule.php b/src/Rules/Functions/ArrayValuesRule.php new file mode 100644 index 00000000..fda8bbf9 --- /dev/null +++ b/src/Rules/Functions/ArrayValuesRule.php @@ -0,0 +1,115 @@ + + */ +final class ArrayValuesRule implements Rule +{ + + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly bool $treatPhpDocTypesAsCertain, + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'array_values') { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (count($args) === 0) { + return []; + } + + if ($this->treatPhpDocTypesAsCertain) { + $arrayType = $scope->getType($args[0]->value); + } else { + $arrayType = $scope->getNativeType($args[0]->value); + } + + if ($arrayType->isIterableAtLeastOnce()->no()) { + $message = 'Parameter #1 $array (%s) to function array_values is empty, call has no effect.'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + $message, + $arrayType->describe(VerbosityLevel::value()), + ))->identifier('arrayValues.empty'); + if ($this->treatPhpDocTypesAsCertain) { + $nativeArrayType = $scope->getNativeType($args[0]->value); + if ($this->treatPhpDocTypesAsCertainTip && !$nativeArrayType->isIterableAtLeastOnce()->no()) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + + return [ + $errorBuilder->build(), + ]; + } + + if ($arrayType->isList()->yes()) { + $message = 'Parameter #1 $array (%s) of array_values is already a list, call has no effect.'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + $message, + $arrayType->describe(VerbosityLevel::value()), + ))->identifier('arrayValues.list'); + if ($this->treatPhpDocTypesAsCertain) { + $nativeArrayType = $scope->getNativeType($args[0]->value); + if ($this->treatPhpDocTypesAsCertainTip && !$nativeArrayType->isList()->yes()) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + + return [ + $errorBuilder->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/ArrowFunctionAttributesRule.php b/src/Rules/Functions/ArrowFunctionAttributesRule.php new file mode 100644 index 00000000..afcdee7a --- /dev/null +++ b/src/Rules/Functions/ArrowFunctionAttributesRule.php @@ -0,0 +1,38 @@ + + */ +final class ArrowFunctionAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InArrowFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_FUNCTION, + 'function', + ); + } + +} diff --git a/src/Rules/Functions/ArrowFunctionReturnNullsafeByRefRule.php b/src/Rules/Functions/ArrowFunctionReturnNullsafeByRefRule.php new file mode 100644 index 00000000..f24f1e3c --- /dev/null +++ b/src/Rules/Functions/ArrowFunctionReturnNullsafeByRefRule.php @@ -0,0 +1,45 @@ + + */ +final class ArrowFunctionReturnNullsafeByRefRule implements Rule +{ + + public function __construct(private NullsafeCheck $nullsafeCheck) + { + } + + public function getNodeType(): string + { + return Node\Expr\ArrowFunction::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->byRef) { + return []; + } + + if (!$this->nullsafeCheck->containsNullSafe($node->expr)) { + return []; + } + + return [ + RuleErrorBuilder::message('Nullsafe cannot be returned by reference.') + ->nonIgnorable() + ->identifier('nullsafe.byRef') + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/ArrowFunctionReturnTypeRule.php b/src/Rules/Functions/ArrowFunctionReturnTypeRule.php new file mode 100644 index 00000000..abb80899 --- /dev/null +++ b/src/Rules/Functions/ArrowFunctionReturnTypeRule.php @@ -0,0 +1,71 @@ + + */ +final class ArrowFunctionReturnTypeRule implements Rule +{ + + public function __construct(private FunctionReturnTypeCheck $returnTypeCheck) + { + } + + public function getNodeType(): string + { + return InArrowFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInAnonymousFunction()) { + throw new ShouldNotHappenException(); + } + + /** @var Type $returnType */ + $returnType = $scope->getAnonymousFunctionReturnType(); + $generatorType = new ObjectType(Generator::class); + + $originalNode = $node->getOriginalNode(); + $isVoidSuperType = $returnType->isVoid(); + if ($originalNode->returnType === null && $isVoidSuperType->yes()) { + return []; + } + + $exprType = $scope->getType($originalNode->expr); + if ( + $returnType instanceof NeverType + && $returnType->isExplicit() + && $exprType instanceof NeverType + && $exprType->isExplicit() + ) { + return []; + } + + return $this->returnTypeCheck->checkReturnType( + $scope, + $returnType, + $originalNode->expr, + $originalNode->expr, + 'Anonymous function should return %s but empty return statement found.', + 'Anonymous function with return type void returns %s but should not return anything.', + 'Anonymous function should return %s but returns %s.', + 'Anonymous function should never return but return statement found.', + $generatorType->isSuperTypeOf($returnType)->yes(), + ); + } + +} diff --git a/src/Rules/Functions/CallCallablesRule.php b/src/Rules/Functions/CallCallablesRule.php new file mode 100644 index 00000000..acc25cd4 --- /dev/null +++ b/src/Rules/Functions/CallCallablesRule.php @@ -0,0 +1,143 @@ + + */ +final class CallCallablesRule implements Rule +{ + + public function __construct( + private FunctionCallParametersCheck $check, + private RuleLevelHelper $ruleLevelHelper, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode( + Node $node, + Scope $scope, + ): array + { + if (!$node->name instanceof Node\Expr) { + return []; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->name), + 'Invoking callable on an unknown class %s.', + static fn (Type $type): bool => $type->isCallable()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $isCallable = $type->isCallable(); + if ($isCallable->no()) { + return [ + RuleErrorBuilder::message( + sprintf('Trying to invoke %s but it\'s not a callable.', $type->describe(VerbosityLevel::value())), + )->identifier('callable.nonCallable')->build(), + ]; + } + if ($this->reportMaybes && $isCallable->maybe()) { + return [ + RuleErrorBuilder::message( + sprintf('Trying to invoke %s but it might not be a callable.', $type->describe(VerbosityLevel::value())), + )->identifier('callable.nonCallable')->build(), + ]; + } + + $parametersAcceptors = $type->getCallableParametersAcceptors($scope); + $messages = []; + + $acceptsNamedArguments = TrinaryLogic::createYes(); + foreach ($parametersAcceptors as $parametersAcceptor) { + $acceptsNamedArguments = $acceptsNamedArguments->and($parametersAcceptor->acceptsNamedArguments()); + } + + if ( + count($parametersAcceptors) === 1 + && $parametersAcceptors[0] instanceof InaccessibleMethod + ) { + $method = $parametersAcceptors[0]->getMethod(); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Call to %s method %s() of class %s.', + $method->isPrivate() ? 'private' : 'protected', + $method->getName(), + $method->getDeclaringClass()->getDisplayName(), + ))->identifier('callable.inaccessibleMethod')->build(); + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $parametersAcceptors, + null, + ); + + if ($type instanceof ClosureType) { + $callableDescription = 'closure'; + } else { + $callableDescription = sprintf('callable %s', SprintfHelper::escapeFormatString($type->describe(VerbosityLevel::value()))); + } + + return array_merge( + $messages, + $this->check->check( + $parametersAcceptor, + $scope, + false, + $node, + 'callable', + $acceptsNamedArguments, + ucfirst($callableDescription) . ' invoked with %d parameter, %d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, %d required.', + ucfirst($callableDescription) . ' invoked with %d parameter, at least %d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, at least %d required.', + ucfirst($callableDescription) . ' invoked with %d parameter, %d-%d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, %d-%d required.', + '%s of ' . $callableDescription . ' expects %s, %s given.', + 'Result of ' . $callableDescription . ' (void) is used.', + '%s of ' . $callableDescription . ' is passed by reference, so it expects variables only.', + 'Unable to resolve the template type %s in call to ' . $callableDescription, + 'Missing parameter $%s in call to ' . $callableDescription . '.', + 'Unknown parameter $%s in call to ' . $callableDescription . '.', + 'Return type of call to ' . $callableDescription . ' contains unresolvable type.', + '%s of ' . $callableDescription . ' contains unresolvable type.', + ucfirst($callableDescription) . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + ), + ); + } + +} diff --git a/src/Rules/Functions/CallToFunctionParametersRule.php b/src/Rules/Functions/CallToFunctionParametersRule.php new file mode 100644 index 00000000..ccf06777 --- /dev/null +++ b/src/Rules/Functions/CallToFunctionParametersRule.php @@ -0,0 +1,73 @@ + + */ +final class CallToFunctionParametersRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider, private FunctionCallParametersCheck $check) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $function = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = SprintfHelper::escapeFormatString($function->getName()); + + return $this->check->check( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $function->getVariants(), + $function->getNamedArgumentsVariants(), + ), + $scope, + $function->isBuiltin(), + $node, + 'function', + $function->acceptsNamedArguments(), + 'Function ' . $functionName . ' invoked with %d parameter, %d required.', + 'Function ' . $functionName . ' invoked with %d parameters, %d required.', + 'Function ' . $functionName . ' invoked with %d parameter, at least %d required.', + 'Function ' . $functionName . ' invoked with %d parameters, at least %d required.', + 'Function ' . $functionName . ' invoked with %d parameter, %d-%d required.', + 'Function ' . $functionName . ' invoked with %d parameters, %d-%d required.', + '%s of function ' . $functionName . ' expects %s, %s given.', + 'Result of function ' . $functionName . ' (void) is used.', + '%s of function ' . $functionName . ' is passed by reference, so it expects variables only.', + 'Unable to resolve the template type %s in call to function ' . $functionName, + 'Missing parameter $%s in call to function ' . $functionName . '.', + 'Unknown parameter $%s in call to function ' . $functionName . '.', + 'Return type of call to function ' . $functionName . ' contains unresolvable type.', + '%s of function ' . $functionName . ' contains unresolvable type.', + 'Function ' . $functionName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + ); + } + +} diff --git a/src/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRule.php b/src/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRule.php new file mode 100644 index 00000000..65e9bbc5 --- /dev/null +++ b/src/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRule.php @@ -0,0 +1,141 @@ + + */ +final class CallToFunctionStatementWithoutSideEffectsRule implements Rule +{ + + private const SIDE_EFFECT_FLIP_PARAMETERS = [ + // functionName => [name, pos, testName] + 'print_r' => ['return', 1, 'isTruthy'], + 'var_export' => ['return', 1, 'isTruthy'], + 'highlight_string' => ['return', 1, 'isTruthy'], + + ]; + + public const PHPSTAN_TESTING_FUNCTIONS = [ + 'PHPStan\\dumpType', + 'PHPStan\\dumpPhpDocType', + 'PHPStan\\debugScope', + 'PHPStan\\Testing\\assertType', + 'PHPStan\\Testing\\assertNativeType', + 'PHPStan\\Testing\\assertVariableCertainty', + ]; + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Expression::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->expr instanceof Node\Expr\FuncCall) { + return []; + } + + $funcCall = $node->expr; + if (!($funcCall->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($funcCall->name, $scope)) { + return []; + } + + $function = $this->reflectionProvider->getFunction($funcCall->name, $scope); + $functionName = $function->getName(); + $functionHasSideEffects = !$function->hasSideEffects()->no(); + + if (in_array($functionName, self::PHPSTAN_TESTING_FUNCTIONS, true)) { + return []; + } + + if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$functionName])) { + [ + $flipParameterName, + $flipParameterPosition, + $testName, + ] = self::SIDE_EFFECT_FLIP_PARAMETERS[$functionName]; + + $sideEffectFlipped = false; + $hasNamedParameter = false; + $checker = [ + 'isNotNull' => static fn (Type $type) => $type->isNull()->no(), + 'isTruthy' => static fn (Type $type) => $type->toBoolean()->isTrue()->yes(), + ][$testName]; + + foreach ($funcCall->getRawArgs() as $i => $arg) { + if (!$arg instanceof Arg) { + return []; + } + + $isFlipParameter = false; + + if ($arg->name !== null) { + $hasNamedParameter = true; + if ($arg->name->name === $flipParameterName) { + $isFlipParameter = true; + } + } + + if (!$hasNamedParameter && $i === $flipParameterPosition) { + $isFlipParameter = true; + } + + if ($isFlipParameter) { + $sideEffectFlipped = $checker($scope->getType($arg->value)); + break; + } + } + + if (!$sideEffectFlipped) { + return []; + } + + $functionHasSideEffects = false; + } + + if (!$functionHasSideEffects || $node->expr->isFirstClassCallable()) { + if (!$node->expr->isFirstClassCallable()) { + $throwsType = $function->getThrowType(); + if ($throwsType !== null && !$throwsType->isVoid()->yes()) { + return []; + } + } + + $functionResult = $scope->getType($funcCall); + if ($functionResult instanceof NeverType && $functionResult->isExplicit()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to function %s() on a separate line has no effect.', + $function->getName(), + ))->identifier('function.resultUnused')->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/CallToNonExistentFunctionRule.php b/src/Rules/Functions/CallToNonExistentFunctionRule.php new file mode 100644 index 00000000..b3e6b53a --- /dev/null +++ b/src/Rules/Functions/CallToNonExistentFunctionRule.php @@ -0,0 +1,72 @@ + + */ +final class CallToNonExistentFunctionRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private bool $checkFunctionNameCase, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + if ($scope->isInFunctionExists($node->name->toString())) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Function %s not found.', (string) $node->name))->identifier('function.notFound')->discoveringSymbolsTip()->build(), + ]; + } + + $function = $this->reflectionProvider->getFunction($node->name, $scope); + $name = (string) $node->name; + + if ($this->checkFunctionNameCase) { + /** @var string $calledFunctionName */ + $calledFunctionName = $this->reflectionProvider->resolveFunctionName($node->name, $scope); + if ( + strtolower($function->getName()) === strtolower($calledFunctionName) + && $function->getName() !== $calledFunctionName + ) { + return [ + RuleErrorBuilder::message(sprintf( + 'Call to function %s() with incorrect case: %s', + $function->getName(), + $name, + ))->identifier('function.nameCase')->build(), + ]; + } + } + + return []; + } + +} diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php new file mode 100644 index 00000000..5a1a8189 --- /dev/null +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -0,0 +1,89 @@ + + */ +final class CallUserFuncRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private FunctionCallParametersCheck $check, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + if (count($node->getArgs()) === 0) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'call_user_func') { + return []; + } + + $result = ArgumentsNormalizer::reorderCallUserFuncArguments( + $node, + $scope, + ); + if ($result === null) { + return []; + } + [$parametersAcceptor, $funcCall, $acceptsNamedArguments] = $result; + + $callableDescription = 'callable passed to call_user_func()'; + + return $this->check->check( + $parametersAcceptor, + $scope, + false, + $funcCall, + 'function', + $acceptsNamedArguments, + ucfirst($callableDescription) . ' invoked with %d parameter, %d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, %d required.', + ucfirst($callableDescription) . ' invoked with %d parameter, at least %d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, at least %d required.', + ucfirst($callableDescription) . ' invoked with %d parameter, %d-%d required.', + ucfirst($callableDescription) . ' invoked with %d parameters, %d-%d required.', + '%s of ' . $callableDescription . ' expects %s, %s given.', + 'Result of ' . $callableDescription . ' (void) is used.', + '%s of ' . $callableDescription . ' is passed by reference, so it expects variables only.', + 'Unable to resolve the template type %s in call to ' . $callableDescription, + 'Missing parameter $%s in call to ' . $callableDescription . '.', + 'Unknown parameter $%s in call to ' . $callableDescription . '.', + 'Return type of call to ' . $callableDescription . ' contains unresolvable type.', + '%s of ' . $callableDescription . ' contains unresolvable type.', + ucfirst($callableDescription) . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + ); + } + +} diff --git a/src/Rules/Functions/ClosureAttributesRule.php b/src/Rules/Functions/ClosureAttributesRule.php new file mode 100644 index 00000000..356c0185 --- /dev/null +++ b/src/Rules/Functions/ClosureAttributesRule.php @@ -0,0 +1,38 @@ + + */ +final class ClosureAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InClosureNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_FUNCTION, + 'function', + ); + } + +} diff --git a/src/Rules/Functions/ClosureReturnTypeRule.php b/src/Rules/Functions/ClosureReturnTypeRule.php new file mode 100644 index 00000000..9b56c9ea --- /dev/null +++ b/src/Rules/Functions/ClosureReturnTypeRule.php @@ -0,0 +1,67 @@ + + */ +final class ClosureReturnTypeRule implements Rule +{ + + public function __construct(private FunctionReturnTypeCheck $returnTypeCheck) + { + } + + public function getNodeType(): string + { + return ClosureReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInAnonymousFunction()) { + return []; + } + + /** @var Type $returnType */ + $returnType = $scope->getAnonymousFunctionReturnType(); + $containsNull = TypeCombinator::containsNull($returnType); + $hasNativeTypehint = $node->getClosureExpr()->returnType !== null; + + $messages = []; + foreach ($node->getReturnStatements() as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + $returnExpr = $returnNode->expr; + if ($returnExpr === null && $containsNull && !$hasNativeTypehint) { + $returnExpr = new Node\Expr\ConstFetch(new Node\Name\FullyQualified('null')); + } + $returnMessages = $this->returnTypeCheck->checkReturnType( + $returnStatement->getScope(), + $returnType, + $returnExpr, + $returnNode, + 'Anonymous function should return %s but empty return statement found.', + 'Anonymous function with return type void returns %s but should not return anything.', + 'Anonymous function should return %s but returns %s.', + 'Anonymous function should never return but return statement found.', + $node->isGenerator(), + ); + + foreach ($returnMessages as $returnMessage) { + $messages[] = $returnMessage; + } + } + + return $messages; + } + +} diff --git a/src/Rules/Functions/DefineParametersRule.php b/src/Rules/Functions/DefineParametersRule.php new file mode 100644 index 00000000..3101f6ef --- /dev/null +++ b/src/Rules/Functions/DefineParametersRule.php @@ -0,0 +1,58 @@ + + */ +final class DefineParametersRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + if ($this->phpVersion->supportsCaseInsensitiveConstantNames()) { + return []; + } + $name = strtolower((string) $node->name); + if ($name !== 'define') { + return []; + } + $args = $node->getArgs(); + $argsCount = count($args); + // Expects 2 or 3, 1 arg is caught by CallToFunctionParametersRule + if ($argsCount < 3) { + return []; + } + return [ + RuleErrorBuilder::message( + 'Argument #3 ($case_insensitive) is ignored since declaration of case-insensitive constants is no longer supported.', + ) + ->line($node->getStartLine()) + ->identifier('argument.unused') + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/DuplicateFunctionDeclarationRule.php b/src/Rules/Functions/DuplicateFunctionDeclarationRule.php new file mode 100644 index 00000000..2f32f47f --- /dev/null +++ b/src/Rules/Functions/DuplicateFunctionDeclarationRule.php @@ -0,0 +1,60 @@ + + */ +final class DuplicateFunctionDeclarationRule implements Rule +{ + + public function __construct(private Reflector $reflector, private RelativePathHelper $relativePathHelper) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $thisFunction = $node->getFunctionReflection(); + $allFunctions = $this->reflector->reflectAllFunctions(); + $filteredFunctions = []; + foreach ($allFunctions as $reflectionFunction) { + if ($reflectionFunction->getName() !== $thisFunction->getName()) { + continue; + } + + $filteredFunctions[] = $reflectionFunction; + } + + if (count($filteredFunctions) < 2) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + "Function %s declared multiple times:\n%s", + $thisFunction->getName(), + implode("\n", array_map(fn (ReflectionFunction $function) => sprintf('- %s:%d', $this->relativePathHelper->getRelativePath($function->getFileName() ?? 'unknown'), $function->getStartLine()), $filteredFunctions)), + ))->identifier('function.duplicate')->build(), + ]; + } + +} diff --git a/src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.php b/src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.php new file mode 100644 index 00000000..feff07db --- /dev/null +++ b/src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.php @@ -0,0 +1,56 @@ + + */ +final class ExistingClassesInArrowFunctionTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check, private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\Expr\ArrowFunction::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = []; + if ($node->returnType !== null && !$this->phpVersion->supportsNeverReturnTypeInArrowFunction()) { + $returnType = ParserNodeTypeToPHPStanType::resolve($node->returnType, $scope->isInClass() ? $scope->getClassReflection() : null); + if ($returnType instanceof NonAcceptingNeverType) { + $messages[] = RuleErrorBuilder::message('Never return type in arrow function is supported only on PHP 8.2 and later.') + ->identifier('return.neverTypeNotSupported') + ->nonIgnorable() + ->build(); + } + } + + return array_merge($messages, $this->check->checkAnonymousFunction( + $scope, + $node->getParams(), + $node->getReturnType(), + 'Parameter $%s of anonymous function has invalid type %s.', + 'Anonymous function has invalid return type %s.', + 'Anonymous function uses native union types but they\'re supported only on PHP 8.0 and later.', + 'Parameter $%s of anonymous function has unresolvable native type.', + 'Anonymous function has unresolvable native return type.', + )); + } + +} diff --git a/src/Rules/Functions/ExistingClassesInClosureTypehintsRule.php b/src/Rules/Functions/ExistingClassesInClosureTypehintsRule.php new file mode 100644 index 00000000..2485d65a --- /dev/null +++ b/src/Rules/Functions/ExistingClassesInClosureTypehintsRule.php @@ -0,0 +1,41 @@ + + */ +final class ExistingClassesInClosureTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check) + { + } + + public function getNodeType(): string + { + return Closure::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->checkAnonymousFunction( + $scope, + $node->getParams(), + $node->getReturnType(), + 'Parameter $%s of anonymous function has invalid type %s.', + 'Anonymous function has invalid return type %s.', + 'Anonymous function uses native union types but they\'re supported only on PHP 8.0 and later.', + 'Parameter $%s of anonymous function has unresolvable native type.', + 'Anonymous function has unresolvable native return type.', + ); + } + +} diff --git a/src/Rules/Functions/ExistingClassesInTypehintsRule.php b/src/Rules/Functions/ExistingClassesInTypehintsRule.php new file mode 100644 index 00000000..c3c03c56 --- /dev/null +++ b/src/Rules/Functions/ExistingClassesInTypehintsRule.php @@ -0,0 +1,57 @@ + + */ +final class ExistingClassesInTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functionName = SprintfHelper::escapeFormatString($node->getFunctionReflection()->getName()); + + return $this->check->checkFunction( + $node->getOriginalNode(), + $node->getFunctionReflection(), + sprintf( + 'Parameter $%%s of function %s() has invalid type %%s.', + $functionName, + ), + sprintf( + 'Function %s() has invalid return type %%s.', + $functionName, + ), + sprintf('Function %s() uses native union types but they\'re supported only on PHP 8.0 and later.', $functionName), + sprintf('Template type %%s of function %s() is not referenced in a parameter.', $functionName), + sprintf( + 'Parameter $%%s of function %s() has unresolvable native type.', + $functionName, + ), + sprintf( + 'Function %s() has unresolvable native return type.', + $functionName, + ), + ); + } + +} diff --git a/src/Rules/Functions/FunctionAttributesRule.php b/src/Rules/Functions/FunctionAttributesRule.php new file mode 100644 index 00000000..d9dbf3f7 --- /dev/null +++ b/src/Rules/Functions/FunctionAttributesRule.php @@ -0,0 +1,38 @@ + + */ +final class FunctionAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_FUNCTION, + 'function', + ); + } + +} diff --git a/src/Rules/Functions/FunctionCallableRule.php b/src/Rules/Functions/FunctionCallableRule.php new file mode 100644 index 00000000..0573145c --- /dev/null +++ b/src/Rules/Functions/FunctionCallableRule.php @@ -0,0 +1,114 @@ + + */ +final class FunctionCallableRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider, private RuleLevelHelper $ruleLevelHelper, private PhpVersion $phpVersion, private bool $checkFunctionNameCase, private bool $reportMaybes) + { + } + + public function getNodeType(): string + { + return FunctionCallableNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->supportsFirstClassCallables()) { + return [ + RuleErrorBuilder::message('First-class callables are supported only on PHP 8.1 and later.') + ->nonIgnorable() + ->identifier('callable.notSupported') + ->build(), + ]; + } + + $functionName = $node->getName(); + if ($functionName instanceof Node\Name) { + $functionNameName = $functionName->toString(); + if ($this->reflectionProvider->hasFunction($functionName, $scope)) { + if ($this->checkFunctionNameCase) { + $function = $this->reflectionProvider->getFunction($functionName, $scope); + + /** @var string $calledFunctionName */ + $calledFunctionName = $this->reflectionProvider->resolveFunctionName($functionName, $scope); + if ( + strtolower($function->getName()) === strtolower($calledFunctionName) + && $function->getName() !== $calledFunctionName + ) { + return [ + RuleErrorBuilder::message(sprintf( + 'Call to function %s() with incorrect case: %s', + $function->getName(), + $functionNameName, + ))->identifier('function.nameCase')->build(), + ]; + } + } + + return []; + } + + if ($scope->isInFunctionExists($functionNameName)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Function %s not found.', $functionNameName)) + ->identifier('function.notFound') + ->build(), + ]; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $functionName), + 'Creating callable from an unknown class %s.', + static fn (Type $type): bool => $type->isCallable()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $isCallable = $type->isCallable(); + if ($isCallable->no()) { + return [ + RuleErrorBuilder::message( + sprintf('Creating callable from %s but it\'s not a callable.', $type->describe(VerbosityLevel::value())), + )->identifier('callable.nonCallable')->build(), + ]; + } + if ($this->reportMaybes && $isCallable->maybe()) { + return [ + RuleErrorBuilder::message( + sprintf('Creating callable from %s but it might not be a callable.', $type->describe(VerbosityLevel::value())), + )->identifier('callable.nonCallable')->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/ImplodeParameterCastableToStringRule.php b/src/Rules/Functions/ImplodeParameterCastableToStringRule.php new file mode 100644 index 00000000..cb9a850d --- /dev/null +++ b/src/Rules/Functions/ImplodeParameterCastableToStringRule.php @@ -0,0 +1,118 @@ + + */ +final class ImplodeParameterCastableToStringRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ParameterCastableToStringCheck $parameterCastableToStringCheck, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + if (!in_array($functionName, ['implode', 'join'], true)) { + return []; + } + + $origArgs = $node->getArgs(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $origArgs, + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if ($normalizedFuncCall === null) { + return []; + } + + $normalizedArgs = $normalizedFuncCall->getArgs(); + $errorMessage = 'Parameter %s of function %s expects array, %s given.'; + if (count($normalizedArgs) === 1) { + $argsToCheck = [0 => $normalizedArgs[0]]; + } elseif (count($normalizedArgs) === 2) { + $argsToCheck = [1 => $normalizedArgs[1]]; + } else { + return []; + } + + $origNamedArgs = []; + foreach ($origArgs as $arg) { + if ($arg->unpack || $arg->name === null) { + continue; + } + + $origNamedArgs[$arg->name->toString()] = $arg; + } + + $errors = []; + + foreach ($argsToCheck as $argIdx => $arg) { + // implode has weird variants, so $array has to be fixed. It's especially weird with named arguments. + if (array_key_exists('array', $origNamedArgs)) { + $argName = '$array'; + } elseif (array_key_exists('separator', $origNamedArgs) && count($origArgs) === 1) { + $argName = '$separator'; + } else { + $argName = sprintf('#%d $array', $argIdx + 1); + } + + $error = $this->parameterCastableToStringCheck->checkParameter( + $arg, + $scope, + $errorMessage, + static fn (Type $t) => $t->toString(), + $functionName, + $argName, + ); + + if ($error === null) { + continue; + } + + $errors[] = $error; + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php b/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php new file mode 100644 index 00000000..9a122d2a --- /dev/null +++ b/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php @@ -0,0 +1,71 @@ + + */ +final class IncompatibleArrowFunctionDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InArrowFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $parameters = $node->getClosureType()->getParameters(); + + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameterType = $parameters[$paramI]->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + $accepts = $parameterType->accepts($defaultValueType, true); + if ($accepts->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of anonymous function is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $parameterType->describe($verbosityLevel), + )) + ->line($param->getStartLine()) + ->identifier('parameter.defaultValue') + ->acceptsReasonsTip($accepts->reasons) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php b/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php new file mode 100644 index 00000000..e5ba29bd --- /dev/null +++ b/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php @@ -0,0 +1,71 @@ + + */ +final class IncompatibleClosureDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InClosureNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $parameters = $node->getClosureType()->getParameters(); + + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameterType = $parameters[$paramI]->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + $accepts = $parameterType->accepts($defaultValueType, true); + if ($accepts->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of anonymous function is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $parameterType->describe($verbosityLevel), + )) + ->line($param->getStartLine()) + ->identifier('parameter.defaultValue') + ->acceptsReasonsTip($accepts->reasons) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/IncompatibleDefaultParameterTypeRule.php b/src/Rules/Functions/IncompatibleDefaultParameterTypeRule.php new file mode 100644 index 00000000..17185188 --- /dev/null +++ b/src/Rules/Functions/IncompatibleDefaultParameterTypeRule.php @@ -0,0 +1,71 @@ + + */ +final class IncompatibleDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $function = $node->getFunctionReflection(); + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameterType = $function->getParameters()[$paramI]->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + $accepts = $parameterType->accepts($defaultValueType, true); + if ($accepts->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of function %s() is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $function->getName(), + $parameterType->describe($verbosityLevel), + )) + ->line($param->getStartLine()) + ->identifier('parameter.defaultValue') + ->acceptsReasonsTip($accepts->reasons) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/InnerFunctionRule.php b/src/Rules/Functions/InnerFunctionRule.php new file mode 100644 index 00000000..8816b876 --- /dev/null +++ b/src/Rules/Functions/InnerFunctionRule.php @@ -0,0 +1,36 @@ + + */ +final class InnerFunctionRule implements Rule +{ + + public function getNodeType(): string + { + return Function_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($scope->getFunction() === null) { + return []; + } + + return [ + RuleErrorBuilder::message( + 'Inner named functions are not supported by PHPStan. Consider refactoring to an anonymous function, class method, or a top-level-defined function. See issue #165 (https://github.com/phpstan/phpstan/issues/165) for more details.', + )->identifier('function.inner')->build(), + ]; + } + +} diff --git a/src/Rules/Functions/InvalidLexicalVariablesInClosureUseRule.php b/src/Rules/Functions/InvalidLexicalVariablesInClosureUseRule.php new file mode 100644 index 00000000..c3e579fe --- /dev/null +++ b/src/Rules/Functions/InvalidLexicalVariablesInClosureUseRule.php @@ -0,0 +1,87 @@ + + */ +final class InvalidLexicalVariablesInClosureUseRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\Closure::class; + } + + /** + * @param Node\Expr\Closure $node + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $params = array_filter(array_map( + static function (Node\Param $param) { + if (!$param->var instanceof Node\Expr\Variable) { + return false; + } + + if (!is_string($param->var->name)) { + return false; + } + + return $param->var->name; + }, + $node->getParams(), + ), static fn ($name) => $name !== false); + + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $var = $use->var->name; + + if ($var === 'this') { + $errors[] = RuleErrorBuilder::message('Cannot use $this as lexical variable.') + ->line($use->getStartLine()) + ->identifier('closure.useThis') + ->nonIgnorable() + ->build(); + continue; + } + + if (in_array($var, Scope::SUPERGLOBAL_VARIABLES, true)) { + $errors[] = RuleErrorBuilder::message(sprintf('Cannot use superglobal variable $%s as lexical variable.', $var)) + ->line($use->getStartLine()) + ->identifier('closure.useSuperGlobal') + ->nonIgnorable() + ->build(); + continue; + } + + if (!in_array($var, $params, true)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Cannot use lexical variable $%s since a parameter with the same name already exists.', $var)) + ->line($use->getStartLine()) + ->identifier('closure.useDuplicate') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php new file mode 100644 index 00000000..4a2b5ecf --- /dev/null +++ b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php @@ -0,0 +1,117 @@ + + */ +final class MissingFunctionParameterTypehintRule implements Rule +{ + + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + ) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functionReflection = $node->getFunctionReflection(); + $messages = []; + + foreach ($functionReflection->getParameters() as $parameterReflection) { + foreach ($this->checkFunctionParameter($functionReflection, sprintf('parameter $%s', $parameterReflection->getName()), $parameterReflection->getType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + + if ($parameterReflection->getClosureThisType() !== null) { + foreach ($this->checkFunctionParameter($functionReflection, sprintf('@param-closure-this PHPDoc tag for parameter $%s', $parameterReflection->getName()), $parameterReflection->getClosureThisType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + } + + if ($parameterReflection->getOutType() === null) { + continue; + } + + foreach ($this->checkFunctionParameter($functionReflection, sprintf('@param-out PHPDoc tag for parameter $%s', $parameterReflection->getName()), $parameterReflection->getOutType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + } + + return $messages; + } + + /** + * @return list + */ + private function checkFunctionParameter(FunctionReflection $functionReflection, string $parameterMessage, Type $parameterType): array + { + if ($parameterType instanceof MixedType && !$parameterType->isExplicitMixed()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with no type specified.', + $functionReflection->getName(), + $parameterMessage, + ))->identifier('missingType.parameter')->build(), + ]; + } + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with no value type specified in iterable type %s.', + $functionReflection->getName(), + $parameterMessage, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($parameterType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with generic %s but does not specify its types: %s', + $functionReflection->getName(), + $parameterMessage, + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with no signature specified for %s.', + $functionReflection->getName(), + $parameterMessage, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php new file mode 100644 index 00000000..9b064b24 --- /dev/null +++ b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php @@ -0,0 +1,78 @@ + + */ +final class MissingFunctionReturnTypehintRule implements Rule +{ + + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + ) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functionReflection = $node->getFunctionReflection(); + $returnType = $functionReflection->getReturnType(); + + if ($returnType instanceof MixedType && !$returnType->isExplicitMixed()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Function %s() has no return type specified.', + $functionReflection->getName(), + ))->identifier('missingType.return')->build(), + ]; + } + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf('Function %s() return type has no value type specified in iterable type %s.', $functionReflection->getName(), $iterableTypeDescription)) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($returnType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() return type with generic %s does not specify its types: %s', + $functionReflection->getName(), + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() return type has no signature specified for %s.', + $functionReflection->getName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Functions/ParamAttributesRule.php b/src/Rules/Functions/ParamAttributesRule.php new file mode 100644 index 00000000..3706f232 --- /dev/null +++ b/src/Rules/Functions/ParamAttributesRule.php @@ -0,0 +1,44 @@ + + */ +final class ParamAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return Node\Param::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $targetName = 'parameter'; + $targetType = Attribute::TARGET_PARAMETER; + if ($node->flags !== 0) { + $targetName = 'parameter or property'; + $targetType |= Attribute::TARGET_PROPERTY; + } + + return $this->attributesCheck->check( + $scope, + $node->attrGroups, + $targetType, + $targetName, + ); + } + +} diff --git a/src/Rules/Functions/ParameterCastableToNumberRule.php b/src/Rules/Functions/ParameterCastableToNumberRule.php new file mode 100644 index 00000000..44d033b2 --- /dev/null +++ b/src/Rules/Functions/ParameterCastableToNumberRule.php @@ -0,0 +1,85 @@ + + */ +final class ParameterCastableToNumberRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ParameterCastableToStringCheck $parameterCastableToStringCheck, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + + if (!in_array($functionName, ['array_sum', 'array_product'], true)) { + return []; + } + + $origArgs = $node->getArgs(); + + if (count($origArgs) !== 1) { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $origArgs, + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $errorMessage = 'Parameter %s of function %s expects an array of values castable to number, %s given.'; + $functionParameters = $parametersAcceptor->getParameters(); + $error = $this->parameterCastableToStringCheck->checkParameter( + $origArgs[0], + $scope, + $errorMessage, + static fn (Type $t) => $t->toNumber(), + $functionName, + $this->parameterCastableToStringCheck->getParameterName( + $origArgs[0], + 0, + $functionParameters[0] ?? null, + ), + ); + + return $error !== null + ? [$error] + : []; + } + +} diff --git a/src/Rules/Functions/ParameterCastableToStringRule.php b/src/Rules/Functions/ParameterCastableToStringRule.php new file mode 100644 index 00000000..cee2b33e --- /dev/null +++ b/src/Rules/Functions/ParameterCastableToStringRule.php @@ -0,0 +1,119 @@ + + */ +final class ParameterCastableToStringRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ParameterCastableToStringCheck $parameterCastableToStringCheck, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + $checkAllArgsFunctions = ['array_intersect', 'array_intersect_assoc', 'array_diff', 'array_diff_assoc']; + $checkFirstArgFunctions = [ + 'array_combine', + 'natcasesort', + 'natsort', + 'array_count_values', + 'array_fill_keys', + ]; + + if ( + !in_array($functionName, $checkAllArgsFunctions, true) + && !in_array($functionName, $checkFirstArgFunctions, true) + ) { + return []; + } + + $origArgs = $node->getArgs(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $origArgs, + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $errorMessage = 'Parameter %s of function %s expects an array of values castable to string, %s given.'; + $functionParameters = $parametersAcceptor->getParameters(); + + if (in_array($functionName, $checkAllArgsFunctions, true)) { + $argsToCheck = $origArgs; + } elseif (in_array($functionName, $checkFirstArgFunctions, true)) { + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if ($normalizedFuncCall === null) { + return []; + } + + $normalizedArgs = $normalizedFuncCall->getArgs(); + if (!array_key_exists(0, $normalizedArgs)) { + return []; + } + $argsToCheck = [0 => $normalizedArgs[0]]; + } else { + return []; + } + + $errors = []; + + foreach ($argsToCheck as $argIdx => $arg) { + $error = $this->parameterCastableToStringCheck->checkParameter( + $arg, + $scope, + $errorMessage, + static fn (Type $t) => $t->toString(), + $functionName, + $this->parameterCastableToStringCheck->getParameterName( + $arg, + $argIdx, + $functionParameters[$argIdx] ?? null, + ), + ); + + if ($error === null) { + continue; + } + + $errors[] = $error; + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/PrintfArrayParametersRule.php b/src/Rules/Functions/PrintfArrayParametersRule.php new file mode 100644 index 00000000..bf20f270 --- /dev/null +++ b/src/Rules/Functions/PrintfArrayParametersRule.php @@ -0,0 +1,189 @@ + + */ +final class PrintfArrayParametersRule implements Rule +{ + + public function __construct( + private PrintfHelper $printfHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $name = $functionReflection->getName(); + if (!in_array($name, ['vprintf', 'vsprintf'], true)) { + return []; + } + + $args = $node->getArgs(); + $argsCount = count($args); + if ($argsCount < 1) { + return []; // caught by CallToFunctionParametersRule + } + + $formatArgType = $scope->getType($args[0]->value); + $placeHoldersCounts = []; + foreach ($formatArgType->getConstantStrings() as $formatString) { + $format = $formatString->getValue(); + + $placeHoldersCounts[] = $this->printfHelper->getPrintfPlaceholdersCount($format); + } + + if ($placeHoldersCounts === []) { + return []; + } + + $minCount = min($placeHoldersCounts); + $maxCount = max($placeHoldersCounts); + if ($minCount === $maxCount) { + $placeHoldersCount = new ConstantIntegerType($minCount); + } else { + $placeHoldersCount = IntegerRangeType::fromInterval($minCount, $maxCount); + + if (!$placeHoldersCount instanceof IntegerRangeType && !$placeHoldersCount instanceof ConstantIntegerType) { + return []; + } + } + + $formatArgsCounts = []; + if (isset($args[1])) { + $formatArgsType = $scope->getType($args[1]->value); + + $constantArrays = $formatArgsType->getConstantArrays(); + if ($constantArrays === []) { + $formatArgsCounts[] = new IntegerType(); + } + foreach ($constantArrays as $constantArray) { + $formatArgsCounts[] = $constantArray->getArraySize(); + } + } + + if ($formatArgsCounts === []) { + $formatArgsCount = new ConstantIntegerType(0); + } else { + $formatArgsCount = TypeCombinator::union(...$formatArgsCounts); + + if (!$formatArgsCount instanceof IntegerRangeType && !$formatArgsCount instanceof ConstantIntegerType) { + return []; + } + } + + if (!$this->placeholdersMatchesArgsCount($placeHoldersCount, $formatArgsCount)) { + + if ($placeHoldersCount instanceof IntegerRangeType) { + $placeholders = $this->getIntegerRangeAsString($placeHoldersCount); + $singlePlaceholder = false; + } else { + $placeholders = $placeHoldersCount->getValue(); + $singlePlaceholder = $placeholders === 1; + } + + if ($formatArgsCount instanceof IntegerRangeType) { + $values = $this->getIntegerRangeAsString($formatArgsCount); + $singleValue = false; + } else { + $values = $formatArgsCount->getValue(); + $singleValue = $values === 1; + } + + return [ + RuleErrorBuilder::message(sprintf( + sprintf( + '%s, %s.', + $singlePlaceholder ? 'Call to %s contains %d placeholder' : 'Call to %s contains %s placeholders', + $singleValue ? '%d value given' : '%s values given', + ), + $name, + $placeholders, + $values, + ))->identifier(sprintf('argument.%s', $name))->build(), + ]; + } + + return []; + } + + private function placeholdersMatchesArgsCount(ConstantIntegerType|IntegerRangeType $placeHoldersCount, ConstantIntegerType|IntegerRangeType $formatArgsCount): bool + { + if ($placeHoldersCount instanceof ConstantIntegerType) { + if ($formatArgsCount instanceof ConstantIntegerType) { + return $placeHoldersCount->getValue() === $formatArgsCount->getValue(); + } + + // Zero placeholders + array + if ($placeHoldersCount->getValue() === 0) { + return true; + } + + return false; + } + + if ( + $formatArgsCount instanceof IntegerRangeType + && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($placeHoldersCount)->yes() + ) { + if ($formatArgsCount->getMin() !== null && $formatArgsCount->getMax() !== null) { + // constant array + return $placeHoldersCount->isSuperTypeOf($formatArgsCount)->yes(); + } + + // general array + return IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($formatArgsCount)->yes(); + } + + return false; + } + + private function getIntegerRangeAsString(IntegerRangeType $range): string + { + if ($range->getMin() !== null && $range->getMax() !== null) { + return $range->getMin() . '-' . $range->getMax(); + } elseif ($range->getMin() !== null) { + return $range->getMin() . ' or more'; + } elseif ($range->getMax() !== null) { + return $range->getMax() . ' or less'; + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Functions/PrintfHelper.php b/src/Rules/Functions/PrintfHelper.php new file mode 100644 index 00000000..88ea4952 --- /dev/null +++ b/src/Rules/Functions/PrintfHelper.php @@ -0,0 +1,76 @@ +getPlaceholdersCount('(?:[bs%s]|l?[cdeEgfFGouxX])', $format); + } + + public function getScanfPlaceholdersCount(string $format): int + { + return $this->getPlaceholdersCount('(?:[cdDeEfinosuxX%s]|\[[^\]]+\])', $format); + } + + private function getPlaceholdersCount(string $specifiersPattern, string $format): int + { + $addSpecifier = ''; + if ($this->phpVersion->supportsHhPrintfSpecifier()) { + $addSpecifier .= 'hH'; + } + + $specifiers = sprintf($specifiersPattern, $addSpecifier); + + $pattern = '~(?%*)%(?:(?\d+)\$)?[-+]?(?:[ 0]|(?:\'[^%]))?(?\*)?-?\d*(?:\.(?:\d+|(?\*))?)?' . $specifiers . '~'; + + $matches = Strings::matchAll($format, $pattern, PREG_SET_ORDER); + + if (count($matches) === 0) { + return 0; + } + + $placeholders = array_filter($matches, static fn (array $match): bool => strlen($match['before']) % 2 === 0); + + if (count($placeholders) === 0) { + return 0; + } + + $maxPositionedNumber = 0; + $maxOrdinaryNumber = 0; + foreach ($placeholders as $placeholder) { + if (isset($placeholder['width']) && $placeholder['width'] !== '') { + $maxOrdinaryNumber++; + } + + if (isset($placeholder['precision']) && $placeholder['precision'] !== '') { + $maxOrdinaryNumber++; + } + + if (isset($placeholder['position']) && $placeholder['position'] !== '') { + $maxPositionedNumber = max((int) $placeholder['position'], $maxPositionedNumber); + } else { + $maxOrdinaryNumber++; + } + } + + return max($maxPositionedNumber, $maxOrdinaryNumber); + } + +} diff --git a/src/Rules/Functions/PrintfParametersRule.php b/src/Rules/Functions/PrintfParametersRule.php new file mode 100644 index 00000000..31d4b920 --- /dev/null +++ b/src/Rules/Functions/PrintfParametersRule.php @@ -0,0 +1,119 @@ + + */ +final class PrintfParametersRule implements Rule +{ + + private const FORMAT_ARGUMENT_POSITIONS = [ + 'printf' => 0, + 'sprintf' => 0, + 'sscanf' => 1, + 'fscanf' => 1, + ]; + private const MINIMUM_NUMBER_OF_ARGUMENTS = [ + 'printf' => 1, + 'sprintf' => 1, + 'sscanf' => 3, + 'fscanf' => 3, + ]; + + public function __construct( + private PrintfHelper $printfHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $name = $functionReflection->getName(); + if (!array_key_exists($name, self::FORMAT_ARGUMENT_POSITIONS)) { + return []; + } + + $formatArgumentPosition = self::FORMAT_ARGUMENT_POSITIONS[$name]; + + $args = $node->getArgs(); + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + } + $argsCount = count($args); + if ($argsCount < self::MINIMUM_NUMBER_OF_ARGUMENTS[$name]) { + return []; // caught by CallToFunctionParametersRule + } + + $formatArgType = $scope->getType($args[$formatArgumentPosition]->value); + $maxPlaceHoldersCount = null; + foreach ($formatArgType->getConstantStrings() as $formatString) { + $format = $formatString->getValue(); + + if (in_array($name, ['sprintf', 'printf'], true)) { + $tempPlaceHoldersCount = $this->printfHelper->getPrintfPlaceholdersCount($format); + } else { + $tempPlaceHoldersCount = $this->printfHelper->getScanfPlaceholdersCount($format); + } + + if ($maxPlaceHoldersCount === null) { + $maxPlaceHoldersCount = $tempPlaceHoldersCount; + } elseif ($tempPlaceHoldersCount > $maxPlaceHoldersCount) { + $maxPlaceHoldersCount = $tempPlaceHoldersCount; + } + } + + if ($maxPlaceHoldersCount === null) { + return []; + } + + $argsCount -= $formatArgumentPosition; + + if ($argsCount !== $maxPlaceHoldersCount + 1) { + return [ + RuleErrorBuilder::message(sprintf( + sprintf( + '%s, %s.', + $maxPlaceHoldersCount === 1 ? 'Call to %s contains %d placeholder' : 'Call to %s contains %d placeholders', + $argsCount - 1 === 1 ? '%d value given' : '%d values given', + ), + $name, + $maxPlaceHoldersCount, + $argsCount - 1, + ))->identifier(sprintf('argument.%s', $name))->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/RandomIntParametersRule.php b/src/Rules/Functions/RandomIntParametersRule.php new file mode 100644 index 00000000..d12f3ecc --- /dev/null +++ b/src/Rules/Functions/RandomIntParametersRule.php @@ -0,0 +1,80 @@ + + */ +final class RandomIntParametersRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if ($this->reflectionProvider->resolveFunctionName($node->name, $scope) !== 'random_int') { + return []; + } + + $args = array_values($node->getArgs()); + if (count($args) < 2) { + return []; + } + + $minType = $scope->getType($args[0]->value)->toInteger(); + $maxType = $scope->getType($args[1]->value)->toInteger(); + + if ( + !$minType instanceof ConstantIntegerType && !$minType instanceof IntegerRangeType + || !$maxType instanceof ConstantIntegerType && !$maxType instanceof IntegerRangeType + ) { + return []; + } + + $isSmaller = $maxType->isSmallerThan($minType, $this->phpVersion); + + if ($isSmaller->yes() || $isSmaller->maybe() && $this->reportMaybes) { + $message = 'Parameter #1 $min (%s) of function random_int expects lower number than parameter #2 $max (%s).'; + return [ + RuleErrorBuilder::message(sprintf( + $message, + $minType->describe(VerbosityLevel::value()), + $maxType->describe(VerbosityLevel::value()), + ))->identifier('argument.type')->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/RedefinedParametersRule.php b/src/Rules/Functions/RedefinedParametersRule.php new file mode 100644 index 00000000..b3159ddc --- /dev/null +++ b/src/Rules/Functions/RedefinedParametersRule.php @@ -0,0 +1,61 @@ + + */ +final class RedefinedParametersRule implements Rule +{ + + public function getNodeType(): string + { + return Node\FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $params = $node->getParams(); + + if (count($params) <= 1) { + return []; + } + + $vars = []; + $errors = []; + + foreach ($params as $param) { + if (!$param->var instanceof Node\Expr\Variable) { + continue; + } + + if (!is_string($param->var->name)) { + continue; + } + + $var = $param->var->name; + + if (!isset($vars[$var])) { + $vars[$var] = true; + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Redefinition of parameter $%s.', $var)) + ->identifier('parameter.duplicate') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/ReturnNullsafeByRefRule.php b/src/Rules/Functions/ReturnNullsafeByRefRule.php new file mode 100644 index 00000000..555232ba --- /dev/null +++ b/src/Rules/Functions/ReturnNullsafeByRefRule.php @@ -0,0 +1,55 @@ + + */ +final class ReturnNullsafeByRefRule implements Rule +{ + + public function __construct(private NullsafeCheck $nullsafeCheck) + { + } + + public function getNodeType(): string + { + return ReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->returnsByRef()) { + return []; + } + + $errors = []; + foreach ($node->getReturnStatements() as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ($returnNode->expr === null) { + continue; + } + + if (!$this->nullsafeCheck->containsNullSafe($returnNode->expr)) { + continue; + } + + $errors[] = RuleErrorBuilder::message('Nullsafe cannot be returned by reference.') + ->line($returnNode->getStartLine()) + ->identifier('nullsafe.byRef') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/ReturnTypeRule.php b/src/Rules/Functions/ReturnTypeRule.php new file mode 100644 index 00000000..c5290287 --- /dev/null +++ b/src/Rules/Functions/ReturnTypeRule.php @@ -0,0 +1,71 @@ + + */ +final class ReturnTypeRule implements Rule +{ + + public function __construct( + private FunctionReturnTypeCheck $returnTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return Return_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($scope->getFunction() === null) { + return []; + } + + if ($scope->isInAnonymousFunction()) { + return []; + } + + $function = $scope->getFunction(); + if ($function instanceof MethodReflection) { + return []; + } + + return $this->returnTypeCheck->checkReturnType( + $scope, + $function->getReturnType(), + $node->expr, + $node, + sprintf( + 'Function %s() should return %%s but empty return statement found.', + $function->getName(), + ), + sprintf( + 'Function %s() with return type void returns %%s but should not return anything.', + $function->getName(), + ), + sprintf( + 'Function %s() should return %%s but returns %%s.', + $function->getName(), + ), + sprintf( + 'Function %s() should never return but return statement found.', + $function->getName(), + ), + $function->isGenerator(), + ); + } + +} diff --git a/src/Rules/Functions/SortParameterCastableToStringRule.php b/src/Rules/Functions/SortParameterCastableToStringRule.php new file mode 100644 index 00000000..9676d69d --- /dev/null +++ b/src/Rules/Functions/SortParameterCastableToStringRule.php @@ -0,0 +1,151 @@ + + */ +final class SortParameterCastableToStringRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ParameterCastableToStringCheck $parameterCastableToStringCheck, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + + if (!in_array($functionName, ['array_unique', 'sort', 'rsort', 'asort', 'arsort'], true)) { + return []; + } + + $origArgs = $node->getArgs(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $origArgs, + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $functionParameters = $parametersAcceptor->getParameters(); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if ($normalizedFuncCall === null) { + return []; + } + + $normalizedArgs = $normalizedFuncCall->getArgs(); + if (!array_key_exists(0, $normalizedArgs)) { + return []; + } + + $argsToCheck = [0 => $normalizedArgs[0]]; + $flags = null; + if (array_key_exists(1, $normalizedArgs)) { + $flags = $scope->getType($normalizedArgs[1]->value); + } elseif (array_key_exists(1, $functionParameters)) { + $flags = $functionParameters[1]->getDefaultValue(); + } + + if ($flags === null || $flags->equals(new ConstantIntegerType(SORT_REGULAR))) { + return []; + } + + $constantIntFlags = TypeUtils::getConstantIntegers($flags); + $mustBeCastableToString = $mustBeCastableToFloat = $constantIntFlags === []; + + foreach ($constantIntFlags as $flag) { + if ($flag->getValue() === SORT_NUMERIC) { + $mustBeCastableToFloat = true; + } elseif (in_array($flag->getValue() & (~SORT_FLAG_CASE), [SORT_STRING, SORT_LOCALE_STRING, SORT_NATURAL], true)) { + $mustBeCastableToString = true; + } + } + + if ($mustBeCastableToString && !$mustBeCastableToFloat) { + $errorMessage = 'Parameter %s of function %s expects an array of values castable to string, %s given.'; + $castFn = static fn (Type $t) => $t->toString(); + } elseif ($mustBeCastableToString) { + $errorMessage = 'Parameter %s of function %s expects an array of values castable to string and float, %s given.'; + $castFn = static function (Type $t): Type { + $float = $t->toFloat(); + + return $float instanceof ErrorType + ? $float + : $t->toString(); + }; + } elseif ($mustBeCastableToFloat) { + $errorMessage = 'Parameter %s of function %s expects an array of values castable to float, %s given.'; + $castFn = static fn (Type $t) => $t->toFloat(); + } else { + return []; + } + + $errors = []; + + foreach ($argsToCheck as $argIdx => $arg) { + $error = $this->parameterCastableToStringCheck->checkParameter( + $arg, + $scope, + $errorMessage, + $castFn, + $functionName, + $this->parameterCastableToStringCheck->getParameterName( + $arg, + $argIdx, + $functionParameters[$argIdx] ?? null, + ), + ); + + if ($error === null) { + continue; + } + + $errors[] = $error; + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/UnusedClosureUsesRule.php b/src/Rules/Functions/UnusedClosureUsesRule.php new file mode 100644 index 00000000..e525a291 --- /dev/null +++ b/src/Rules/Functions/UnusedClosureUsesRule.php @@ -0,0 +1,43 @@ + + */ +final class UnusedClosureUsesRule implements Rule +{ + + public function __construct(private UnusedFunctionParametersCheck $check) + { + } + + public function getNodeType(): string + { + return Node\Expr\Closure::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($node->uses) === 0) { + return []; + } + + return $this->check->getUnusedParameters( + $scope, + array_map(static fn (Node\ClosureUse $use): Node\Expr\Variable => $use->var, $node->uses), + $node->stmts, + 'Anonymous function has an unused use $%s.', + 'closure.unusedUse', + ); + } + +} diff --git a/src/Rules/Functions/UselessFunctionReturnValueRule.php b/src/Rules/Functions/UselessFunctionReturnValueRule.php new file mode 100644 index 00000000..509acdea --- /dev/null +++ b/src/Rules/Functions/UselessFunctionReturnValueRule.php @@ -0,0 +1,90 @@ + + */ +final class UselessFunctionReturnValueRule implements Rule +{ + + private const USELESS_FUNCTIONS = [ + 'var_export' => 'null', + 'print_r' => 'true', + 'highlight_string' => 'true', + ]; + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $funcCall, Scope $scope): array + { + if (!($funcCall->name instanceof Node\Name) || $scope->isInFirstLevelStatement()) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($funcCall->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($funcCall->name, $scope); + + if (!array_key_exists($functionReflection->getName(), self::USELESS_FUNCTIONS)) { + return []; + } + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $funcCall->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $reorderedFuncCall = ArgumentsNormalizer::reorderFuncArguments( + $parametersAcceptor, + $funcCall, + ); + + if ($reorderedFuncCall === null) { + return []; + } + $reorderedArgs = $reorderedFuncCall->getArgs(); + + if (count($reorderedArgs) === 1 || (count($reorderedArgs) >= 2 && $scope->getType($reorderedArgs[1]->value)->isFalse()->yes())) { + return [RuleErrorBuilder::message( + sprintf( + 'Return value of function %s() is always %s and the result is printed instead of being returned. Pass in true as parameter #%d $%s to return the output instead.', + $functionReflection->getName(), + self::USELESS_FUNCTIONS[$functionReflection->getName()], + 2, + $parametersAcceptor->getParameters()[1]->getName(), + ), + ) + ->identifier('function.uselessReturnValue') + ->line($funcCall->getStartLine()) + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/VariadicParametersDeclarationRule.php b/src/Rules/Functions/VariadicParametersDeclarationRule.php new file mode 100644 index 00000000..cdaba9a4 --- /dev/null +++ b/src/Rules/Functions/VariadicParametersDeclarationRule.php @@ -0,0 +1,52 @@ + + */ +final class VariadicParametersDeclarationRule implements Rule +{ + + public function getNodeType(): string + { + return Node\FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $parameters = $node->getParams(); + $paramCount = count($parameters); + + if ($paramCount === 0) { + return []; + } + + $errors = []; + + foreach ($parameters as $index => $parameter) { + if (!$parameter->variadic) { + continue; + } + + if ($paramCount - 1 === $index) { + continue; + } + + $errors[] = RuleErrorBuilder::message('Only the last parameter can be variadic.') + ->nonIgnorable() + ->identifier('parameter.variadicNotLast') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Generators/YieldFromTypeRule.php b/src/Rules/Generators/YieldFromTypeRule.php new file mode 100644 index 00000000..e8313a73 --- /dev/null +++ b/src/Rules/Generators/YieldFromTypeRule.php @@ -0,0 +1,146 @@ + + */ +final class YieldFromTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return YieldFrom::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $exprType = $scope->getType($node->expr); + $isIterable = $exprType->isIterable(); + $messagePattern = 'Argument of an invalid type %s passed to yield from, only iterables are supported.'; + if ($isIterable->no()) { + return [ + RuleErrorBuilder::message(sprintf( + $messagePattern, + $exprType->describe(VerbosityLevel::typeOnly()), + )) + ->line($node->expr->getStartLine()) + ->identifier('generator.nonIterable') + ->build(), + ]; + } elseif ( + !$exprType instanceof MixedType + && $this->reportMaybes + && $isIterable->maybe() + ) { + return [ + RuleErrorBuilder::message(sprintf( + $messagePattern, + $exprType->describe(VerbosityLevel::typeOnly()), + )) + ->line($node->expr->getStartLine()) + ->identifier('generator.nonIterable') + ->build(), + ]; + } + + $anonymousFunctionReturnType = $scope->getAnonymousFunctionReturnType(); + $scopeFunction = $scope->getFunction(); + if ($anonymousFunctionReturnType !== null) { + $returnType = $anonymousFunctionReturnType; + } elseif ($scopeFunction !== null) { + $returnType = $scopeFunction->getReturnType(); + } else { + return []; // already reported by YieldInGeneratorRule + } + + if ($returnType instanceof MixedType) { + return []; + } + + $messages = []; + $acceptsKey = $this->ruleLevelHelper->accepts($returnType->getIterableKeyType(), $exprType->getIterableKeyType(), $scope->isDeclareStrictTypes()); + if (!$acceptsKey->result) { + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType->getIterableKeyType(), $exprType->getIterableKeyType()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects key type %s, %s given.', + $returnType->getIterableKeyType()->describe($verbosityLevel), + $exprType->getIterableKeyType()->describe($verbosityLevel), + )) + ->line($node->expr->getStartLine()) + ->identifier('generator.keyType') + ->acceptsReasonsTip($acceptsKey->reasons) + ->build(); + } + + $acceptsValue = $this->ruleLevelHelper->accepts($returnType->getIterableValueType(), $exprType->getIterableValueType(), $scope->isDeclareStrictTypes()); + if (!$acceptsValue->result) { + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType->getIterableValueType(), $exprType->getIterableValueType()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects value type %s, %s given.', + $returnType->getIterableValueType()->describe($verbosityLevel), + $exprType->getIterableValueType()->describe($verbosityLevel), + )) + ->line($node->expr->getStartLine()) + ->identifier('generator.valueType') + ->acceptsReasonsTip($acceptsValue->reasons) + ->build(); + } + + $scopeFunction = $scope->getFunction(); + if ($scopeFunction === null) { + return $messages; + } + + $currentReturnType = $scopeFunction->getReturnType(); + $exprSendType = $exprType->getTemplateType(Generator::class, 'TSend'); + $thisSendType = $currentReturnType->getTemplateType(Generator::class, 'TSend'); + if ($exprSendType instanceof ErrorType || $thisSendType instanceof ErrorType) { + return $messages; + } + + $isSuperType = $exprSendType->isSuperTypeOf($thisSendType); + if ($isSuperType->no()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects delegated TSend type %s, %s given.', + $exprSendType->describe(VerbosityLevel::typeOnly()), + $thisSendType->describe(VerbosityLevel::typeOnly()), + ))->identifier('generator.sendType')->build(); + } elseif ($this->reportMaybes && !$isSuperType->yes()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects delegated TSend type %s, %s given.', + $exprSendType->describe(VerbosityLevel::typeOnly()), + $thisSendType->describe(VerbosityLevel::typeOnly()), + ))->identifier('generator.sendType')->build(); + } + + if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + $messages[] = RuleErrorBuilder::message('Result of yield from (void) is used.') + ->identifier('generator.void') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Generators/YieldInGeneratorRule.php b/src/Rules/Generators/YieldInGeneratorRule.php new file mode 100644 index 00000000..16b627b8 --- /dev/null +++ b/src/Rules/Generators/YieldInGeneratorRule.php @@ -0,0 +1,78 @@ + + */ +final class YieldInGeneratorRule implements Rule +{ + + public function __construct(private bool $reportMaybes) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof Node\Expr\Yield_ && !$node instanceof Node\Expr\YieldFrom) { + return []; + } + + $anonymousFunctionReturnType = $scope->getAnonymousFunctionReturnType(); + $scopeFunction = $scope->getFunction(); + if ($anonymousFunctionReturnType !== null) { + $returnType = $anonymousFunctionReturnType; + } elseif ($scopeFunction !== null) { + $returnType = $scopeFunction->getReturnType(); + } else { + return [ + RuleErrorBuilder::message('Yield can be used only inside a function.') + ->identifier('generator.outOfFunction') + ->nonIgnorable() + ->build(), + ]; + } + + if ($returnType instanceof MixedType) { + return []; + } + + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + $isSuperType = TrinaryLogic::createNo(); + } else { + $isSuperType = $returnType->isIterable()->and(TrinaryLogic::createFromBoolean( + !$returnType->isArray()->yes(), + )); + } + if ($isSuperType->yes()) { + return []; + } + + if ($isSuperType->maybe() && !$this->reportMaybes) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Yield can be used only with these return types: %s.', + 'Generator, Iterator, Traversable, iterable', + ))->identifier('generator.returnType')->build(), + ]; + } + +} diff --git a/src/Rules/Generators/YieldTypeRule.php b/src/Rules/Generators/YieldTypeRule.php new file mode 100644 index 00000000..11d77c57 --- /dev/null +++ b/src/Rules/Generators/YieldTypeRule.php @@ -0,0 +1,97 @@ + + */ +final class YieldTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Yield_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $anonymousFunctionReturnType = $scope->getAnonymousFunctionReturnType(); + $scopeFunction = $scope->getFunction(); + if ($anonymousFunctionReturnType !== null) { + $returnType = $anonymousFunctionReturnType; + } elseif ($scopeFunction !== null) { + $returnType = $scopeFunction->getReturnType(); + } else { + return []; // already reported by YieldInGeneratorRule + } + + if ($returnType instanceof MixedType) { + return []; + } + + if ($node->key === null) { + $keyType = new IntegerType(); + } else { + $keyType = $scope->getType($node->key); + } + + $messages = []; + $acceptsKey = $this->ruleLevelHelper->accepts($returnType->getIterableKeyType(), $keyType, $scope->isDeclareStrictTypes()); + if (!$acceptsKey->result) { + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType->getIterableKeyType(), $keyType); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects key type %s, %s given.', + $returnType->getIterableKeyType()->describe($verbosityLevel), + $keyType->describe($verbosityLevel), + )) + ->acceptsReasonsTip($acceptsKey->reasons) + ->identifier('generator.keyType') + ->build(); + } + + if ($node->value === null) { + $valueType = new NullType(); + } else { + $valueType = $scope->getType($node->value); + } + + $acceptsValue = $this->ruleLevelHelper->accepts($returnType->getIterableValueType(), $valueType, $scope->isDeclareStrictTypes()); + if (!$acceptsValue->result) { + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($returnType->getIterableValueType(), $valueType); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Generator expects value type %s, %s given.', + $returnType->getIterableValueType()->describe($verbosityLevel), + $valueType->describe($verbosityLevel), + )) + ->acceptsReasonsTip($acceptsValue->reasons) + ->identifier('generator.valueType') + ->build(); + } + if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + $messages[] = RuleErrorBuilder::message('Result of yield (void) is used.') + ->identifier('generator.void') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Generics/ClassAncestorsRule.php b/src/Rules/Generics/ClassAncestorsRule.php new file mode 100644 index 00000000..88a1294c --- /dev/null +++ b/src/Rules/Generics/ClassAncestorsRule.php @@ -0,0 +1,90 @@ + + */ +final class ClassAncestorsRule implements Rule +{ + + public function __construct( + private GenericAncestorsCheck $genericAncestorsCheck, + private CrossCheckInterfacesHelper $crossCheckInterfacesHelper, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + if (!$originalNode instanceof Node\Stmt\Class_) { + return []; + } + $classReflection = $node->getClassReflection(); + if ($classReflection->isAnonymous()) { + return []; + } + $className = $classReflection->getName(); + $escapedClassName = SprintfHelper::escapeFormatString($className); + + $extendsErrors = $this->genericAncestorsCheck->check( + $originalNode->extends !== null ? [$originalNode->extends] : [], + array_map(static fn (ExtendsTag $tag): Type => $tag->getType(), $classReflection->getExtendsTags()), + sprintf('Class %s @extends tag contains incompatible type %%s.', $escapedClassName), + sprintf('Class %s @extends tag contains unresolvable type.', $className), + sprintf('Class %s has @extends tag, but does not extend any class.', $escapedClassName), + sprintf('The @extends tag of class %s describes %%s but the class extends %%s.', $escapedClassName), + 'PHPDoc tag @extends contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @extends does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @extends specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @extends is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @extends is not allowed.', + 'PHPDoc tag @extends has invalid type %s.', + sprintf('Class %s extends generic class %%s but does not specify its types: %%s', $escapedClassName), + sprintf('in extended type %%s of class %s', $escapedClassName), + ); + + $implementsErrors = $this->genericAncestorsCheck->check( + $originalNode->implements, + array_map(static fn (ImplementsTag $tag): Type => $tag->getType(), $classReflection->getImplementsTags()), + sprintf('Class %s @implements tag contains incompatible type %%s.', $escapedClassName), + sprintf('Class %s @implements tag contains unresolvable type.', $className), + sprintf('Class %s has @implements tag, but does not implement any interface.', $escapedClassName), + sprintf('The @implements tag of class %s describes %%s but the class implements: %%s', $escapedClassName), + 'PHPDoc tag @implements contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @implements does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @implements specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @implements is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @implements is not allowed.', + 'PHPDoc tag @implements has invalid type %s.', + sprintf('Class %s implements generic interface %%s but does not specify its types: %%s', $escapedClassName), + sprintf('in implemented type %%s of class %s', $escapedClassName), + ); + + foreach ($this->crossCheckInterfacesHelper->check($classReflection) as $error) { + $implementsErrors[] = $error; + } + + return array_merge($extendsErrors, $implementsErrors); + } + +} diff --git a/src/Rules/Generics/ClassTemplateTypeRule.php b/src/Rules/Generics/ClassTemplateTypeRule.php new file mode 100644 index 00000000..77ac03cd --- /dev/null +++ b/src/Rules/Generics/ClassTemplateTypeRule.php @@ -0,0 +1,59 @@ + + */ +final class ClassTemplateTypeRule implements Rule +{ + + public function __construct( + private TemplateTypeCheck $templateTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$classReflection->isClass()) { + return []; + } + $className = $classReflection->getName(); + if ($classReflection->isAnonymous()) { + $displayName = 'anonymous class'; + } else { + $displayName = 'class ' . SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + } + + return $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithClass($className), + $classReflection->getTemplateTags(), + sprintf('PHPDoc tag @template for %s cannot have existing class %%s as its name.', $displayName), + sprintf('PHPDoc tag @template for %s cannot have existing type alias %%s as its name.', $displayName), + sprintf('PHPDoc tag @template %%s for %s has invalid bound type %%s.', $displayName), + sprintf('PHPDoc tag @template %%s for %s with bound type %%s is not supported.', $displayName), + sprintf('PHPDoc tag @template %%s for %s has invalid default type %%s.', $displayName), + sprintf('Default type %%s in PHPDoc tag @template %%s for %s is not subtype of bound type %%s.', $displayName), + sprintf('PHPDoc tag @template %%s for %s does not have a default type but follows an optional @template %%s.', $displayName), + ); + } + +} diff --git a/src/Rules/Generics/CrossCheckInterfacesHelper.php b/src/Rules/Generics/CrossCheckInterfacesHelper.php new file mode 100644 index 00000000..51c5d53a --- /dev/null +++ b/src/Rules/Generics/CrossCheckInterfacesHelper.php @@ -0,0 +1,95 @@ + + */ + public function check(ClassReflection $classReflection): array + { + $interfaceTemplateTypeMaps = []; + $errors = []; + $check = static function (ClassReflection $classReflection, bool $first) use (&$interfaceTemplateTypeMaps, &$check, &$errors): void { + foreach ($classReflection->getInterfaces() as $interface) { + if (!$interface->isGeneric()) { + continue; + } + + if (array_key_exists($interface->getName(), $interfaceTemplateTypeMaps)) { + $otherMap = $interfaceTemplateTypeMaps[$interface->getName()]; + foreach ($interface->getActiveTemplateTypeMap()->getTypes() as $name => $type) { + $otherType = $otherMap->getType($name); + if ($otherType === null) { + continue; + } + + if ($type->equals($otherType)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s specifies template type %s of interface %s as %s but it\'s already specified as %s.', + $classReflection->isInterface() ? sprintf('Interface %s', $classReflection->getName()) : sprintf('Class %s', $classReflection->getName()), + $name, + $interface->getName(), + $type->describe(VerbosityLevel::value()), + $otherType->describe(VerbosityLevel::value()), + ))->identifier('generics.interfaceConflict')->build(); + } + continue; + } + + $interfaceTemplateTypeMaps[$interface->getName()] = $interface->getActiveTemplateTypeMap(); + } + + $parent = $classReflection->getParentClass(); + $checkParents = true; + if ($first && $parent !== null) { + $extendsTags = $classReflection->getExtendsTags(); + if (!array_key_exists($parent->getName(), $extendsTags)) { + $checkParents = false; + } + } + + if ($checkParents) { + while ($parent !== null) { + $check($parent, false); + $parent = $parent->getParentClass(); + } + } + + $interfaceTags = []; + if ($first) { + if ($classReflection->isInterface()) { + $interfaceTags = $classReflection->getExtendsTags(); + } else { + $interfaceTags = $classReflection->getImplementsTags(); + } + } + foreach ($classReflection->getInterfaces() as $interface) { + if ($first) { + if (!array_key_exists($interface->getName(), $interfaceTags)) { + continue; + } + } + $check($interface, false); + } + }; + + $check($classReflection, true); + + return $errors; + } + +} diff --git a/src/Rules/Generics/EnumAncestorsRule.php b/src/Rules/Generics/EnumAncestorsRule.php new file mode 100644 index 00000000..3259df27 --- /dev/null +++ b/src/Rules/Generics/EnumAncestorsRule.php @@ -0,0 +1,88 @@ + + */ +final class EnumAncestorsRule implements Rule +{ + + public function __construct( + private GenericAncestorsCheck $genericAncestorsCheck, + private CrossCheckInterfacesHelper $crossCheckInterfacesHelper, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + if (!$originalNode instanceof Node\Stmt\Enum_) { + return []; + } + $classReflection = $node->getClassReflection(); + + $enumName = $classReflection->getName(); + $escapedEnumName = SprintfHelper::escapeFormatString($enumName); + + $extendsErrors = $this->genericAncestorsCheck->check( + [], + array_map(static fn (ExtendsTag $tag): Type => $tag->getType(), $classReflection->getExtendsTags()), + sprintf('Enum %s @extends tag contains incompatible type %%s.', $escapedEnumName), + sprintf('Enum %s @extends tag contains unresolvable type.', $enumName), + sprintf('Enum %s has @extends tag, but cannot extend anything.', $escapedEnumName), + '', + '', + '', + '', + '', + '', + '', + '', + '', + ); + + $implementsErrors = $this->genericAncestorsCheck->check( + $originalNode->implements, + array_map(static fn (ImplementsTag $tag): Type => $tag->getType(), $classReflection->getImplementsTags()), + sprintf('Enum %s @implements tag contains incompatible type %%s.', $escapedEnumName), + sprintf('Enum %s @implements tag contains unresolvable type.', $enumName), + sprintf('Enum %s has @implements tag, but does not implement any interface.', $escapedEnumName), + sprintf('The @implements tag of eunm %s describes %%s but the enum implements: %%s', $escapedEnumName), + 'PHPDoc tag @implements contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @implements does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @implements specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @implements is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @implements is not allowed.', + 'PHPDoc tag @implements has invalid type %s.', + sprintf('Enum %s implements generic interface %%s but does not specify its types: %%s', $escapedEnumName), + sprintf('in implemented type %%s of enum %s', $escapedEnumName), + ); + + foreach ($this->crossCheckInterfacesHelper->check($classReflection) as $error) { + $implementsErrors[] = $error; + } + + return array_merge($extendsErrors, $implementsErrors); + } + +} diff --git a/src/Rules/Generics/EnumTemplateTypeRule.php b/src/Rules/Generics/EnumTemplateTypeRule.php new file mode 100644 index 00000000..7c07e7c9 --- /dev/null +++ b/src/Rules/Generics/EnumTemplateTypeRule.php @@ -0,0 +1,46 @@ + + */ +final class EnumTemplateTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$classReflection->isEnum()) { + return []; + } + + $templateTagsCount = count($classReflection->getTemplateTags()); + if ($templateTagsCount === 0) { + return []; + } + + $className = $classReflection->getDisplayName(); + + return [ + RuleErrorBuilder::message(sprintf('Enum %s has PHPDoc @template tag%s but enums cannot be generic.', $className, $templateTagsCount === 1 ? '' : 's')) + ->identifier('enum.generic') + ->build(), + ]; + } + +} diff --git a/src/Rules/Generics/FunctionSignatureVarianceRule.php b/src/Rules/Generics/FunctionSignatureVarianceRule.php new file mode 100644 index 00000000..2a7e45c5 --- /dev/null +++ b/src/Rules/Generics/FunctionSignatureVarianceRule.php @@ -0,0 +1,45 @@ + + */ +final class FunctionSignatureVarianceRule implements Rule +{ + + public function __construct(private VarianceCheck $varianceCheck) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functionReflection = $node->getFunctionReflection(); + $functionName = $functionReflection->getName(); + + return $this->varianceCheck->checkParametersAcceptor( + $functionReflection, + sprintf('in parameter %%s of function %s()', SprintfHelper::escapeFormatString($functionName)), + sprintf('in param-out type of parameter %%s of function %s()', SprintfHelper::escapeFormatString($functionName)), + sprintf('in return type of function %s()', $functionName), + sprintf('in function %s()', $functionName), + false, + false, + 'function', + ); + } + +} diff --git a/src/Rules/Generics/FunctionTemplateTypeRule.php b/src/Rules/Generics/FunctionTemplateTypeRule.php new file mode 100644 index 00000000..0d46bbfe --- /dev/null +++ b/src/Rules/Generics/FunctionTemplateTypeRule.php @@ -0,0 +1,70 @@ + + */ +final class FunctionTemplateTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private TemplateTypeCheck $templateTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Function_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + if (!isset($node->namespacedName)) { + throw new ShouldNotHappenException(); + } + + $functionName = (string) $node->namespacedName; + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + null, + null, + $functionName, + $docComment->getText(), + ); + + $escapedFunctionName = SprintfHelper::escapeFormatString($functionName); + + return $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithFunction($functionName), + $resolvedPhpDoc->getTemplateTags(), + sprintf('PHPDoc tag @template for function %s() cannot have existing class %%s as its name.', $escapedFunctionName), + sprintf('PHPDoc tag @template for function %s() cannot have existing type alias %%s as its name.', $escapedFunctionName), + sprintf('PHPDoc tag @template %%s for function %s() has invalid bound type %%s.', $escapedFunctionName), + sprintf('PHPDoc tag @template %%s for function %s() with bound type %%s is not supported.', $escapedFunctionName), + sprintf('PHPDoc tag @template %%s for function %s() has invalid default type %%s.', $escapedFunctionName), + sprintf('Default type %%s in PHPDoc tag @template %%s for function %s() is not subtype of bound type %%s.', $escapedFunctionName), + sprintf('PHPDoc tag @template %%s for function %s() does not have a default type but follows an optional @template %%s.', $escapedFunctionName), + ); + } + +} diff --git a/src/Rules/Generics/GenericAncestorsCheck.php b/src/Rules/Generics/GenericAncestorsCheck.php new file mode 100644 index 00000000..cbd76c6f --- /dev/null +++ b/src/Rules/Generics/GenericAncestorsCheck.php @@ -0,0 +1,197 @@ + $nameNodes + * @param array $ancestorTypes + * @return list + */ + public function check( + array $nameNodes, + array $ancestorTypes, + string $incompatibleTypeMessage, + string $unresolvableTypeMessage, + string $noNamesMessage, + string $noRelatedNameMessage, + string $classNotGenericMessage, + string $notEnoughTypesMessage, + string $extraTypesMessage, + string $typeIsNotSubtypeMessage, + string $typeProjectionIsNotAllowedMessage, + string $invalidTypeMessage, + string $genericClassInNonGenericObjectType, + string $invalidVarianceMessage, + ): array + { + $names = array_fill_keys(array_map(static fn (Name $nameNode): string => $nameNode->toString(), $nameNodes), true); + + $unusedNames = $names; + + $messages = []; + foreach ($ancestorTypes as $ancestorType) { + if (!$ancestorType instanceof GenericObjectType) { + $messages[] = RuleErrorBuilder::message(sprintf($incompatibleTypeMessage, $ancestorType->describe(VerbosityLevel::typeOnly()))) + ->identifier('generics.notCompatible') + ->build(); + continue; + } + + $ancestorTypeClassName = $ancestorType->getClassName(); + if (!isset($names[$ancestorTypeClassName])) { + if (count($names) === 0) { + $messages[] = RuleErrorBuilder::message($noNamesMessage) + ->identifier('generics.noParent') + ->build(); + } else { + $messages[] = RuleErrorBuilder::message(sprintf($noRelatedNameMessage, $ancestorTypeClassName, implode(', ', array_keys($names)))) + ->identifier('generics.wrongParent') + ->build(); + } + + continue; + } + + unset($unusedNames[$ancestorTypeClassName]); + + $genericObjectTypeCheckMessages = $this->genericObjectTypeCheck->check( + $ancestorType, + $classNotGenericMessage, + $notEnoughTypesMessage, + $extraTypesMessage, + $typeIsNotSubtypeMessage, + '', + '', + ); + $messages = array_merge($messages, $genericObjectTypeCheckMessages); + + if ($this->unresolvableTypeHelper->containsUnresolvableType($ancestorType)) { + $messages[] = RuleErrorBuilder::message($unresolvableTypeMessage) + ->identifier('generics.unresolvable') + ->build(); + } + + foreach ($ancestorType->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + $messages[] = RuleErrorBuilder::message(sprintf($invalidTypeMessage, $referencedClass)) + ->identifier('class.notFound') + ->build(); + continue; + } + + if ($referencedClass === $ancestorType->getClassName()) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($referencedClass); + if (!$classReflection->isTrait()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf($invalidTypeMessage, $referencedClass)) + ->identifier('generics.trait') + ->build(); + } + + $variance = TemplateTypeVariance::createStatic(); + $messageContext = sprintf( + $invalidVarianceMessage, + $ancestorType->describe(VerbosityLevel::typeOnly()), + ); + foreach ($this->varianceCheck->check($variance, $ancestorType, $messageContext) as $message) { + $messages[] = $message; + } + + foreach ($ancestorType->getVariances() as $index => $typeVariance) { + if ($typeVariance->invariant()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $typeProjectionIsNotAllowedMessage, + TypeProjectionHelper::describe($ancestorType->getTypes()[$index], $typeVariance, VerbosityLevel::typeOnly()), + $ancestorType->describe(VerbosityLevel::typeOnly()), + ))->identifier('generics.callSiteVarianceNotAllowed')->build(); + } + } + + if ($this->checkMissingTypehints) { + foreach (array_keys($unusedNames) as $unusedName) { + if (!$this->reflectionProvider->hasClass($unusedName)) { + continue; + } + + $unusedNameClassReflection = $this->reflectionProvider->getClass($unusedName); + if (in_array($unusedNameClassReflection->getName(), $this->skipCheckGenericClasses, true)) { + continue; + } + if (!$unusedNameClassReflection->isGeneric()) { + continue; + } + + $templateTypes = $unusedNameClassReflection->getTemplateTypeMap()->getTypes(); + $templateTypesCount = count($templateTypes); + $requiredTemplateTypesCount = count(array_filter($templateTypes, static fn (Type $type) => $type instanceof TemplateType && $type->getDefault() === null)); + if ($requiredTemplateTypesCount === 0) { + continue; + } + + $templateTypesList = implode(', ', array_keys($templateTypes)); + if ($requiredTemplateTypesCount !== $templateTypesCount) { + $templateTypesList .= sprintf(' (%d-%d required)', $requiredTemplateTypesCount, $templateTypesCount); + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $genericClassInNonGenericObjectType, + $unusedName, + $templateTypesList, + )) + ->identifier('missingType.generics') + ->build(); + } + } + + return $messages; + } + +} diff --git a/src/Rules/Generics/GenericObjectTypeCheck.php b/src/Rules/Generics/GenericObjectTypeCheck.php new file mode 100644 index 00000000..6971ecae --- /dev/null +++ b/src/Rules/Generics/GenericObjectTypeCheck.php @@ -0,0 +1,193 @@ + + */ + public function check( + Type $phpDocType, + string $classNotGenericMessage, + string $notEnoughTypesMessage, + string $extraTypesMessage, + string $typeIsNotSubtypeMessage, + string $typeProjectionHasConflictingVarianceMessage, + string $typeProjectionIsRedundantMessage, + ): array + { + $genericTypes = $this->getGenericTypes($phpDocType); + $messages = []; + foreach ($genericTypes as $genericType) { + $classReflection = $genericType->getClassReflection(); + if ($classReflection === null) { + continue; + } + + $classLikeDescription = strtolower($classReflection->getClassTypeDescription()); + if (!$classReflection->isGeneric()) { + $messages[] = RuleErrorBuilder::message(sprintf($classNotGenericMessage, $genericType->describe(VerbosityLevel::typeOnly()), $classLikeDescription, $classReflection->getDisplayName())) + ->identifier('generics.notGeneric') + ->build(); + continue; + } + + $templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes()); + + $genericTypeTypes = $genericType->getTypes(); + $genericTypeVariances = $genericType->getVariances(); + $templateTypesCount = count($templateTypes); + $genericTypeTypesCount = count($genericTypeTypes); + $requiredTemplateTypesCount = count(array_filter($templateTypes, static fn (Type $type) => $type instanceof TemplateType && $type->getDefault() === null)); + if ($requiredTemplateTypesCount > $genericTypeTypesCount) { + $templateTypesList = implode(', ', array_keys($classReflection->getTemplateTypeMap()->getTypes())); + if ($requiredTemplateTypesCount !== $templateTypesCount) { + $templateTypesList .= sprintf(' (%d-%d required).', $requiredTemplateTypesCount, $templateTypesCount); + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $notEnoughTypesMessage, + $genericType->describe(VerbosityLevel::typeOnly()), + $classLikeDescription, + $classReflection->getDisplayName(false), + $templateTypesList, + ))->identifier('generics.lessTypes')->build(); + } elseif ($templateTypesCount < $genericTypeTypesCount) { + $templateTypesList = implode(', ', array_keys($classReflection->getTemplateTypeMap()->getTypes())); + if ($requiredTemplateTypesCount !== $templateTypesCount) { + $templateTypesList .= sprintf(' (%d-%d required)', $requiredTemplateTypesCount, $templateTypesCount); + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $extraTypesMessage, + $genericType->describe(VerbosityLevel::typeOnly()), + $genericTypeTypesCount, + $classLikeDescription, + $classReflection->getDisplayName(false), + $templateTypesCount, + $templateTypesList, + ))->identifier('generics.moreTypes')->build(); + } + + for ($i = 0; $i < $templateTypesCount; $i++) { + if (!isset($genericTypeTypes[$i])) { + continue; + } + + $templateType = $templateTypes[$i]; + $genericTypeType = $genericTypeTypes[$i]; + + $genericTypeVariance = $genericTypeVariances[$i] ?? TemplateTypeVariance::createInvariant(); + if ($templateType instanceof TemplateType && !$genericTypeVariance->invariant()) { + if ($genericTypeVariance->equals($templateType->getVariance())) { + $messages[] = RuleErrorBuilder::message(sprintf( + $typeProjectionIsRedundantMessage, + TypeProjectionHelper::describe($genericTypeType, $genericTypeVariance, VerbosityLevel::typeOnly()), + $genericType->describe(VerbosityLevel::typeOnly()), + $templateType->describe(VerbosityLevel::typeOnly()), + $classLikeDescription, + $classReflection->getDisplayName(false), + )) + ->identifier('generics.callSiteVarianceRedundant') + ->tip('You can safely remove the call-site variance annotation.') + ->build(); + } elseif (!$genericTypeVariance->validPosition($templateType->getVariance())) { + $messages[] = RuleErrorBuilder::message(sprintf( + $typeProjectionHasConflictingVarianceMessage, + TypeProjectionHelper::describe($genericTypeType, $genericTypeVariance, VerbosityLevel::typeOnly()), + $genericType->describe(VerbosityLevel::typeOnly()), + $templateType->getVariance()->describe(), + $templateType->describe(VerbosityLevel::typeOnly()), + $classLikeDescription, + $classReflection->getDisplayName(false), + ))->identifier('generics.callSiteVarianceConflict')->build(); + } + } + + $boundType = TemplateTypeHelper::resolveToBounds($templateType); + if ($boundType->isSuperTypeOf($genericTypeType)->yes()) { + if (!$templateType instanceof TemplateType) { + continue; + } + $map = $templateType->inferTemplateTypes($genericTypeType); + for ($j = 0; $j < $templateTypesCount; $j++) { + if ($i === $j) { + continue; + } + + $templateTypes[$j] = TemplateTypeHelper::resolveTemplateTypes( + $templateTypes[$j], + $map, + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createStatic(), + ); + } + continue; + } + + if ($genericTypeVariance->bivariant()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $typeIsNotSubtypeMessage, + $genericTypeType->describe(VerbosityLevel::typeOnly()), + $genericType->describe(VerbosityLevel::typeOnly()), + $templateType->describe(VerbosityLevel::typeOnly()), + $classLikeDescription, + $classReflection->getDisplayName(false), + ))->identifier('generics.notSubtype')->build(); + } + } + + return $messages; + } + + /** + * @return list + */ + private function getGenericTypes(Type $phpDocType): array + { + $genericObjectTypes = []; + TypeTraverser::map($phpDocType, static function (Type $type, callable $traverse) use (&$genericObjectTypes): Type { + if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { + $resolvedType = TemplateTypeHelper::resolveToBounds($type); + if (!$resolvedType instanceof GenericObjectType && !$resolvedType instanceof GenericStaticType) { + throw new ShouldNotHappenException(); + } + $genericObjectTypes[] = $resolvedType; + $traverse($type); + return $type; + } + $traverse($type); + return $type; + }); + + return $genericObjectTypes; + } + +} diff --git a/src/Rules/Generics/InterfaceAncestorsRule.php b/src/Rules/Generics/InterfaceAncestorsRule.php new file mode 100644 index 00000000..062a184d --- /dev/null +++ b/src/Rules/Generics/InterfaceAncestorsRule.php @@ -0,0 +1,88 @@ + + */ +final class InterfaceAncestorsRule implements Rule +{ + + public function __construct( + private GenericAncestorsCheck $genericAncestorsCheck, + private CrossCheckInterfacesHelper $crossCheckInterfacesHelper, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + if (!$originalNode instanceof Node\Stmt\Interface_) { + return []; + } + $classReflection = $node->getClassReflection(); + + $interfaceName = $classReflection->getName(); + $escapedInterfaceName = SprintfHelper::escapeFormatString($interfaceName); + + $extendsErrors = $this->genericAncestorsCheck->check( + $originalNode->extends, + array_map(static fn (ExtendsTag $tag): Type => $tag->getType(), $classReflection->getExtendsTags()), + sprintf('Interface %s @extends tag contains incompatible type %%s.', $escapedInterfaceName), + sprintf('Interface %s @extends tag contains unresolvable type.', $interfaceName), + sprintf('Interface %s has @extends tag, but does not extend any interface.', $escapedInterfaceName), + sprintf('The @extends tag of interface %s describes %%s but the interface extends: %%s', $escapedInterfaceName), + 'PHPDoc tag @extends contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @extends does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @extends specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @extends is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @extends is not allowed.', + 'PHPDoc tag @extends has invalid type %s.', + sprintf('Interface %s extends generic interface %%s but does not specify its types: %%s', $escapedInterfaceName), + sprintf('in extended type %%s of interface %s', $escapedInterfaceName), + ); + + $implementsErrors = $this->genericAncestorsCheck->check( + [], + array_map(static fn (ImplementsTag $tag): Type => $tag->getType(), $classReflection->getImplementsTags()), + sprintf('Interface %s @implements tag contains incompatible type %%s.', $escapedInterfaceName), + sprintf('Interface %s @implements tag contains unresolvable type.', $interfaceName), + sprintf('Interface %s has @implements tag, but can not implement any interface, must extend from it.', $escapedInterfaceName), + '', + '', + '', + '', + '', + '', + '', + '', + '', + ); + + foreach ($this->crossCheckInterfacesHelper->check($classReflection) as $error) { + $implementsErrors[] = $error; + } + + return array_merge($extendsErrors, $implementsErrors); + } + +} diff --git a/src/Rules/Generics/InterfaceTemplateTypeRule.php b/src/Rules/Generics/InterfaceTemplateTypeRule.php new file mode 100644 index 00000000..48bf1e69 --- /dev/null +++ b/src/Rules/Generics/InterfaceTemplateTypeRule.php @@ -0,0 +1,56 @@ + + */ +final class InterfaceTemplateTypeRule implements Rule +{ + + public function __construct( + private TemplateTypeCheck $templateTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$classReflection->isInterface()) { + return []; + } + $interfaceName = $classReflection->getName(); + + $escapadInterfaceName = SprintfHelper::escapeFormatString($interfaceName); + + return $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithClass($interfaceName), + $classReflection->getTemplateTags(), + sprintf('PHPDoc tag @template for interface %s cannot have existing class %%s as its name.', $escapadInterfaceName), + sprintf('PHPDoc tag @template for interface %s cannot have existing type alias %%s as its name.', $escapadInterfaceName), + sprintf('PHPDoc tag @template %%s for interface %s has invalid bound type %%s.', $escapadInterfaceName), + sprintf('PHPDoc tag @template %%s for interface %s with bound type %%s is not supported.', $escapadInterfaceName), + sprintf('PHPDoc tag @template %%s for interface %s has invalid default type %%s.', $escapadInterfaceName), + sprintf('Default type %%s in PHPDoc tag @template %%s for interface %s is not subtype of bound type %%s.', $escapadInterfaceName), + sprintf('PHPDoc tag @template %%s for interface %s does not have a default type but follows an optional @template %%s.', $escapadInterfaceName), + ); + } + +} diff --git a/src/Rules/Generics/MethodSignatureVarianceRule.php b/src/Rules/Generics/MethodSignatureVarianceRule.php new file mode 100644 index 00000000..e9ac2127 --- /dev/null +++ b/src/Rules/Generics/MethodSignatureVarianceRule.php @@ -0,0 +1,44 @@ + + */ +final class MethodSignatureVarianceRule implements Rule +{ + + public function __construct(private VarianceCheck $varianceCheck) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + + return $this->varianceCheck->checkParametersAcceptor( + $method, + sprintf('in parameter %%s of method %s::%s()', SprintfHelper::escapeFormatString($method->getDeclaringClass()->getDisplayName()), SprintfHelper::escapeFormatString($method->getName())), + sprintf('in param-out type of parameter %%s of method %s::%s()', SprintfHelper::escapeFormatString($method->getDeclaringClass()->getDisplayName()), SprintfHelper::escapeFormatString($method->getName())), + sprintf('in return type of method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()), + sprintf('in method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()), + $method->isStatic(), + $method->isPrivate() || $method->getName() === '__construct', + 'method', + ); + } + +} diff --git a/src/Rules/Generics/MethodTagTemplateTypeCheck.php b/src/Rules/Generics/MethodTagTemplateTypeCheck.php new file mode 100644 index 00000000..b65af8e4 --- /dev/null +++ b/src/Rules/Generics/MethodTagTemplateTypeCheck.php @@ -0,0 +1,84 @@ + + */ + public function check( + ClassReflection $classReflection, + Scope $scope, + ClassLike $node, + string $docComment, + ): array + { + $className = $classReflection->getDisplayName(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + null, + $docComment, + ); + + $messages = []; + $escapedClassName = SprintfHelper::escapeFormatString($className); + $classTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + + foreach ($resolvedPhpDoc->getMethodTags() as $methodName => $methodTag) { + $methodTemplateTags = $methodTag->getTemplateTags(); + $escapedMethodName = SprintfHelper::escapeFormatString($methodName); + + $messages = array_merge($messages, $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithMethod($className, $methodName), + $methodTemplateTags, + sprintf('PHPDoc tag @method template for method %s::%s() cannot have existing class %%s as its name.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @method template for method %s::%s() cannot have existing type alias %%s as its name.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @method template %%s for method %s::%s() has invalid bound type %%s.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @method template %%s for method %s::%s() with bound type %%s is not supported.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @method template %%s for method %s::%s() has invalid default type %%s', $escapedClassName, $escapedMethodName), + sprintf('Default type %%s in PHPDoc tag @method template %%s for method %s::%s() is not subtype of bound type %%s', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template %%s for method %s::%s() does not have a default type but follows an optional @template %%s.', $escapedClassName, $escapedMethodName), + )); + + foreach (array_keys($methodTemplateTags) as $name) { + if (!isset($classTemplateTypes[$name])) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @method template %s for method %s::%s() shadows @template %s for class %s.', $name, $className, $methodName, $classTemplateTypes[$name]->describe(VerbosityLevel::typeOnly()), $classReflection->getDisplayName(false))) + ->identifier('methodTag.shadowTemplate') + ->build(); + } + } + + return $messages; + } + +} diff --git a/src/Rules/Generics/MethodTagTemplateTypeRule.php b/src/Rules/Generics/MethodTagTemplateTypeRule.php new file mode 100644 index 00000000..1bb8e004 --- /dev/null +++ b/src/Rules/Generics/MethodTagTemplateTypeRule.php @@ -0,0 +1,43 @@ + + */ +final class MethodTagTemplateTypeRule implements Rule +{ + + public function __construct( + private MethodTagTemplateTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + return $this->check->check( + $node->getClassReflection(), + $scope, + $node->getOriginalNode(), + $docComment->getText(), + ); + } + +} diff --git a/src/Rules/Generics/MethodTagTemplateTypeTraitRule.php b/src/Rules/Generics/MethodTagTemplateTypeTraitRule.php new file mode 100644 index 00000000..21250179 --- /dev/null +++ b/src/Rules/Generics/MethodTagTemplateTypeTraitRule.php @@ -0,0 +1,53 @@ + + */ +final class MethodTagTemplateTypeTraitRule implements Rule +{ + + public function __construct( + private MethodTagTemplateTypeCheck $check, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->check( + $this->reflectionProvider->getClass($traitName->toString()), + $scope, + $node, + $docComment->getText(), + ); + } + +} diff --git a/src/Rules/Generics/MethodTemplateTypeRule.php b/src/Rules/Generics/MethodTemplateTypeRule.php new file mode 100644 index 00000000..0be89bdc --- /dev/null +++ b/src/Rules/Generics/MethodTemplateTypeRule.php @@ -0,0 +1,89 @@ + + */ +final class MethodTemplateTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private TemplateTypeCheck $templateTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $classReflection = $scope->getClassReflection(); + $className = $classReflection->getDisplayName(); + $methodName = $node->name->toString(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $methodName, + $docComment->getText(), + ); + + $methodTemplateTags = $resolvedPhpDoc->getTemplateTags(); + $escapedClassName = SprintfHelper::escapeFormatString($className); + $escapedMethodName = SprintfHelper::escapeFormatString($methodName); + $messages = $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithMethod($className, $methodName), + $methodTemplateTags, + sprintf('PHPDoc tag @template for method %s::%s() cannot have existing class %%s as its name.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template for method %s::%s() cannot have existing type alias %%s as its name.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template %%s for method %s::%s() has invalid bound type %%s.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template %%s for method %s::%s() with bound type %%s is not supported.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template %%s for method %s::%s() has invalid default type %%s.', $escapedClassName, $escapedMethodName), + sprintf('Default type %%s in PHPDoc tag @template %%s for method %s::%s() is not subtype of bound type %%s.', $escapedClassName, $escapedMethodName), + sprintf('PHPDoc tag @template %%s for method %s::%s() does not have a default type but follows an optional @template %%s.', $escapedClassName, $escapedMethodName), + ); + + $classTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + foreach (array_keys($methodTemplateTags) as $name) { + if (!isset($classTemplateTypes[$name])) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @template %s for method %s::%s() shadows @template %s for class %s.', $name, $className, $methodName, $classTemplateTypes[$name]->describe(VerbosityLevel::typeOnly()), $classReflection->getDisplayName(false))) + ->identifier('method.shadowTemplate') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Generics/PropertyVarianceRule.php b/src/Rules/Generics/PropertyVarianceRule.php new file mode 100644 index 00000000..a9d6a157 --- /dev/null +++ b/src/Rules/Generics/PropertyVarianceRule.php @@ -0,0 +1,56 @@ + + */ +final class PropertyVarianceRule implements Rule +{ + + public function __construct( + private VarianceCheck $varianceCheck, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (!$classReflection->hasNativeProperty($node->getName())) { + return []; + } + + $propertyReflection = $classReflection->getNativeProperty($node->getName()); + + if ($propertyReflection->isPrivate()) { + return []; + } + + $variance = $node->isReadOnly() || $node->isReadOnlyByPhpDoc() + ? TemplateTypeVariance::createCovariant() + : TemplateTypeVariance::createInvariant(); + + return $this->varianceCheck->check( + $variance, + $propertyReflection->getReadableType(), + sprintf('in property %s::$%s', SprintfHelper::escapeFormatString($classReflection->getDisplayName()), SprintfHelper::escapeFormatString($node->getName())), + ); + } + +} diff --git a/src/Rules/Generics/TemplateTypeCheck.php b/src/Rules/Generics/TemplateTypeCheck.php new file mode 100644 index 00000000..d14769aa --- /dev/null +++ b/src/Rules/Generics/TemplateTypeCheck.php @@ -0,0 +1,213 @@ + $templateTags + * @return list + */ + public function check( + Scope $scope, + Node $node, + TemplateTypeScope $templateTypeScope, + array $templateTags, + string $sameTemplateTypeNameAsClassMessage, + string $sameTemplateTypeNameAsTypeMessage, + string $invalidBoundTypeMessage, + string $notSupportedBoundMessage, + string $invalidDefaultTypeMessage, + string $defaultNotSubtypeOfBoundMessage, + string $requiredTypeAfterOptionalMessage, + ): array + { + $messages = []; + $templateTagWithDefaultType = null; + foreach ($templateTags as $templateTag) { + $templateTagName = $scope->resolveName(new Node\Name($templateTag->getName())); + if ($this->reflectionProvider->hasClass($templateTagName)) { + $messages[] = RuleErrorBuilder::message(sprintf( + $sameTemplateTypeNameAsClassMessage, + $templateTagName, + ))->identifier('generics.existingClass')->build(); + } + if ($this->typeAliasResolver->hasTypeAlias($templateTagName, $templateTypeScope->getClassName())) { + $messages[] = RuleErrorBuilder::message(sprintf( + $sameTemplateTypeNameAsTypeMessage, + $templateTagName, + ))->identifier('generics.existingTypeAlias')->build(); + } + $boundType = $templateTag->getBound(); + foreach ($boundType->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + $messages[] = RuleErrorBuilder::message(sprintf( + $invalidBoundTypeMessage, + $templateTagName, + $referencedClass, + ))->identifier('class.notFound')->build(); + continue; + } + if (!$this->reflectionProvider->getClass($referencedClass)->isTrait()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $invalidBoundTypeMessage, + $templateTagName, + $referencedClass, + ))->identifier('generics.traitBound')->build(); + } + + $classNameNodePairs = array_map(static fn (string $referencedClass): ClassNameNodePair => new ClassNameNodePair($referencedClass, $node), $boundType->getReferencedClasses()); + $messages = array_merge($messages, $this->classCheck->checkClassNames($classNameNodePairs, $this->checkClassCaseSensitivity)); + + $boundTypeClass = get_class($boundType); + if ( + $boundTypeClass !== MixedType::class + && $boundTypeClass !== ConstantArrayType::class + && $boundTypeClass !== ArrayType::class + && $boundTypeClass !== ConstantStringType::class + && $boundTypeClass !== StringType::class + && $boundTypeClass !== ConstantIntegerType::class + && $boundTypeClass !== IntegerType::class + && $boundTypeClass !== FloatType::class + && $boundTypeClass !== BooleanType::class + && $boundTypeClass !== ObjectWithoutClassType::class + && $boundTypeClass !== ObjectType::class + && $boundTypeClass !== ObjectShapeType::class + && $boundTypeClass !== GenericObjectType::class + && $boundTypeClass !== KeyOfType::class + && !$boundType instanceof UnionType + && !$boundType instanceof IntersectionType + && !$boundType instanceof TemplateType + ) { + $messages[] = RuleErrorBuilder::message(sprintf($notSupportedBoundMessage, $templateTagName, $boundType->describe(VerbosityLevel::typeOnly()))) + ->identifier('generics.notSupportedBound') + ->build(); + } + + $escapedTemplateTagName = SprintfHelper::escapeFormatString($templateTagName); + $genericObjectErrors = $this->genericObjectTypeCheck->check( + $boundType, + sprintf('PHPDoc tag @template %s bound contains generic type %%s but %%s %%s is not generic.', $escapedTemplateTagName), + sprintf('PHPDoc tag @template %s bound has type %%s which does not specify all template types of %%s %%s: %%s', $escapedTemplateTagName), + sprintf('PHPDoc tag @template %s bound has type %%s which specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedTemplateTagName), + sprintf('Type %%s in generic type %%s in PHPDoc tag @template %s is not subtype of template type %%s of %%s %%s.', $escapedTemplateTagName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @template %s is in conflict with %%s template type %%s of %%s %%s.', $escapedTemplateTagName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @template %s is redundant, template type %%s of %%s %%s has the same variance.', $escapedTemplateTagName), + ); + foreach ($genericObjectErrors as $genericObjectError) { + $messages[] = $genericObjectError; + } + + $defaultType = $templateTag->getDefault(); + if ($defaultType === null) { + if ($templateTagWithDefaultType !== null) { + $messages[] = RuleErrorBuilder::message(sprintf( + $requiredTypeAfterOptionalMessage, + $templateTagName, + $templateTagWithDefaultType, + ))->identifier('generics.requiredTypeAfterOptional')->build(); + } + + continue; + } + + $templateTagWithDefaultType = $templateTagName; + + foreach ($defaultType->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + $messages[] = RuleErrorBuilder::message(sprintf( + $invalidDefaultTypeMessage, + $templateTagName, + $referencedClass, + ))->identifier('class.notFound')->build(); + continue; + } + if (!$this->reflectionProvider->getClass($referencedClass)->isTrait()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + $invalidDefaultTypeMessage, + $templateTagName, + $referencedClass, + ))->identifier('generics.traitBound')->build(); + } + + $classNameNodePairs = array_map(static fn (string $referencedClass): ClassNameNodePair => new ClassNameNodePair($referencedClass, $node), $defaultType->getReferencedClasses()); + $messages = array_merge($messages, $this->classCheck->checkClassNames($classNameNodePairs, $this->checkClassCaseSensitivity)); + + $genericDefaultErrors = $this->genericObjectTypeCheck->check( + $defaultType, + sprintf('PHPDoc tag @template %s default contains generic type %%s but class %%s is not generic.', $escapedTemplateTagName), + sprintf('PHPDoc tag @template %s default has type %%s which does not specify all template types of class %%s: %%s', $escapedTemplateTagName), + sprintf('PHPDoc tag @template %s default has type %%s which specifies %%d template types, but class %%s supports only %%d: %%s', $escapedTemplateTagName), + sprintf('Type %%s in generic type %%s in PHPDoc tag @template %s default is not subtype of template type %%s of class %%s.', $escapedTemplateTagName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @template %s default is in conflict with %%s template type %%s of %%s %%s.', $escapedTemplateTagName), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @template %s default is redundant, template type %%s of %%s %%s has the same variance.', $escapedTemplateTagName), + ); + foreach ($genericDefaultErrors as $genericDefaultError) { + $messages[] = $genericDefaultError; + } + + if (!$boundType->accepts($defaultType, $scope->isDeclareStrictTypes())->no()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf($defaultNotSubtypeOfBoundMessage, $defaultType->describe(VerbosityLevel::typeOnly()), $templateTagName, $boundType->describe(VerbosityLevel::typeOnly()))) + ->identifier('generics.templateDefaultOutOfBounds') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Generics/TraitTemplateTypeRule.php b/src/Rules/Generics/TraitTemplateTypeRule.php new file mode 100644 index 00000000..635ea39c --- /dev/null +++ b/src/Rules/Generics/TraitTemplateTypeRule.php @@ -0,0 +1,70 @@ + + */ +final class TraitTemplateTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private TemplateTypeCheck $templateTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + if (!isset($node->namespacedName)) { + throw new ShouldNotHappenException(); + } + + $traitName = (string) $node->namespacedName; + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $traitName, + null, + null, + $docComment->getText(), + ); + + $escapedTraitName = SprintfHelper::escapeFormatString($traitName); + + return $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithClass($traitName), + $resolvedPhpDoc->getTemplateTags(), + sprintf('PHPDoc tag @template for trait %s cannot have existing class %%s as its name.', $escapedTraitName), + sprintf('PHPDoc tag @template for trait %s cannot have existing type alias %%s as its name.', $escapedTraitName), + sprintf('PHPDoc tag @template %%s for trait %s has invalid bound type %%s.', $escapedTraitName), + sprintf('PHPDoc tag @template %%s for trait %s with bound type %%s is not supported.', $escapedTraitName), + sprintf('PHPDoc tag @template %%s for trait %s has invalid default type %%s.', $escapedTraitName), + sprintf('Default type %%s in PHPDoc tag @template %%s for trait %s is not subtype of bound type %%s.', $escapedTraitName), + sprintf('PHPDoc tag @template %%s for trait %s does not have a default type but follows an optional @template %%s.', $escapedTraitName), + ); + } + +} diff --git a/src/Rules/Generics/UsedTraitsRule.php b/src/Rules/Generics/UsedTraitsRule.php new file mode 100644 index 00000000..43fcd6e9 --- /dev/null +++ b/src/Rules/Generics/UsedTraitsRule.php @@ -0,0 +1,90 @@ + + */ +final class UsedTraitsRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private GenericAncestorsCheck $genericAncestorsCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\TraitUse::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $className = $scope->getClassReflection()->getName(); + $traitName = null; + if ($scope->isInTrait()) { + $traitName = $scope->getTraitReflection()->getName(); + } + $useTags = []; + $docComment = $node->getDocComment(); + if ($docComment !== null) { + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $className, + $traitName, + null, + $docComment->getText(), + ); + $useTags = $resolvedPhpDoc->getUsesTags(); + } + + $typeDescription = strtolower($scope->getClassReflection()->getClassTypeDescription()); + $description = sprintf('%s %s', $typeDescription, SprintfHelper::escapeFormatString($className)); + if ($traitName !== null) { + $typeDescription = 'trait'; + $description = sprintf('%s %s', $typeDescription, SprintfHelper::escapeFormatString($traitName)); + } + + $escapedDescription = SprintfHelper::escapeFormatString($description); + $upperCaseDescription = ucfirst($description); + $escapedUpperCaseDescription = SprintfHelper::escapeFormatString($upperCaseDescription); + + return $this->genericAncestorsCheck->check( + $node->traits, + array_map(static fn (UsesTag $tag): Type => $tag->getType(), $useTags), + sprintf('%s @use tag contains incompatible type %%s.', $escapedUpperCaseDescription), + sprintf('%s @use tag contains unresolvable type.', $upperCaseDescription), + sprintf('%s has @use tag, but does not use any trait.', $upperCaseDescription), + sprintf('The @use tag of %s describes %%s but the %s uses %%s.', $escapedDescription, $typeDescription), + 'PHPDoc tag @use contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @use does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @use specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @use is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @use is not allowed.', + 'PHPDoc tag @use has invalid type %s.', + sprintf('%s uses generic trait %%s but does not specify its types: %%s', $escapedUpperCaseDescription), + sprintf('in used type %%s of %s', $escapedDescription), + ); + } + +} diff --git a/src/Rules/Generics/VarianceCheck.php b/src/Rules/Generics/VarianceCheck.php new file mode 100644 index 00000000..9e0a2cd0 --- /dev/null +++ b/src/Rules/Generics/VarianceCheck.php @@ -0,0 +1,111 @@ + + */ + public function checkParametersAcceptor( + ExtendedParametersAcceptor $parametersAcceptor, + string $parameterTypeMessage, + string $parameterOutTypeMessage, + string $returnTypeMessage, + string $generalMessage, + bool $isStatic, + bool $isPrivate, + string $identifier, + ): array + { + $errors = []; + + foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $templateType) { + if (!$templateType instanceof TemplateType + || $templateType->getScope()->getFunctionName() === null + || $templateType->getVariance()->invariant() + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variance annotation is only allowed for type parameters of classes and interfaces, but occurs in template type %s in %s.', + $templateType->getName(), + $generalMessage, + ))->identifier(sprintf('%s.variance', $identifier))->build(); + } + + if ($isPrivate) { + return $errors; + } + + $covariant = TemplateTypeVariance::createCovariant(); + $parameterVariance = TemplateTypeVariance::createContravariant(); + + foreach ($parametersAcceptor->getParameters() as $parameterReflection) { + $type = $parameterReflection->getType(); + $message = sprintf($parameterTypeMessage, $parameterReflection->getName()); + foreach ($this->check($parameterVariance, $type, $message) as $error) { + $errors[] = $error; + } + + $paramOutType = $parameterReflection->getOutType(); + if ($paramOutType === null) { + continue; + } + + $outMessage = sprintf($parameterOutTypeMessage, $parameterReflection->getName()); + foreach ($this->check($covariant, $paramOutType, $outMessage) as $error) { + $errors[] = $error; + } + } + + $type = $parametersAcceptor->getReturnType(); + foreach ($this->check($covariant, $type, $returnTypeMessage) as $error) { + $errors[] = $error; + } + + return $errors; + } + + /** @return list */ + public function check(TemplateTypeVariance $positionVariance, Type $type, string $messageContext): array + { + $errors = []; + + foreach ($type->getReferencedTemplateTypes($positionVariance) as $reference) { + $referredType = $reference->getType(); + if (($referredType->getScope()->getFunctionName() !== null && !$referredType->getVariance()->invariant()) + || $this->isTemplateTypeVarianceValid($reference->getPositionVariance(), $referredType)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Template type %s is declared as %s, but occurs in %s position %s.', + $referredType->getName(), + $referredType->getVariance()->describe(), + $reference->getPositionVariance()->describe(), + $messageContext, + ))->identifier('generics.variance')->build(); + } + + return $errors; + } + + private function isTemplateTypeVarianceValid(TemplateTypeVariance $positionVariance, TemplateType $type): bool + { + return $positionVariance->validPosition($type->getVariance()); + } + +} diff --git a/src/Rules/IdentifierRuleError.php b/src/Rules/IdentifierRuleError.php new file mode 100644 index 00000000..de4c379f --- /dev/null +++ b/src/Rules/IdentifierRuleError.php @@ -0,0 +1,12 @@ + + */ +final class IgnoreParseErrorRule implements Rule +{ + + public function getNodeType(): string + { + return FileNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $nodes = $node->getNodes(); + if (count($nodes) === 0) { + return []; + } + + $firstNode = $nodes[0]; + $parseErrors = $firstNode->getAttribute('linesToIgnoreParseErrors', []); + $errors = []; + foreach ($parseErrors as $line => $lineParseErrors) { + foreach ($lineParseErrors as $parseError) { + $errors[] = RuleErrorBuilder::message(sprintf('Parse error in @phpstan-ignore: %s', $parseError)) + ->line($line) + ->identifier('ignore.parseError') + ->nonIgnorable() + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php new file mode 100644 index 00000000..1e95ed20 --- /dev/null +++ b/src/Rules/IssetCheck.php @@ -0,0 +1,298 @@ +name)) { + $hasVariable = $scope->hasVariableType($expr->name); + if ($hasVariable->maybe()) { + return null; + } + + if ($error === null) { + if ($hasVariable->yes()) { + if ($expr->name === '_SESSION') { + return null; + } + + $type = $this->treatPhpDocTypesAsCertain ? $scope->getType($expr) : $scope->getNativeType($expr); + if (!$type instanceof NeverType) { + return $this->generateError( + $type, + sprintf('Variable $%s %s always exists and', $expr->name, $operatorDescription), + $typeMessageCallback, + $identifier, + 'variable', + ); + } + } + + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + ->identifier(sprintf('%s.variable', $identifier)) + ->build(); + } + + return $error; + } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->treatPhpDocTypesAsCertain + ? $scope->getType($expr->var) + : $scope->getNativeType($expr->var); + if (!$type->isOffsetAccessible()->yes()) { + return $error ?? $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + $dimType = $this->treatPhpDocTypesAsCertain + ? $scope->getType($expr->dim) + : $scope->getNativeType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if ($hasOffsetValue->no()) { + if (!$this->checkAdvancedIsset) { + return null; + } + + return RuleErrorBuilder::message( + sprintf( + 'Offset %s on %s %s does not exist.', + $dimType->describe(VerbosityLevel::value()), + $type->describe(VerbosityLevel::value()), + $operatorDescription, + ), + )->identifier(sprintf('%s.offset', $identifier))->build(); + } + + // If offset cannot be null, store this error message and see if one of the earlier offsets is. + // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. + if ($hasOffsetValue->yes() || $scope->hasExpressionType($expr)->yes()) { + if (!$this->checkAdvancedIsset) { + return null; + } + + $error ??= $this->generateError($type->getOffsetValueType($dimType), sprintf( + 'Offset %s on %s %s always exists and', + $dimType->describe(VerbosityLevel::value()), + $type->describe(VerbosityLevel::value()), + $operatorDescription, + ), $typeMessageCallback, $identifier, 'offset'); + + if ($error !== null) { + return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + } + + // Has offset, it is nullable + return null; + + } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); + + if ($propertyReflection === null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($expr->class instanceof Expr) { + return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + } + + return null; + } + + if (!$propertyReflection->isNative()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($expr->class instanceof Expr) { + return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + } + + return null; + } + + if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { + if (!$scope->hasExpressionType($expr)->yes()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($expr->class instanceof Expr) { + return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + } + + return null; + } + } + + $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr); + $propertyType = $propertyReflection->getWritableType(); + if ($error !== null) { + return $error; + } + if (!$this->checkAdvancedIsset) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($expr->class instanceof Expr) { + return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + } + + return null; + } + + $error = $this->generateError( + $propertyReflection->getWritableType(), + sprintf('%s (%s) %s', $propertyDescription, $propertyType->describe(VerbosityLevel::typeOnly()), $operatorDescription), + $typeMessageCallback, + $identifier, + 'property', + ); + + if ($error !== null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + + if ($expr->class instanceof Expr) { + return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + } + + return $error; + } + + if ($error !== null) { + return $error; + } + + if (!$this->checkAdvancedIsset) { + return null; + } + + $error = $this->generateError( + $this->treatPhpDocTypesAsCertain ? $scope->getType($expr) : $scope->getNativeType($expr), + sprintf('Expression %s', $operatorDescription), + $typeMessageCallback, + $identifier, + 'expr', + ); + if ($error !== null) { + return $error; + } + + if ($expr instanceof Expr\NullsafePropertyFetch) { + if ($expr->name instanceof Node\Identifier) { + return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $expr->name->name, $operatorDescription)) + ->identifier('nullsafe.neverNull') + ->build(); + } + + return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->(Expression)" %s is unnecessary. Use -> instead.', $operatorDescription)) + ->identifier('nullsafe.neverNull') + ->build(); + } + + return null; + } + + /** + * @param ErrorIdentifier $identifier + */ + private function checkUndefined(Expr $expr, Scope $scope, string $operatorDescription, string $identifier): ?IdentifierRuleError + { + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + $hasVariable = $scope->hasVariableType($expr->name); + if (!$hasVariable->no()) { + return null; + } + + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + ->identifier(sprintf('%s.variable', $identifier)) + ->build(); + } + + if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->treatPhpDocTypesAsCertain ? $scope->getType($expr->var) : $scope->getNativeType($expr->var); + $dimType = $this->treatPhpDocTypesAsCertain ? $scope->getType($expr->dim) : $scope->getNativeType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if (!$type->isOffsetAccessible()->yes()) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if (!$hasOffsetValue->no()) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + return RuleErrorBuilder::message( + sprintf( + 'Offset %s on %s %s does not exist.', + $dimType->describe(VerbosityLevel::value()), + $type->describe(VerbosityLevel::value()), + $operatorDescription, + ), + )->identifier(sprintf('%s.offset', $identifier))->build(); + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + } + + return null; + } + + /** + * @param callable(Type): ?string $typeMessageCallback + * @param ErrorIdentifier $identifier + * @param 'variable'|'offset'|'property'|'expr' $identifierSecondPart + */ + private function generateError(Type $type, string $message, callable $typeMessageCallback, string $identifier, string $identifierSecondPart): ?IdentifierRuleError + { + $typeMessage = $typeMessageCallback($type); + if ($typeMessage === null) { + return null; + } + + return RuleErrorBuilder::message( + sprintf('%s %s.', $message, $typeMessage), + )->identifier(sprintf('%s.%s', $identifier, $identifierSecondPart))->build(); + } + +} diff --git a/src/Rules/Keywords/ContinueBreakInLoopRule.php b/src/Rules/Keywords/ContinueBreakInLoopRule.php new file mode 100644 index 00000000..343037a7 --- /dev/null +++ b/src/Rules/Keywords/ContinueBreakInLoopRule.php @@ -0,0 +1,83 @@ + + */ +final class ContinueBreakInLoopRule implements Rule +{ + + public function getNodeType(): string + { + return Stmt::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof Stmt\Continue_ && !$node instanceof Stmt\Break_) { + return []; + } + + if (!$node->num instanceof Node\Scalar\Int_) { + $value = 1; + } else { + $value = $node->num->value; + } + + $parentStmtTypes = array_reverse($node->getAttribute(ParentStmtTypesVisitor::ATTRIBUTE_NAME)); + foreach ($parentStmtTypes as $parentStmtType) { + if ($parentStmtType === Stmt\Case_::class) { + continue; + } + if ($parentStmtType === Node\Expr\Closure::class) { + return [ + RuleErrorBuilder::message(sprintf( + 'Keyword %s used outside of a loop or a switch statement.', + $node instanceof Stmt\Continue_ ? 'continue' : 'break', + )) + ->nonIgnorable() + ->identifier(sprintf('%s.outOfLoop', $node instanceof Stmt\Continue_ ? 'continue' : 'break')) + ->build(), + ]; + } + if ( + $parentStmtType === Stmt\For_::class + || $parentStmtType === Stmt\Foreach_::class + || $parentStmtType === Stmt\Do_::class + || $parentStmtType === Stmt\While_::class + || $parentStmtType === Stmt\Switch_::class + ) { + $value--; + } + if ($value === 0) { + break; + } + } + + if ($value > 0) { + return [ + RuleErrorBuilder::message(sprintf( + 'Keyword %s used outside of a loop or a switch statement.', + $node instanceof Stmt\Continue_ ? 'continue' : 'break', + )) + ->nonIgnorable() + ->identifier(sprintf('%s.outOfLoop', $node instanceof Stmt\Continue_ ? 'continue' : 'break')) + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Keywords/DeclareStrictTypesRule.php b/src/Rules/Keywords/DeclareStrictTypesRule.php new file mode 100644 index 00000000..657d6419 --- /dev/null +++ b/src/Rules/Keywords/DeclareStrictTypesRule.php @@ -0,0 +1,81 @@ + + */ +final class DeclareStrictTypesRule implements Rule +{ + + public function __construct( + private readonly ExprPrinter $exprPrinter, + ) + { + } + + public function getNodeType(): string + { + return Stmt\Declare_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $declaresStrictTypes = false; + foreach ($node->declares as $declare) { + if ( + $declare->key->name !== 'strict_types' + ) { + continue; + } + + if ( + !$declare->value instanceof Node\Scalar\Int_ + || !in_array($declare->value->value, [0, 1], true) + ) { + return [ + RuleErrorBuilder::message(sprintf( + sprintf( + 'Declare strict_types must have 0 or 1 as its value, %s given.', + $this->exprPrinter->printExpr($declare->value), + ), + ))->identifier('declareStrictTypes.value')->nonIgnorable()->build(), + ]; + } + + $declaresStrictTypes = true; + break; + } + + if ($declaresStrictTypes === false) { + return []; + } + + if (!$node->hasAttribute(DeclarePositionVisitor::ATTRIBUTE_NAME)) { + return []; + } + + $isFirstStatement = (bool) $node->getAttribute(DeclarePositionVisitor::ATTRIBUTE_NAME); + if ($isFirstStatement) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Declare strict_types must be the very first statement.', + ))->identifier('declareStrictTypes.notFirst')->nonIgnorable()->build(), + ]; + } + +} diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php new file mode 100644 index 00000000..cf355fe2 --- /dev/null +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -0,0 +1,138 @@ + + */ +final class RequireFileExistsRule implements Rule +{ + + public function __construct(private string $currentWorkingDirectory) + { + } + + public function getNodeType(): string + { + return Include_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $paths = $this->resolveFilePaths($node, $scope); + + foreach ($paths as $path) { + if ($this->doesFileExist($path, $scope)) { + continue; + } + + $errors[] = $this->getErrorMessage($node, $path); + } + + return $errors; + } + + /** + * We cannot use `stream_resolve_include_path` as it works based on the calling script. + * This method simulates the behavior of `stream_resolve_include_path` but for the given scope. + * The priority order is the following: + * 1. The current working directory. + * 2. The include path. + * 3. The path of the script that is being executed. + */ + private function doesFileExist(string $path, Scope $scope): bool + { + $directories = array_merge( + [$this->currentWorkingDirectory], + explode(PATH_SEPARATOR, get_include_path()), + [dirname($scope->getFile())], + ); + + foreach ($directories as $directory) { + if ($this->doesFileExistForDirectory($path, $directory)) { + return true; + } + } + + return false; + } + + private function doesFileExistForDirectory(string $path, string $workingDirectory): bool + { + $fileHelper = new FileHelper($workingDirectory); + $absolutePath = $fileHelper->absolutizePath($path); + + return is_file($absolutePath); + } + + private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError + { + $message = 'Path in %s() "%s" is not a file or it does not exist.'; + + switch ($node->type) { + case Include_::TYPE_REQUIRE: + $type = 'require'; + $identifierType = 'require'; + break; + case Include_::TYPE_REQUIRE_ONCE: + $type = 'require_once'; + $identifierType = 'requireOnce'; + break; + case Include_::TYPE_INCLUDE: + $type = 'include'; + $identifierType = 'include'; + break; + case Include_::TYPE_INCLUDE_ONCE: + $type = 'include_once'; + $identifierType = 'includeOnce'; + break; + default: + throw new ShouldNotHappenException('Rule should have already validated the node type.'); + } + + $identifier = sprintf('%s.fileNotFound', $identifierType); + + return RuleErrorBuilder::message( + sprintf( + $message, + $type, + $filePath, + ), + )->identifier($identifier)->build(); + } + + /** + * @return array + */ + private function resolveFilePaths(Include_ $node, Scope $scope): array + { + $paths = []; + $type = $scope->getType($node->expr); + $constantStrings = $type->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + $paths[] = $constantString->getValue(); + } + + return $paths; + } + +} diff --git a/src/Rules/LazyRegistry.php b/src/Rules/LazyRegistry.php new file mode 100644 index 00000000..df36e182 --- /dev/null +++ b/src/Rules/LazyRegistry.php @@ -0,0 +1,72 @@ + $nodeType + * @return array> + */ + public function getRules(string $nodeType): array + { + if (!isset($this->cache[$nodeType])) { + $parentNodeTypes = [$nodeType] + class_parents($nodeType) + class_implements($nodeType); + + $rules = []; + $rulesFromContainer = $this->getRulesFromContainer(); + foreach ($parentNodeTypes as $parentNodeType) { + foreach ($rulesFromContainer[$parentNodeType] ?? [] as $rule) { + $rules[] = $rule; + } + } + + $this->cache[$nodeType] = $rules; + } + + /** + * @var array> $selectedRules + */ + $selectedRules = $this->cache[$nodeType]; + + return $selectedRules; + } + + /** + * @return Rule[][] + */ + private function getRulesFromContainer(): array + { + if ($this->rules !== null) { + return $this->rules; + } + + $rules = []; + foreach ($this->container->getServicesByTag(self::RULE_TAG) as $rule) { + $rules[$rule->getNodeType()][] = $rule; + } + + return $this->rules = $rules; + } + +} diff --git a/src/Rules/LineRuleError.php b/src/Rules/LineRuleError.php new file mode 100644 index 00000000..d168a413 --- /dev/null +++ b/src/Rules/LineRuleError.php @@ -0,0 +1,12 @@ + + */ +final class AbstractMethodInNonAbstractClassRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $class = $scope->getClassReflection(); + + if (!$class->isAbstract() && $node->isAbstract()) { + if ($class->isEnum()) { + $lowercasedMethodName = $node->name->toLowerString(); + if ($lowercasedMethodName === 'cases') { + return []; + } + if ($class->isBackedEnum()) { + if (in_array($lowercasedMethodName, ['from', 'tryfrom'], true)) { + return []; + } + } + } + + $description = $class->getClassTypeDescription(); + return [ + RuleErrorBuilder::message(sprintf( + '%s %s contains abstract method %s().', + $description === 'Class' ? 'Non-abstract class' : $description, + $class->getDisplayName(), + $node->name->toString(), + )) + ->nonIgnorable() + ->identifier('method.abstract') + ->build(), + ]; + } + + if (!$class->isAbstract() && !$class->isInterface() && $node->getStmts() === null) { + return [ + RuleErrorBuilder::message(sprintf( + 'Non-abstract method %s::%s() must contain a body.', + $class->getDisplayName(), + $node->name->toString(), + )) + ->nonIgnorable() + ->identifier('method.nonAbstract') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Methods/AbstractPrivateMethodRule.php b/src/Rules/Methods/AbstractPrivateMethodRule.php new file mode 100644 index 00000000..6d5c23fa --- /dev/null +++ b/src/Rules/Methods/AbstractPrivateMethodRule.php @@ -0,0 +1,59 @@ + */ +final class AbstractPrivateMethodRule implements Rule +{ + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + + if (!$method->isPrivate()) { + return []; + } + + if (!$method->isAbstract()->yes()) { + return []; + } + + if ($scope->isInTrait()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + return []; + } + + if (!$classReflection->isAbstract() && !$classReflection->isInterface()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Private method %s::%s() cannot be abstract.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + )) + ->identifier('method.abstractPrivate') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Methods/AlwaysUsedMethodExtension.php b/src/Rules/Methods/AlwaysUsedMethodExtension.php new file mode 100644 index 00000000..9f69fd19 --- /dev/null +++ b/src/Rules/Methods/AlwaysUsedMethodExtension.php @@ -0,0 +1,28 @@ + + */ +final class CallMethodsRule implements Rule +{ + + public function __construct( + private MethodCallCheck $methodCallCheck, + private FunctionCallParametersCheck $parametersCheck, + ) + { + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->name; + + [$errors, $methodReflection] = $this->methodCallCheck->check($scope, $methodName, $node->var); + if ($methodReflection === null) { + return $errors; + } + + $declaringClass = $methodReflection->getDeclaringClass(); + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); + + return array_merge($errors, $this->parametersCheck->check( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ), + $scope, + $declaringClass->isBuiltin(), + $node, + 'method', + $methodReflection->acceptsNamedArguments(), + 'Method ' . $messagesMethodName . ' invoked with %d parameter, %d required.', + 'Method ' . $messagesMethodName . ' invoked with %d parameters, %d required.', + 'Method ' . $messagesMethodName . ' invoked with %d parameter, at least %d required.', + 'Method ' . $messagesMethodName . ' invoked with %d parameters, at least %d required.', + 'Method ' . $messagesMethodName . ' invoked with %d parameter, %d-%d required.', + 'Method ' . $messagesMethodName . ' invoked with %d parameters, %d-%d required.', + '%s of method ' . $messagesMethodName . ' expects %s, %s given.', + 'Result of method ' . $messagesMethodName . ' (void) is used.', + '%s of method ' . $messagesMethodName . ' is passed by reference, so it expects variables only.', + 'Unable to resolve the template type %s in call to method ' . $messagesMethodName, + 'Missing parameter $%s in call to method ' . $messagesMethodName . '.', + 'Unknown parameter $%s in call to method ' . $messagesMethodName . '.', + 'Return type of call to method ' . $messagesMethodName . ' contains unresolvable type.', + '%s of method ' . $messagesMethodName . ' contains unresolvable type.', + 'Method ' . $messagesMethodName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + )); + } + +} diff --git a/src/Rules/Methods/CallPrivateMethodThroughStaticRule.php b/src/Rules/Methods/CallPrivateMethodThroughStaticRule.php new file mode 100644 index 00000000..272d6c19 --- /dev/null +++ b/src/Rules/Methods/CallPrivateMethodThroughStaticRule.php @@ -0,0 +1,63 @@ + + */ +final class CallPrivateMethodThroughStaticRule implements Rule +{ + + public function getNodeType(): string + { + return StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + if (!$node->class instanceof Name) { + return []; + } + + $methodName = $node->name->name; + $className = $node->class; + if ($className->toLowerString() !== 'static') { + return []; + } + + $classType = $scope->resolveTypeByName($className); + if (!$classType->hasMethod($methodName)->yes()) { + return []; + } + + $method = $classType->getMethod($methodName, $scope); + if (!$method->isPrivate()) { + return []; + } + + if ($scope->isInClass() && $scope->getClassReflection()->isFinal()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unsafe call to private method %s::%s() through static::.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('staticClassAccess.privateMethod')->build(), + ]; + } + +} diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php new file mode 100644 index 00000000..2f2f6ff5 --- /dev/null +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -0,0 +1,89 @@ + + */ +final class CallStaticMethodsRule implements Rule +{ + + public function __construct( + private StaticMethodCallCheck $methodCallCheck, + private FunctionCallParametersCheck $parametersCheck, + ) + { + } + + public function getNodeType(): string + { + return StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + $methodName = $node->name->name; + + [$errors, $method] = $this->methodCallCheck->check($scope, $methodName, $node->class); + if ($method === null) { + return $errors; + } + + $displayMethodName = SprintfHelper::escapeFormatString(sprintf( + '%s %s', + $method->isStatic() ? 'Static method' : 'Method', + $method->getDeclaringClass()->getDisplayName() . '::' . $method->getName() . '()', + )); + $lowercasedMethodName = SprintfHelper::escapeFormatString(sprintf( + '%s %s', + $method->isStatic() ? 'static method' : 'method', + $method->getDeclaringClass()->getDisplayName() . '::' . $method->getName() . '()', + )); + + $errors = array_merge($errors, $this->parametersCheck->check( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $method->getVariants(), + $method->getNamedArgumentsVariants(), + ), + $scope, + $method->getDeclaringClass()->isBuiltin(), + $node, + 'staticMethod', + $method->acceptsNamedArguments(), + $displayMethodName . ' invoked with %d parameter, %d required.', + $displayMethodName . ' invoked with %d parameters, %d required.', + $displayMethodName . ' invoked with %d parameter, at least %d required.', + $displayMethodName . ' invoked with %d parameters, at least %d required.', + $displayMethodName . ' invoked with %d parameter, %d-%d required.', + $displayMethodName . ' invoked with %d parameters, %d-%d required.', + '%s of ' . $lowercasedMethodName . ' expects %s, %s given.', + 'Result of ' . $lowercasedMethodName . ' (void) is used.', + '%s of ' . $lowercasedMethodName . ' is passed by reference, so it expects variables only.', + 'Unable to resolve the template type %s in call to method ' . $lowercasedMethodName, + 'Missing parameter $%s in call to ' . $lowercasedMethodName . '.', + 'Unknown parameter $%s in call to ' . $lowercasedMethodName . '.', + 'Return type of call to ' . $lowercasedMethodName . ' contains unresolvable type.', + '%s of ' . $lowercasedMethodName . ' contains unresolvable type.', + $displayMethodName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + )); + + return $errors; + } + +} diff --git a/src/Rules/Methods/CallToConstructorStatementWithoutSideEffectsRule.php b/src/Rules/Methods/CallToConstructorStatementWithoutSideEffectsRule.php new file mode 100644 index 00000000..fabc3a99 --- /dev/null +++ b/src/Rules/Methods/CallToConstructorStatementWithoutSideEffectsRule.php @@ -0,0 +1,73 @@ + + */ +final class CallToConstructorStatementWithoutSideEffectsRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return NoopExpressionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $instantiation = $node->getOriginalExpr(); + if (!$instantiation instanceof Node\Expr\New_) { + return []; + } + + if (!$instantiation->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($instantiation->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$classReflection->hasConstructor()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Call to new %s() on a separate line has no effect.', + $classReflection->getDisplayName(), + ))->identifier('new.resultUnused')->build(), + ]; + } + + $constructor = $classReflection->getConstructor(); + $methodResult = $scope->getType($instantiation); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to %s::%s() on a separate line has no effect.', + $classReflection->getDisplayName(), + $constructor->getName(), + ))->identifier('new.resultUnused')->build(), + ]; + } + +} diff --git a/src/Rules/Methods/CallToMethodStatementWithoutSideEffectsRule.php b/src/Rules/Methods/CallToMethodStatementWithoutSideEffectsRule.php new file mode 100644 index 00000000..67c28eec --- /dev/null +++ b/src/Rules/Methods/CallToMethodStatementWithoutSideEffectsRule.php @@ -0,0 +1,82 @@ + + */ +final class CallToMethodStatementWithoutSideEffectsRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return NoopExpressionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methodCall = $node->getOriginalExpr(); + if ($methodCall instanceof Node\Expr\NullsafeMethodCall) { + $scope = $scope->filterByTruthyValue(new Node\Expr\BinaryOp\NotIdentical($methodCall->var, new Node\Expr\ConstFetch(new Node\Name('null')))); + } elseif (!$methodCall instanceof Node\Expr\MethodCall) { + return []; + } + + if (!$methodCall->name instanceof Node\Identifier) { + return []; + } + $methodName = $methodCall->name->toString(); + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $methodCall->var), + '', + static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(), + ); + $calledOnType = $typeResult->getType(); + if ($calledOnType instanceof ErrorType) { + return []; + } + if (!$calledOnType->canCallMethods()->yes()) { + return []; + } + + if (!$calledOnType->hasMethod($methodName)->yes()) { + return []; + } + + $methodResult = $scope->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return []; + } + + $method = $calledOnType->getMethod($methodName, $scope); + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to %s %s::%s() on a separate line has no effect.', + $method->isStatic() ? 'static method' : 'method', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('method.resultUnused')->build(), + ]; + } + +} diff --git a/src/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRule.php b/src/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRule.php new file mode 100644 index 00000000..1ce81ac6 --- /dev/null +++ b/src/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRule.php @@ -0,0 +1,104 @@ + + */ +final class CallToStaticMethodStatementWithoutSideEffectsRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return NoopExpressionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $staticCall = $node->getOriginalExpr(); + if (!$staticCall instanceof Node\Expr\StaticCall) { + return []; + } + + if (!$staticCall->name instanceof Node\Identifier) { + return []; + } + + $methodName = $staticCall->name->toString(); + if ($staticCall->class instanceof Node\Name) { + $className = $scope->resolveName($staticCall->class); + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $calledOnType = new ObjectType($className); + } else { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $staticCall->class), + '', + static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(), + ); + $calledOnType = $typeResult->getType(); + if ($calledOnType instanceof ErrorType) { + return []; + } + } + + if (!$calledOnType->canCallMethods()->yes()) { + return []; + } + + if (!$calledOnType->hasMethod($methodName)->yes()) { + return []; + } + + $method = $calledOnType->getMethod($methodName, $scope); + if ( + ( + strtolower($method->getName()) === '__construct' + || strtolower($method->getName()) === strtolower($method->getDeclaringClass()->getName()) + ) + ) { + return []; + } + + $methodResult = $scope->getType($staticCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to %s %s::%s() on a separate line has no effect.', + $method->isStatic() ? 'static method' : 'method', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('staticMethod.resultUnused')->build(), + ]; + } + +} diff --git a/src/Rules/Methods/ConsistentConstructorRule.php b/src/Rules/Methods/ConsistentConstructorRule.php new file mode 100644 index 00000000..dac0903f --- /dev/null +++ b/src/Rules/Methods/ConsistentConstructorRule.php @@ -0,0 +1,59 @@ + */ +final class ConsistentConstructorRule implements Rule +{ + + public function __construct( + private MethodParameterComparisonHelper $methodParameterComparisonHelper, + private MethodVisibilityComparisonHelper $methodVisibilityComparisonHelper, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + if (strtolower($method->getName()) !== '__construct') { + return []; + } + + $parent = $method->getDeclaringClass()->getParentClass(); + + if ($parent === null) { + return []; + } + + if ($parent->hasConstructor()) { + $parentConstructor = $parent->getConstructor(); + } else { + $parentConstructor = new DummyConstructorReflection($parent); + } + + if (! $parentConstructor->getDeclaringClass()->hasConsistentConstructor()) { + return []; + } + + return array_merge( + $this->methodParameterComparisonHelper->compare($parentConstructor, $parentConstructor->getDeclaringClass(), $method, true), + $this->methodVisibilityComparisonHelper->compare($parentConstructor, $parentConstructor->getDeclaringClass(), $method), + ); + } + +} diff --git a/src/Rules/Methods/ConstructorReturnTypeRule.php b/src/Rules/Methods/ConstructorReturnTypeRule.php new file mode 100644 index 00000000..bf3e937c --- /dev/null +++ b/src/Rules/Methods/ConstructorReturnTypeRule.php @@ -0,0 +1,64 @@ + + */ +final class ConstructorReturnTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $methodNode = $node->getOriginalNode(); + if ($scope->isInTrait()) { + $originalMethodName = $methodNode->getAttribute('originalTraitMethodName'); + if ( + $originalMethodName === '__construct' + && $methodNode->returnType !== null + ) { + return [ + RuleErrorBuilder::message(sprintf('Original constructor of trait %s has a return type.', $scope->getTraitReflection()->getDisplayName())) + ->identifier('constructor.returnType') + ->nonIgnorable() + ->build(), + ]; + } + } + if (!$classReflection->hasConstructor()) { + return []; + } + + $constructorReflection = $classReflection->getConstructor(); + $methodReflection = $node->getMethodReflection(); + if ($methodReflection->getName() !== $constructorReflection->getName()) { + return []; + } + + if ($methodNode->returnType === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Constructor of class %s has a return type.', $classReflection->getDisplayName())) + ->identifier('constructor.returnType') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/src/Rules/Methods/DirectAlwaysUsedMethodExtensionProvider.php b/src/Rules/Methods/DirectAlwaysUsedMethodExtensionProvider.php new file mode 100644 index 00000000..61dc47a6 --- /dev/null +++ b/src/Rules/Methods/DirectAlwaysUsedMethodExtensionProvider.php @@ -0,0 +1,21 @@ +extensions; + } + +} diff --git a/src/Rules/Methods/ExistingClassesInTypehintsRule.php b/src/Rules/Methods/ExistingClassesInTypehintsRule.php new file mode 100644 index 00000000..154247df --- /dev/null +++ b/src/Rules/Methods/ExistingClassesInTypehintsRule.php @@ -0,0 +1,68 @@ + + */ +final class ExistingClassesInTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methodReflection = $node->getMethodReflection(); + $className = SprintfHelper::escapeFormatString($node->getClassReflection()->getDisplayName()); + $methodName = SprintfHelper::escapeFormatString($methodReflection->getName()); + + return $this->check->checkClassMethod( + $methodReflection, + $node->getOriginalNode(), + sprintf( + 'Parameter $%%s of method %s::%s() has invalid type %%s.', + $className, + $methodName, + ), + sprintf( + 'Method %s::%s() has invalid return type %%s.', + $className, + $methodName, + ), + sprintf('Method %s::%s() uses native union types but they\'re supported only on PHP 8.0 and later.', $className, $methodName), + sprintf('Template type %%s of method %s::%s() is not referenced in a parameter.', $className, $methodName), + sprintf( + 'Parameter $%%s of method %s::%s() has unresolvable native type.', + $className, + $methodName, + ), + sprintf( + 'Method %s::%s() has unresolvable native return type.', + $className, + $methodName, + ), + sprintf( + 'Method %s::%s() has invalid @phpstan-self-out type %%s.', + $className, + $methodName, + ), + ); + } + +} diff --git a/src/Rules/Methods/FinalPrivateMethodRule.php b/src/Rules/Methods/FinalPrivateMethodRule.php new file mode 100644 index 00000000..849716a6 --- /dev/null +++ b/src/Rules/Methods/FinalPrivateMethodRule.php @@ -0,0 +1,46 @@ + */ +final class FinalPrivateMethodRule implements Rule +{ + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + if ($scope->getPhpVersion()->producesWarningForFinalPrivateMethods()->no()) { + return []; + } + + if ($method->getName() === '__construct') { + return []; + } + + if (!$method->isFinal()->yes() || !$method->isPrivate()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Private method %s::%s() cannot be final as it is never overridden by other classes.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('method.finalPrivate')->build(), + ]; + } + +} diff --git a/src/Rules/Methods/IncompatibleDefaultParameterTypeRule.php b/src/Rules/Methods/IncompatibleDefaultParameterTypeRule.php new file mode 100644 index 00000000..c974ae50 --- /dev/null +++ b/src/Rules/Methods/IncompatibleDefaultParameterTypeRule.php @@ -0,0 +1,73 @@ + + */ +final class IncompatibleDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameter = $method->getParameters()[$paramI]; + $parameterType = $parameter->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + $accepts = $parameterType->accepts($defaultValueType, true); + if ($accepts->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of method %s::%s() is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $parameterType->describe($verbosityLevel), + )) + ->line($param->getStartLine()) + ->identifier('parameter.defaultValue') + ->acceptsReasonsTip($accepts->reasons) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Methods/LazyAlwaysUsedMethodExtensionProvider.php b/src/Rules/Methods/LazyAlwaysUsedMethodExtensionProvider.php new file mode 100644 index 00000000..929f5f75 --- /dev/null +++ b/src/Rules/Methods/LazyAlwaysUsedMethodExtensionProvider.php @@ -0,0 +1,23 @@ +extensions ??= $this->container->getServicesByTag(static::EXTENSION_TAG); + } + +} diff --git a/src/Rules/Methods/MethodAttributesRule.php b/src/Rules/Methods/MethodAttributesRule.php new file mode 100644 index 00000000..ba39dba3 --- /dev/null +++ b/src/Rules/Methods/MethodAttributesRule.php @@ -0,0 +1,38 @@ + + */ +final class MethodAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_METHOD, + 'method', + ); + } + +} diff --git a/src/Rules/Methods/MethodCallCheck.php b/src/Rules/Methods/MethodCallCheck.php new file mode 100644 index 00000000..97ccd545 --- /dev/null +++ b/src/Rules/Methods/MethodCallCheck.php @@ -0,0 +1,151 @@ +, ExtendedMethodReflection|null} + */ + public function check( + Scope $scope, + string $methodName, + Expr $var, + ): array + { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $var), + sprintf('Call to method %s() on an unknown class %%s.', SprintfHelper::escapeFormatString($methodName)), + static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(), + ); + + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return [$typeResult->getUnknownClassErrors(), null]; + } + + $typeForDescribe = $type; + if ($type instanceof StaticType) { + $typeForDescribe = $type->getStaticObjectType(); + } + if (!$type->canCallMethods()->yes() || $type->isClassString()->yes()) { + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Cannot call method %s() on %s.', + $methodName, + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + ))->identifier('method.nonObject')->build(), + ], + null, + ]; + } + + if (!$type->hasMethod($methodName)->yes()) { + $directClassNames = $typeResult->getReferencedClasses(); + if (!$this->reportMagicMethods) { + foreach ($directClassNames as $className) { + if (!$this->reflectionProvider->hasClass($className)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->hasNativeMethod('__call')) { + return [[], null]; + } + } + } + + if (count($directClassNames) === 1) { + $referencedClass = $directClassNames[0]; + $methodClassReflection = $this->reflectionProvider->getClass($referencedClass); + $parentClassReflection = $methodClassReflection->getParentClass(); + while ($parentClassReflection !== null) { + if ($parentClassReflection->hasMethod($methodName)) { + $methodReflection = $parentClassReflection->getMethod($methodName, $scope); + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Call to private method %s() of parent class %s.', + $methodReflection->getName(), + $parentClassReflection->getDisplayName(), + ))->identifier('method.private')->build(), + ], + $methodReflection, + ]; + } + + $parentClassReflection = $parentClassReflection->getParentClass(); + } + } + + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Call to an undefined method %s::%s().', + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + $methodName, + ))->identifier('method.notFound')->build(), + ], + null, + ]; + } + + $methodReflection = $type->getMethod($methodName, $scope); + $declaringClass = $methodReflection->getDeclaringClass(); + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); + $errors = []; + if (!$scope->canCallMethod($methodReflection)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to %s method %s() of class %s.', + $methodReflection->isPrivate() ? 'private' : 'protected', + $methodReflection->getName(), + $declaringClass->getDisplayName(), + )) + ->identifier(sprintf('method.%s', $methodReflection->isPrivate() ? 'private' : 'protected')) + ->build(); + } + + if ( + $this->checkFunctionNameCase + && strtolower($methodReflection->getName()) === strtolower($methodName) + && $methodReflection->getName() !== $methodName + ) { + $errors[] = RuleErrorBuilder::message( + sprintf('Call to method %s with incorrect case: %s', $messagesMethodName, $methodName), + )->identifier('method.nameCase')->build(); + } + + return [$errors, $methodReflection]; + } + +} diff --git a/src/Rules/Methods/MethodCallableRule.php b/src/Rules/Methods/MethodCallableRule.php new file mode 100644 index 00000000..afa96e4a --- /dev/null +++ b/src/Rules/Methods/MethodCallableRule.php @@ -0,0 +1,67 @@ + + */ +final class MethodCallableRule implements Rule +{ + + public function __construct(private MethodCallCheck $methodCallCheck, private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return MethodCallableNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->supportsFirstClassCallables()) { + return [ + RuleErrorBuilder::message('First-class callables are supported only on PHP 8.1 and later.') + ->nonIgnorable() + ->identifier('callable.notSupported') + ->build(), + ]; + } + + $methodName = $node->getName(); + if (!$methodName instanceof Node\Identifier) { + return []; + } + + $methodNameName = $methodName->toString(); + + [$errors, $methodReflection] = $this->methodCallCheck->check($scope, $methodNameName, $node->getVar()); + if ($methodReflection === null) { + return $errors; + } + + $declaringClass = $methodReflection->getDeclaringClass(); + if ($declaringClass->hasNativeMethod($methodNameName)) { + return $errors; + } + + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); + + $errors[] = RuleErrorBuilder::message(sprintf('Creating callable from a non-native method %s.', $messagesMethodName)) + ->identifier('callable.nonNativeMethod') + ->build(); + + return $errors; + } + +} diff --git a/src/Rules/Methods/MethodParameterComparisonHelper.php b/src/Rules/Methods/MethodParameterComparisonHelper.php new file mode 100644 index 00000000..70e001d3 --- /dev/null +++ b/src/Rules/Methods/MethodParameterComparisonHelper.php @@ -0,0 +1,411 @@ + + */ + public function compare(ExtendedMethodReflection $prototype, ClassReflection $prototypeDeclaringClass, PhpMethodFromParserNodeReflection $method, bool $ignorable): array + { + /** @var list $messages */ + $messages = []; + $prototypeVariant = $prototype->getVariants()[0]; + + $methodParameters = $method->getParameters(); + + $prototypeAfterVariadic = false; + foreach ($prototypeVariant->getParameters() as $i => $prototypeParameter) { + if (!array_key_exists($i, $methodParameters)) { + $error = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides method %s::%s() but misses parameter #%d $%s.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + $i + 1, + $prototypeParameter->getName(), + ))->identifier('parameter.missing'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } + + $methodParameter = $methodParameters[$i]; + if ($prototypeParameter->passedByReference()->no()) { + if (!$methodParameter->passedByReference()->no()) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is passed by reference but parameter #%d $%s of method %s::%s() is not passed by reference.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('parameter.byRef'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + } elseif ($methodParameter->passedByReference()->no()) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not passed by reference but parameter #%d $%s of method %s::%s() is passed by reference.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('parameter.notByRef'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + + if ($prototypeParameter->isVariadic()) { + $prototypeAfterVariadic = true; + if (!$methodParameter->isVariadic()) { + if (!$methodParameter->isOptional()) { + if (count($methodParameters) !== $i + 1) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not optional.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('parameter.notOptional'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } + + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not variadic but parameter #%d $%s of method %s::%s() is variadic.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('parameter.notVariadic'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } elseif (count($methodParameters) === $i + 1) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not variadic.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('parameter.notVariadic'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + } + } elseif ($methodParameter->isVariadic()) { + if ($this->phpVersion->supportsLessOverridenParametersWithVariadic()) { + $remainingPrototypeParameters = array_slice($prototypeVariant->getParameters(), $i); + foreach ($remainingPrototypeParameters as $j => $remainingPrototypeParameter) { + if ($methodParameter->getNativeType()->isSuperTypeOf($remainingPrototypeParameter->getNativeType())->yes()) { + continue; + } + + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d ...$%s (%s) of method %s::%s() is not contravariant with parameter #%d $%s (%s) of method %s::%s().', + $i + 1, + $methodParameter->getName(), + $methodParameter->getNativeType()->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + $j + 1, + $remainingPrototypeParameter->getName(), + $remainingPrototypeParameter->getNativeType()->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.childParameterType'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + break; + } + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is variadic but parameter #%d $%s of method %s::%s() is not variadic.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('parameter.variadic'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } + + if ($prototypeParameter->isOptional() && !$methodParameter->isOptional()) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is required but parameter #%d $%s of method %s::%s() is optional.', + $i + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('parameter.notOptional'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + + $methodParameterType = $methodParameter->getNativeType(); + + $prototypeParameterType = $prototypeParameter->getNativeType(); + if (!$this->phpVersion->supportsParameterTypeWidening()) { + if (!$methodParameterType->equals($prototypeParameterType)) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s (%s) of method %s::%s() does not match parameter #%d $%s (%s) of method %s::%s().', + $i + 1, + $methodParameter->getName(), + $methodParameterType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeParameterType->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.childParameterType'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + continue; + } + + if ($this->isParameterTypeCompatible($methodParameterType, $prototypeParameterType, $this->phpVersion->supportsParameterContravariance())) { + continue; + } + + if ($this->phpVersion->supportsParameterContravariance()) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s (%s) of method %s::%s() is not contravariant with parameter #%d $%s (%s) of method %s::%s().', + $i + 1, + $methodParameter->getName(), + $methodParameterType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeParameterType->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.childParameterType'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } else { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s (%s) of method %s::%s() is not compatible with parameter #%d $%s (%s) of method %s::%s().', + $i + 1, + $methodParameter->getName(), + $methodParameterType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $i + 1, + $prototypeParameter->getName(), + $prototypeParameterType->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.childParameterType'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + } + } + + if (!isset($i)) { + $i = -1; + } + + foreach ($methodParameters as $j => $methodParameter) { + if ($j <= $i) { + continue; + } + + if ( + $j === count($methodParameters) - 1 + && $prototypeAfterVariadic + && !$methodParameter->isVariadic() + ) { + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not variadic.', + $j + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('parameter.notVariadic'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } + + if ($methodParameter->isOptional()) { + continue; + } + + $error = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of method %s::%s() is not optional.', + $j + 1, + $methodParameter->getName(), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('parameter.notOptional'); + + if (! $ignorable) { + $error->nonIgnorable(); + } + + $messages[] = $error->build(); + + continue; + } + + return $messages; + } + + public function isParameterTypeCompatible(Type $methodParameterType, Type $prototypeParameterType, bool $supportsContravariance): bool + { + return $this->isTypeCompatible($methodParameterType, $prototypeParameterType, $supportsContravariance, false); + } + + public function isReturnTypeCompatible(Type $methodParameterType, Type $prototypeParameterType, bool $supportsCovariance): bool + { + return $this->isTypeCompatible($methodParameterType, $prototypeParameterType, $supportsCovariance, true); + } + + private function isTypeCompatible(Type $methodParameterType, Type $prototypeParameterType, bool $supportsContravariance, bool $considerMixedExplicitness): bool + { + if ($methodParameterType instanceof MixedType) { + if ($considerMixedExplicitness && $prototypeParameterType instanceof MixedType) { + return !$methodParameterType->isExplicitMixed() || $prototypeParameterType->isExplicitMixed(); + } + + return true; + } + + if (!$supportsContravariance) { + if (TypeCombinator::containsNull($methodParameterType)) { + $prototypeParameterType = TypeCombinator::removeNull($prototypeParameterType); + } + $methodParameterType = TypeCombinator::removeNull($methodParameterType); + if ($methodParameterType->equals($prototypeParameterType)) { + return true; + } + + if ($methodParameterType instanceof IterableType) { + if ($prototypeParameterType instanceof ArrayType) { + return true; + } + if ($prototypeParameterType instanceof ConstantArrayType) { + return true; + } + if ($prototypeParameterType->isObject()->yes() && $prototypeParameterType->getObjectClassNames() === [Traversable::class]) { + return true; + } + } + + return false; + } + + return $methodParameterType->isSuperTypeOf($prototypeParameterType)->yes(); + } + +} diff --git a/src/Rules/Methods/MethodSignatureRule.php b/src/Rules/Methods/MethodSignatureRule.php new file mode 100644 index 00000000..2069abf7 --- /dev/null +++ b/src/Rules/Methods/MethodSignatureRule.php @@ -0,0 +1,292 @@ + + */ +final class MethodSignatureRule implements Rule +{ + + public function __construct( + private PhpClassReflectionExtension $phpClassReflectionExtension, + private bool $reportMaybes, + private bool $reportStatic, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $methodName = $method->getName(); + if ($methodName === '__construct') { + return []; + } + if (!$this->reportStatic && $method->isStatic()) { + return []; + } + if ($method->isPrivate()) { + return []; + } + $errors = []; + $declaringClass = $method->getDeclaringClass(); + foreach ($this->collectParentMethods($methodName, $method->getDeclaringClass()) as [$parentMethod, $parentMethodDeclaringClass]) { + $parentVariants = $parentMethod->getVariants(); + if (count($parentVariants) !== 1) { + continue; + } + $parentVariant = $parentVariants[0]; + [$returnTypeCompatibility, $returnType, $parentReturnType] = $this->checkReturnTypeCompatibility($declaringClass, $method, $parentVariant); + if ($returnTypeCompatibility->no() || (!$returnTypeCompatibility->yes() && $this->reportMaybes)) { + $builder = RuleErrorBuilder::message(sprintf( + 'Return type (%s) of method %s::%s() should be %s with return type (%s) of method %s::%s()', + $returnType->describe(VerbosityLevel::value()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $returnTypeCompatibility->no() ? 'compatible' : 'covariant', + $parentReturnType->describe(VerbosityLevel::value()), + $parentMethodDeclaringClass->getDisplayName(), + $parentMethod->getName(), + ))->identifier('method.childReturnType'); + if ( + $parentMethod->getDeclaringClass()->getName() === Rule::class + && strtolower($methodName) === 'processnode' + ) { + $ruleErrorType = new ObjectType(RuleError::class); + $identifierRuleErrorType = new ObjectType(IdentifierRuleError::class); + $listOfIdentifierRuleErrors = new IntersectionType([ + new ArrayType(IntegerRangeType::fromInterval(0, null), $identifierRuleErrorType), + new AccessoryArrayListType(), + ]); + if ($listOfIdentifierRuleErrors->isSuperTypeOf($parentReturnType)->yes()) { + $returnValueType = $returnType->getIterableValueType(); + if (!$returnValueType->isString()->no()) { + $builder->tip('Rules can no longer return plain strings. See: https://phpstan.org/blog/using-rule-error-builder'); + } elseif ( + $ruleErrorType->isSuperTypeOf($returnValueType)->yes() + && !$identifierRuleErrorType->isSuperTypeOf($returnValueType)->yes() + ) { + $builder->tip('Errors are missing identifiers. See: https://phpstan.org/blog/using-rule-error-builder'); + } elseif (!$returnType->isList()->yes()) { + $builder->tip('Return type must be a list. See: https://phpstan.org/blog/using-rule-error-builder'); + } + } + } + $errors[] = $builder->build(); + } + + $parameterResults = $this->checkParameterTypeCompatibility($declaringClass, $method->getParameters(), $parentVariant->getParameters()); + foreach ($parameterResults as $parameterIndex => [$parameterResult, $parameterType, $parentParameterType]) { + if ($parameterResult->yes()) { + continue; + } + if (!$parameterResult->no() && !$this->reportMaybes) { + continue; + } + $parameter = $method->getParameters()[$parameterIndex]; + $parentParameter = $parentVariant->getParameters()[$parameterIndex]; + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s (%s) of method %s::%s() should be %s with parameter $%s (%s) of method %s::%s()', + $parameterIndex + 1, + $parameter->getName(), + $parameterType->describe(VerbosityLevel::value()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $parameterResult->no() ? 'compatible' : 'contravariant', + $parentParameter->getName(), + $parentParameterType->describe(VerbosityLevel::value()), + $parentMethodDeclaringClass->getDisplayName(), + $parentMethod->getName(), + ))->identifier('method.childParameterType')->build(); + } + } + + return $errors; + } + + /** + * @return list + */ + private function collectParentMethods(string $methodName, ClassReflection $class): array + { + $parentMethods = []; + + $parentClass = $class->getParentClass(); + if ($parentClass !== null && $parentClass->hasNativeMethod($methodName)) { + $parentMethod = $parentClass->getNativeMethod($methodName); + if (!$parentMethod->isPrivate()) { + $parentMethods[] = [$parentMethod, $parentMethod->getDeclaringClass()]; + } + } + + foreach ($class->getInterfaces() as $interface) { + if (!$interface->hasNativeMethod($methodName)) { + continue; + } + + $method = $interface->getNativeMethod($methodName); + $parentMethods[] = [$method, $method->getDeclaringClass()]; + } + + foreach ($class->getTraits(true) as $trait) { + $nativeTraitReflection = $trait->getNativeReflection(); + if (!$nativeTraitReflection->hasMethod($methodName)) { + continue; + } + + $methodReflection = $nativeTraitReflection->getMethod($methodName); + $isAbstract = $methodReflection->isAbstract(); + if (!$isAbstract) { + continue; + } + + $declaringTrait = $trait->getNativeMethod($methodName)->getDeclaringClass(); + $parentMethods[] = [ + $this->phpClassReflectionExtension->createUserlandMethodReflection( + $trait, + $class, + $methodReflection, + $declaringTrait->getName(), + ), + $declaringTrait, + ]; + } + + return $parentMethods; + } + + /** + * @return array{TrinaryLogic, Type, Type} + */ + private function checkReturnTypeCompatibility( + ClassReflection $declaringClass, + ExtendedParametersAcceptor $currentVariant, + ExtendedParametersAcceptor $parentVariant, + ): array + { + $returnType = TypehintHelper::decideType( + $currentVariant->getNativeReturnType(), + TemplateTypeHelper::resolveToBounds($currentVariant->getPhpDocReturnType()), + ); + $originalParentReturnType = TypehintHelper::decideType( + $parentVariant->getNativeReturnType(), + TemplateTypeHelper::resolveToBounds($parentVariant->getPhpDocReturnType()), + ); + $parentReturnType = $this->transformStaticType($declaringClass, $originalParentReturnType); + // Allow adding `void` return type hints when the parent defines no return type + if ($returnType->isVoid()->yes() && $parentReturnType instanceof MixedType) { + return [TrinaryLogic::createYes(), $returnType, $parentReturnType]; + } + + // We can return anything + if ($parentReturnType->isVoid()->yes()) { + return [TrinaryLogic::createYes(), $returnType, $parentReturnType]; + } + + return [$parentReturnType->isSuperTypeOf($returnType)->result, TypehintHelper::decideType( + $currentVariant->getNativeReturnType(), + $currentVariant->getPhpDocReturnType(), + ), $originalParentReturnType]; + } + + /** + * @param ExtendedParameterReflection[] $parameters + * @param ExtendedParameterReflection[] $parentParameters + * @return array + */ + private function checkParameterTypeCompatibility( + ClassReflection $declaringClass, + array $parameters, + array $parentParameters, + ): array + { + $parameterResults = []; + + $numberOfParameters = min(count($parameters), count($parentParameters)); + for ($i = 0; $i < $numberOfParameters; $i++) { + $parameter = $parameters[$i]; + $parentParameter = $parentParameters[$i]; + + $parameterType = TypehintHelper::decideType( + $parameter->getNativeType(), + TemplateTypeHelper::resolveToBounds($parameter->getPhpDocType()), + ); + $originalParameterType = TypehintHelper::decideType( + $parentParameter->getNativeType(), + TemplateTypeHelper::resolveToBounds($parentParameter->getPhpDocType()), + ); + $parentParameterType = $this->transformStaticType($declaringClass, $originalParameterType); + + $parameterResults[] = [$parameterType->isSuperTypeOf($parentParameterType)->result, TypehintHelper::decideType( + $parameter->getNativeType(), + $parameter->getPhpDocType(), + ), $originalParameterType]; + } + + return $parameterResults; + } + + private function transformStaticType(ClassReflection $declaringClass, Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($declaringClass): Type { + if ($type instanceof GenericStaticType) { + if ($declaringClass->isFinal()) { + $changedType = $type->changeBaseClass($declaringClass)->getStaticObjectType(); + } else { + $changedType = $type->changeBaseClass($declaringClass); + } + return $traverse($changedType); + } + + if ($type instanceof StaticType) { + if ($declaringClass->isFinal()) { + $changedType = new ObjectType($declaringClass->getName()); + } else { + $changedType = $type->changeBaseClass($declaringClass); + } + return $traverse($changedType); + } + + return $traverse($type); + }); + } + +} diff --git a/src/Rules/Methods/MethodVisibilityComparisonHelper.php b/src/Rules/Methods/MethodVisibilityComparisonHelper.php new file mode 100644 index 00000000..89cc2f01 --- /dev/null +++ b/src/Rules/Methods/MethodVisibilityComparisonHelper.php @@ -0,0 +1,52 @@ + */ + public function compare(ExtendedMethodReflection $prototype, ClassReflection $prototypeDeclaringClass, PhpMethodFromParserNodeReflection $method): array + { + /** @var list $messages */ + $messages = []; + + if ($prototype->isPublic()) { + if (!$method->isPublic()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s method %s::%s() overriding public method %s::%s() should also be public.', + $method->isPrivate() ? 'Private' : 'Protected', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.visibility') + ->build(); + } + } elseif ($method->isPrivate()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Private method %s::%s() overriding protected method %s::%s() should be protected or public.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.visibility') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/MethodVisibilityInInterfaceRule.php b/src/Rules/Methods/MethodVisibilityInInterfaceRule.php new file mode 100644 index 00000000..11ca68a0 --- /dev/null +++ b/src/Rules/Methods/MethodVisibilityInInterfaceRule.php @@ -0,0 +1,48 @@ + */ +final class MethodVisibilityInInterfaceRule implements Rule +{ + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + + if ($method->isPublic()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + return []; + } + + if (!$classReflection->isInterface()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() cannot use non-public visibility in interface.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('method.visibility')->nonIgnorable()->build(), + ]; + } + +} diff --git a/src/Rules/Methods/MissingMagicSerializationMethodsRule.php b/src/Rules/Methods/MissingMagicSerializationMethodsRule.php new file mode 100644 index 00000000..70dc8d75 --- /dev/null +++ b/src/Rules/Methods/MissingMagicSerializationMethodsRule.php @@ -0,0 +1,88 @@ + + */ +final class MissingMagicSerializationMethodsRule implements Rule +{ + + public function __construct(private PhpVersion $phpversion) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$this->phpversion->serializableRequiresMagicMethods()) { + return []; + } + if (!$classReflection->implementsInterface(Serializable::class)) { + return []; + } + if ($classReflection->isAbstract() || $classReflection->isInterface() || $classReflection->isEnum()) { + return []; + } + + $messages = []; + + try { + $nativeMethods = $classReflection->getNativeReflection()->getMethods(); + } catch (IdentifierNotFound) { + return []; + } + + $missingMagicSerialize = true; + $missingMagicUnserialize = true; + foreach ($nativeMethods as $method) { + if (strtolower($method->getName()) === '__serialize') { + $missingMagicSerialize = false; + } + if (strtolower($method->getName()) !== '__unserialize') { + continue; + } + + $missingMagicUnserialize = false; + } + + if ($missingMagicSerialize) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Non-abstract class %s implements the Serializable interface, but does not implement __serialize().', + $classReflection->getDisplayName(), + )) + ->tip('See https://wiki.php.net/rfc/phase_out_serializable') + ->identifier('class.serializable') + ->build(); + } + if ($missingMagicUnserialize) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Non-abstract class %s implements the Serializable interface, but does not implement __unserialize().', + $classReflection->getDisplayName(), + )) + ->tip('See https://wiki.php.net/rfc/phase_out_serializable') + ->identifier('class.serializable') + ->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/MissingMethodImplementationRule.php b/src/Rules/Methods/MissingMethodImplementationRule.php new file mode 100644 index 00000000..26102c16 --- /dev/null +++ b/src/Rules/Methods/MissingMethodImplementationRule.php @@ -0,0 +1,67 @@ + + */ +final class MissingMethodImplementationRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if ($classReflection->isInterface()) { + return []; + } + if ($classReflection->isAbstract()) { + return []; + } + + $messages = []; + + try { + $nativeMethods = $classReflection->getNativeReflection()->getMethods(); + } catch (IdentifierNotFound) { + return []; + } + foreach ($nativeMethods as $method) { + if (!$method->isAbstract()) { + continue; + } + + $declaringClass = $method->getDeclaringClass(); + + $classLikeDescription = 'Non-abstract class'; + if ($classReflection->isEnum()) { + $classLikeDescription = 'Enum'; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + '%s %s contains abstract method %s() from %s %s.', + $classLikeDescription, + $classReflection->getDisplayName(), + $method->getName(), + $declaringClass->isInterface() ? 'interface' : 'class', + $declaringClass->getName(), + ))->nonIgnorable()->identifier('method.abstract')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/MissingMethodParameterTypehintRule.php b/src/Rules/Methods/MissingMethodParameterTypehintRule.php new file mode 100644 index 00000000..af5b65fa --- /dev/null +++ b/src/Rules/Methods/MissingMethodParameterTypehintRule.php @@ -0,0 +1,121 @@ + + */ +final class MissingMethodParameterTypehintRule implements Rule +{ + + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methodReflection = $node->getMethodReflection(); + $messages = []; + + foreach ($methodReflection->getParameters() as $parameterReflection) { + foreach ($this->checkMethodParameter($methodReflection, sprintf('parameter $%s', $parameterReflection->getName()), $parameterReflection->getType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + + if ($parameterReflection->getClosureThisType() !== null) { + foreach ($this->checkMethodParameter($methodReflection, sprintf('@param-closure-this PHPDoc tag for parameter $%s', $parameterReflection->getName()), $parameterReflection->getClosureThisType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + } + + if ($parameterReflection->getOutType() === null) { + continue; + } + + foreach ($this->checkMethodParameter($methodReflection, sprintf('@param-out PHPDoc tag for parameter $%s', $parameterReflection->getName()), $parameterReflection->getOutType()) as $parameterMessage) { + $messages[] = $parameterMessage; + } + } + + return $messages; + } + + /** + * @return list + */ + private function checkMethodParameter(MethodReflection $methodReflection, string $parameterMessage, Type $parameterType): array + { + if ($parameterType instanceof MixedType && !$parameterType->isExplicitMixed()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with no type specified.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + ))->identifier('missingType.parameter')->build(), + ]; + } + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with no value type specified in iterable type %s.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($parameterType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic %s but does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with no signature specified for %s.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/MissingMethodReturnTypehintRule.php b/src/Rules/Methods/MissingMethodReturnTypehintRule.php new file mode 100644 index 00000000..99648995 --- /dev/null +++ b/src/Rules/Methods/MissingMethodReturnTypehintRule.php @@ -0,0 +1,91 @@ + + */ +final class MissingMethodReturnTypehintRule implements Rule +{ + + public function __construct(private MissingTypehintCheck $missingTypehintCheck) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methodReflection = $node->getMethodReflection(); + if ($scope->isInTrait()) { + $methodNode = $node->getOriginalNode(); + $originalMethodName = $methodNode->getAttribute('originalTraitMethodName'); + if ($originalMethodName === '__construct') { + return []; + } + } + $returnType = $methodReflection->getReturnType(); + + if ($returnType instanceof MixedType && !$returnType->isExplicitMixed()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has no return type specified.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + ))->identifier('missingType.return')->build(), + ]; + } + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() return type has no value type specified in iterable type %s.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($returnType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() return type with generic %s does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() return type has no signature specified for %s.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php new file mode 100644 index 00000000..a6c19e19 --- /dev/null +++ b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php @@ -0,0 +1,85 @@ + + */ +final class MissingMethodSelfOutTypeRule implements Rule +{ + + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $methodReflection = $node->getMethodReflection(); + $selfOutType = $methodReflection->getSelfOutType(); + + if ($selfOutType === null) { + return []; + } + + $classReflection = $methodReflection->getDeclaringClass(); + $phpDocTagMessage = 'PHPDoc tag @phpstan-self-out'; + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($selfOutType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with no value type specified in iterable type %s.', + $classReflection->getDisplayName(), + $methodReflection->getName(), + $phpDocTagMessage, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($selfOutType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodReflection->getName(), + $phpDocTagMessage, + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($selfOutType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with no signature specified for %s.', + $classReflection->getDisplayName(), + $methodReflection->getName(), + $phpDocTagMessage, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Methods/NullsafeMethodCallRule.php b/src/Rules/Methods/NullsafeMethodCallRule.php new file mode 100644 index 00000000..b2280840 --- /dev/null +++ b/src/Rules/Methods/NullsafeMethodCallRule.php @@ -0,0 +1,38 @@ + + */ +final class NullsafeMethodCallRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\NullsafeMethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $calledOnType = $scope->getType($node->var); + if (!$calledOnType->isNull()->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Using nullsafe method call on non-nullable type %s. Use -> instead.', $calledOnType->describe(VerbosityLevel::typeOnly()))) + ->identifier('nullsafe.neverNull') + ->build(), + ]; + } + +} diff --git a/src/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php new file mode 100644 index 00000000..b428a99a --- /dev/null +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -0,0 +1,393 @@ + + */ +final class OverridingMethodRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + private MethodSignatureRule $methodSignatureRule, + private bool $checkPhpDocMethodSignatures, + private MethodParameterComparisonHelper $methodParameterComparisonHelper, + private MethodVisibilityComparisonHelper $methodVisibilityComparisonHelper, + private PhpClassReflectionExtension $phpClassReflectionExtension, + private bool $checkMissingOverrideMethodAttribute, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $prototypeData = $this->findPrototype($node->getClassReflection(), $method->getName()); + if ($prototypeData === null) { + if (strtolower($method->getName()) === '__construct') { + $parent = $method->getDeclaringClass()->getParentClass(); + if ($parent !== null && $parent->hasConstructor()) { + $parentConstructor = $parent->getConstructor(); + if ($parentConstructor->isFinalByKeyword()->yes()) { + return $this->addErrors([ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides final method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $parent->getDisplayName(true), + $parentConstructor->getName(), + )) + ->nonIgnorable() + ->identifier('method.parentMethodFinal') + ->build(), + ], $node, $scope); + } + if ($parentConstructor->isFinal()->yes()) { + return $this->addErrors([ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides @final method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $parent->getDisplayName(true), + $parentConstructor->getName(), + ))->identifier('method.parentMethodFinalByPhpDoc') + ->build(), + ], $node, $scope); + } + } + } + + if ($this->phpVersion->supportsOverrideAttribute() && $this->hasOverrideAttribute($node->getOriginalNode())) { + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has #[\Override] attribute but does not override any method.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + )) + ->nonIgnorable() + ->identifier('method.override') + ->build(), + ]; + } + + return []; + } + + [$prototype, $prototypeDeclaringClass, $checkVisibility] = $prototypeData; + + $messages = []; + if ( + $this->phpVersion->supportsOverrideAttribute() + && $this->checkMissingOverrideMethodAttribute + && !$this->hasOverrideAttribute($node->getOriginalNode()) + ) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides method %s::%s() but is missing the #[\Override] attribute.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.missingOverride')->build(); + } + if ($prototype->isFinalByKeyword()->yes()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides final method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.parentMethodFinal') + ->build(); + } elseif ($prototype->isFinal()->yes()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() overrides @final method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + ))->identifier('method.parentMethodFinalByPhpDoc') + ->build(); + } + + if ($prototype->isStatic()) { + if (!$method->isStatic()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Non-static method %s::%s() overrides static method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.nonStatic') + ->build(); + } + } elseif ($method->isStatic()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Static method %s::%s() overrides non-static method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.static') + ->build(); + } + + if ($checkVisibility) { + $messages = array_merge($messages, $this->methodVisibilityComparisonHelper->compare($prototype, $prototypeDeclaringClass, $method)); + } + + $prototypeVariants = $prototype->getVariants(); + if (count($prototypeVariants) !== 1) { + return $this->addErrors($messages, $node, $scope); + } + + $prototypeVariant = $prototypeVariants[0]; + + $methodReturnType = $method->getNativeReturnType(); + + $realPrototype = $method->getPrototype(); + + if ( + $realPrototype instanceof MethodPrototypeReflection + && $this->phpVersion->hasTentativeReturnTypes() + && $realPrototype->getTentativeReturnType() !== null + && !$this->hasReturnTypeWillChangeAttribute($node->getOriginalNode()) + && count($prototypeDeclaringClass->getNativeReflection()->getMethod($prototype->getName())->getAttributes('ReturnTypeWillChange')) === 0 + ) { + if (!$this->methodParameterComparisonHelper->isReturnTypeCompatible($realPrototype->getTentativeReturnType(), $method->getNativeReturnType(), true)) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Return type %s of method %s::%s() is not covariant with tentative return type %s of method %s::%s().', + $methodReturnType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $realPrototype->getTentativeReturnType()->describe(VerbosityLevel::typeOnly()), + $realPrototype->getDeclaringClass()->getDisplayName(true), + $realPrototype->getName(), + )) + ->tip('Make it covariant, or use the #[\ReturnTypeWillChange] attribute to temporarily suppress the error.') + ->nonIgnorable() + ->identifier('method.tentativeReturnType') + ->build(); + } + } + + $messages = array_merge($messages, $this->methodParameterComparisonHelper->compare($prototype, $prototypeDeclaringClass, $method, false)); + + if (!$prototypeVariant instanceof ExtendedFunctionVariant) { + return $this->addErrors($messages, $node, $scope); + } + + $prototypeReturnType = $prototypeVariant->getNativeReturnType(); + $reportReturnType = true; + if ($this->phpVersion->hasTentativeReturnTypes()) { + $reportReturnType = !$realPrototype instanceof MethodPrototypeReflection || $realPrototype->getTentativeReturnType() === null || $prototype->isInternal()->no(); + } else { + if ($realPrototype instanceof MethodPrototypeReflection && $realPrototype->isInternal()) { + if ($prototype->isInternal()->yes() && $prototypeDeclaringClass->getName() !== $realPrototype->getDeclaringClass()->getName()) { + $realPrototypeVariant = $realPrototype->getVariants()[0]; + if ( + $prototypeReturnType instanceof MixedType + && !$prototypeReturnType->isExplicitMixed() + && (!$realPrototypeVariant->getReturnType() instanceof MixedType || $realPrototypeVariant->getReturnType()->isExplicitMixed()) + ) { + $reportReturnType = false; + } + } + + if ($reportReturnType && $prototype->isInternal()->yes()) { + $reportReturnType = !$this->hasReturnTypeWillChangeAttribute($node->getOriginalNode()); + } + } + } + + if ( + $reportReturnType + && !$this->methodParameterComparisonHelper->isReturnTypeCompatible($prototypeReturnType, $methodReturnType, $this->phpVersion->supportsReturnCovariance()) + ) { + if ($this->phpVersion->supportsReturnCovariance()) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Return type %s of method %s::%s() is not covariant with return type %s of method %s::%s().', + $methodReturnType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeReturnType->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.childReturnType') + ->build(); + } else { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Return type %s of method %s::%s() is not compatible with return type %s of method %s::%s().', + $methodReturnType->describe(VerbosityLevel::typeOnly()), + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $prototypeReturnType->describe(VerbosityLevel::typeOnly()), + $prototypeDeclaringClass->getDisplayName(true), + $prototype->getName(), + )) + ->nonIgnorable() + ->identifier('method.childReturnType') + ->build(); + } + } + + return $this->addErrors($messages, $node, $scope); + } + + /** + * @param list $errors + * @return list + */ + private function addErrors( + array $errors, + InClassMethodNode $classMethod, + Scope $scope, + ): array + { + if (count($errors) > 0) { + return $errors; + } + + if (!$this->checkPhpDocMethodSignatures) { + return $errors; + } + + return $this->methodSignatureRule->processNode($classMethod, $scope); + } + + private function hasReturnTypeWillChangeAttribute(Node\Stmt\ClassMethod $method): bool + { + foreach ($method->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toLowerString() === 'returntypewillchange') { + return true; + } + } + } + + return false; + } + + private function hasOverrideAttribute(Node\Stmt\ClassMethod $method): bool + { + foreach ($method->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toLowerString() === 'override') { + return true; + } + } + } + + return false; + } + + /** + * @return array{ExtendedMethodReflection, ClassReflection, bool}|null + */ + private function findPrototype(ClassReflection $classReflection, string $methodName): ?array + { + foreach ($classReflection->getImmediateInterfaces() as $immediateInterface) { + if ($immediateInterface->hasNativeMethod($methodName)) { + $method = $immediateInterface->getNativeMethod($methodName); + return [$method, $method->getDeclaringClass(), true]; + } + } + + if ($this->phpVersion->supportsAbstractTraitMethods()) { + foreach ($classReflection->getTraits(true) as $trait) { + $nativeTraitReflection = $trait->getNativeReflection(); + if (!$nativeTraitReflection->hasMethod($methodName)) { + continue; + } + + $methodReflection = $nativeTraitReflection->getMethod($methodName); + $isAbstract = $methodReflection->isAbstract(); + if ($isAbstract) { + $declaringTrait = $trait->getNativeMethod($methodName)->getDeclaringClass(); + return [ + $this->phpClassReflectionExtension->createUserlandMethodReflection( + $trait, + $classReflection, + $methodReflection, + $declaringTrait->getName(), + ), + $declaringTrait, + false, + ]; + } + } + } + + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null) { + return null; + } + + if (!$parentClass->hasNativeMethod($methodName)) { + return null; + } + + $method = $parentClass->getNativeMethod($methodName); + if ($method->isPrivate()) { + return null; + } + + $declaringClass = $method->getDeclaringClass(); + if ($declaringClass->hasConstructor()) { + if ($method->getName() === $declaringClass->getConstructor()->getName()) { + $prototype = $method->getPrototype(); + if ($prototype instanceof PhpMethodReflection || $prototype instanceof MethodPrototypeReflection || $prototype instanceof NativeMethodReflection) { + $abstract = $prototype->isAbstract(); + if (is_bool($abstract)) { + if (!$abstract) { + return null; + } + } elseif (!$abstract->yes()) { + return null; + } + } + } elseif (strtolower($methodName) === '__construct') { + return null; + } + } + + return [$method, $method->getDeclaringClass(), true]; + } + +} diff --git a/src/Rules/Methods/ReturnTypeRule.php b/src/Rules/Methods/ReturnTypeRule.php new file mode 100644 index 00000000..edaf19dd --- /dev/null +++ b/src/Rules/Methods/ReturnTypeRule.php @@ -0,0 +1,131 @@ + + */ +final class ReturnTypeRule implements Rule +{ + + public function __construct(private FunctionReturnTypeCheck $returnTypeCheck) + { + } + + public function getNodeType(): string + { + return Return_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($scope->getFunction() === null) { + return []; + } + + if ($scope->isInAnonymousFunction()) { + return []; + } + + $method = $scope->getFunction(); + if (!$method instanceof PhpMethodFromParserNodeReflection) { + return []; + } + + if ($method->isPropertyHook()) { + $methodDescription = sprintf( + '%s hook for property %s::$%s', + ucfirst($method->getPropertyHookName()), + $method->getDeclaringClass()->getDisplayName(), + $method->getHookedPropertyName(), + ); + } else { + $methodDescription = sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()); + } + + $returnType = $method->getReturnType(); + $errors = $this->returnTypeCheck->checkReturnType( + $scope, + $returnType, + $node->expr, + $node, + sprintf( + '%s should return %%s but empty return statement found.', + $methodDescription, + ), + sprintf( + '%s with return type void returns %%s but should not return anything.', + $methodDescription, + ), + sprintf( + '%s should return %%s but returns %%s.', + $methodDescription, + ), + sprintf( + '%s should never return but return statement found.', + $methodDescription, + ), + $method->isGenerator(), + ); + + if ( + count($errors) === 1 + && $errors[0]->getIdentifier() === 'return.type' + && !$errors[0] instanceof TipRuleError + && $errors[0] instanceof LineRuleError + && $method->getDeclaringClass()->isSubclassOf(Rule::class) + && strtolower($method->getName()) === 'processnode' + && $node->expr !== null + ) { + $ruleErrorType = new ObjectType(RuleError::class); + $identifierRuleErrorType = new ObjectType(IdentifierRuleError::class); + $listOfIdentifierRuleErrors = new IntersectionType([ + new ArrayType(IntegerRangeType::fromInterval(0, null), $identifierRuleErrorType), + new AccessoryArrayListType(), + ]); + if (!$listOfIdentifierRuleErrors->isSuperTypeOf($returnType)->yes()) { + return $errors; + } + + $returnValueType = $scope->getType($node->expr)->getIterableValueType(); + $builder = RuleErrorBuilder::message($errors[0]->getMessage()) + ->line($errors[0]->getLine()) + ->identifier($errors[0]->getIdentifier()); + if (!$returnValueType->isString()->no()) { + $builder->tip('Rules can no longer return plain strings. See: https://phpstan.org/blog/using-rule-error-builder'); + } elseif ( + $ruleErrorType->isSuperTypeOf($returnValueType)->yes() + && !$identifierRuleErrorType->isSuperTypeOf($returnValueType)->yes() + ) { + $builder->tip('Error is missing an identifier. See: https://phpstan.org/blog/using-rule-error-builder'); + } + + $errors = [$builder->build()]; + } + + return $errors; + } + +} diff --git a/src/Rules/Methods/StaticMethodCallCheck.php b/src/Rules/Methods/StaticMethodCallCheck.php new file mode 100644 index 00000000..481158a4 --- /dev/null +++ b/src/Rules/Methods/StaticMethodCallCheck.php @@ -0,0 +1,302 @@ +, ExtendedMethodReflection|null} + */ + public function check( + Scope $scope, + string $methodName, + $class, + ): array + { + $errors = []; + $isAbstract = false; + if ($class instanceof Name) { + $classStringType = $scope->getType(new Expr\ClassConstFetch($class, 'class')); + if ($classStringType->hasMethod($methodName)->yes()) { + return [[], null]; + } + + $className = (string) $class; + $lowercasedClassName = strtolower($className); + if (in_array($lowercasedClassName, ['self', 'static'], true)) { + if (!$scope->isInClass()) { + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Calling %s::%s() outside of class scope.', + $className, + $methodName, + ))->identifier(sprintf('outOfClass.%s', $lowercasedClassName))->build(), + ], + null, + ]; + } + $classType = $scope->resolveTypeByName($class); + } elseif ($lowercasedClassName === 'parent') { + if (!$scope->isInClass()) { + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Calling %s::%s() outside of class scope.', + $className, + $methodName, + ))->identifier(sprintf('outOfClass.parent'))->build(), + ], + null, + ]; + } + $currentClassReflection = $scope->getClassReflection(); + if ($currentClassReflection->getParentClass() === null) { + return [ + [ + RuleErrorBuilder::message(sprintf( + '%s::%s() calls parent::%s() but %s does not extend any class.', + $scope->getClassReflection()->getDisplayName(), + $scope->getFunctionName(), + $methodName, + $scope->getClassReflection()->getDisplayName(), + ))->identifier('class.noParent')->build(), + ], + null, + ]; + } + + if ($scope->getFunctionName() === null) { + throw new ShouldNotHappenException(); + } + + $classType = $scope->resolveTypeByName($class); + } else { + if (!$this->reflectionProvider->hasClass($className)) { + if ($scope->isInClassExists($className)) { + return [[], null]; + } + + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Call to static method %s() on an unknown class %s.', + $methodName, + $className, + ))->identifier('class.notFound')->discoveringSymbolsTip()->build(), + ], + null, + ]; + } + + $errors = $this->classCheck->checkClassNames([new ClassNameNodePair($className, $class)]); + + $classType = $scope->resolveTypeByName($class); + } + + $classReflection = $classType->getClassReflection(); + if ($classReflection !== null && $classReflection->hasNativeMethod($methodName) && $lowercasedClassName !== 'static') { + $nativeMethodReflection = $classReflection->getNativeMethod($methodName); + if ($nativeMethodReflection instanceof PhpMethodReflection || $nativeMethodReflection instanceof NativeMethodReflection) { + $isAbstract = $nativeMethodReflection->isAbstract(); + if ($isAbstract instanceof TrinaryLogic) { + $isAbstract = $isAbstract->yes(); + } + } + } + } else { + $classTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class), + sprintf('Call to static method %s() on an unknown class %%s.', SprintfHelper::escapeFormatString($methodName)), + static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(), + ); + $classType = $classTypeResult->getType(); + if ($classType instanceof ErrorType) { + return [$classTypeResult->getUnknownClassErrors(), null]; + } + } + + if ($classType instanceof GenericClassStringType) { + $classType = $classType->getGenericType(); + if (!$classType->isObject()->yes()) { + return [[], null]; + } + } elseif ($classType->isString()->yes()) { + return [[], null]; + } + + $typeForDescribe = $classType; + if ($classType instanceof StaticType) { + $typeForDescribe = $classType->getStaticObjectType(); + } + $classType = TypeCombinator::remove($classType, new StringType()); + + if (!$classType->canCallMethods()->yes()) { + return [ + array_merge($errors, [ + RuleErrorBuilder::message(sprintf( + 'Cannot call static method %s() on %s.', + $methodName, + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + ))->identifier('staticMethod.nonObject')->build(), + ]), + null, + ]; + } + + if (!$classType->hasMethod($methodName)->yes()) { + if (!$this->reportMagicMethods) { + foreach ($classType->getObjectClassNames() as $className) { + if (!$this->reflectionProvider->hasClass($className)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->hasNativeMethod('__callStatic')) { + return [[], null]; + } + } + } + + return [ + array_merge($errors, [ + RuleErrorBuilder::message(sprintf( + 'Call to an undefined static method %s::%s().', + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + $methodName, + ))->identifier('staticMethod.notFound')->build(), + ]), + null, + ]; + } + + $method = $classType->getMethod($methodName, $scope); + if (!$method->isStatic()) { + $function = $scope->getFunction(); + + $scopeIsInMethodClassOrSubClass = TrinaryLogic::createFromBoolean($scope->isInClass())->lazyAnd( + $classType->getObjectClassNames(), + static fn (string $objectClassName) => TrinaryLogic::createFromBoolean( + $scope->isInClass() + && ($scope->getClassReflection()->getName() === $objectClassName || $scope->getClassReflection()->isSubclassOf($objectClassName)), + ), + ); + if ( + !$function instanceof MethodReflection + || $function->isStatic() + || $scopeIsInMethodClassOrSubClass->no() + ) { + // per php-src docs, this method can be called statically, even if declared non-static + if (strtolower($method->getName()) === 'loadhtml' && $method->getDeclaringClass()->getName() === DOMDocument::class) { + return [[], null]; + } + + return [ + array_merge($errors, [ + RuleErrorBuilder::message(sprintf( + 'Static call to instance method %s::%s().', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier('method.staticCall')->build(), + ]), + $method, + ]; + } + } + + if (!$scope->canCallMethod($method)) { + $errors = array_merge($errors, [ + RuleErrorBuilder::message(sprintf( + 'Call to %s %s %s() of class %s.', + $method->isPrivate() ? 'private' : 'protected', + $method->isStatic() ? 'static method' : 'method', + $method->getName(), + $method->getDeclaringClass()->getDisplayName(), + )) + ->identifier(sprintf('staticMethod.%s', $method->isPrivate() ? 'private' : 'protected')) + ->build(), + ]); + } + + if ($isAbstract) { + return [ + [ + RuleErrorBuilder::message(sprintf( + 'Cannot call abstract%s method %s::%s().', + $method->isStatic() ? ' static' : '', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + ))->identifier(sprintf( + '%s.callToAbstract', + $method->isStatic() ? 'staticMethod' : 'method', + ))->build(), + ], + $method, + ]; + } + + $lowercasedMethodName = SprintfHelper::escapeFormatString(sprintf( + '%s %s', + $method->isStatic() ? 'static method' : 'method', + $method->getDeclaringClass()->getDisplayName() . '::' . $method->getName() . '()', + )); + + if ( + $this->checkFunctionNameCase + && $method->getName() !== $methodName + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to %s with incorrect case: %s', + $lowercasedMethodName, + $methodName, + ))->identifier('staticMethod.nameCase')->build(); + } + + return [$errors, $method]; + } + +} diff --git a/src/Rules/Methods/StaticMethodCallableRule.php b/src/Rules/Methods/StaticMethodCallableRule.php new file mode 100644 index 00000000..892b4e73 --- /dev/null +++ b/src/Rules/Methods/StaticMethodCallableRule.php @@ -0,0 +1,67 @@ + + */ +final class StaticMethodCallableRule implements Rule +{ + + public function __construct(private StaticMethodCallCheck $methodCallCheck, private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return StaticMethodCallableNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->supportsFirstClassCallables()) { + return [ + RuleErrorBuilder::message('First-class callables are supported only on PHP 8.1 and later.') + ->nonIgnorable() + ->identifier('callable.notSupported') + ->build(), + ]; + } + + $methodName = $node->getName(); + if (!$methodName instanceof Node\Identifier) { + return []; + } + + $methodNameName = $methodName->toString(); + + [$errors, $methodReflection] = $this->methodCallCheck->check($scope, $methodNameName, $node->getClass()); + if ($methodReflection === null) { + return $errors; + } + + $declaringClass = $methodReflection->getDeclaringClass(); + if ($declaringClass->hasNativeMethod($methodNameName)) { + return $errors; + } + + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); + + $errors[] = RuleErrorBuilder::message(sprintf('Creating callable from a non-native static method %s.', $messagesMethodName)) + ->identifier('callable.nonNativeMethod') + ->build(); + + return $errors; + } + +} diff --git a/src/Rules/Missing/MissingReturnRule.php b/src/Rules/Missing/MissingReturnRule.php new file mode 100644 index 00000000..d68ab267 --- /dev/null +++ b/src/Rules/Missing/MissingReturnRule.php @@ -0,0 +1,152 @@ + + */ +final class MissingReturnRule implements Rule +{ + + public function __construct( + private bool $checkExplicitMixedMissingReturn, + private bool $checkPhpDocMissingReturn, + ) + { + } + + public function getNodeType(): string + { + return ExecutionEndNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + if ($statementResult->isAlwaysTerminating()) { + return []; + } + + $anonymousFunctionReturnType = $scope->getAnonymousFunctionReturnType(); + $scopeFunction = $scope->getFunction(); + if ($anonymousFunctionReturnType !== null) { + $returnType = $anonymousFunctionReturnType; + $description = 'Anonymous function'; + if (!$node->hasNativeReturnTypehint()) { + return []; + } + } elseif ($scopeFunction !== null) { + $returnType = $scopeFunction->getReturnType(); + if ($scopeFunction instanceof PhpMethodFromParserNodeReflection) { + if (!$scopeFunction->isPropertyHook()) { + $description = sprintf('Method %s::%s()', $scopeFunction->getDeclaringClass()->getDisplayName(), $scopeFunction->getName()); + } else { + $description = sprintf('%s hook for property %s::$%s', ucfirst($scopeFunction->getPropertyHookName()), $scopeFunction->getDeclaringClass()->getDisplayName(), $scopeFunction->getHookedPropertyName()); + } + } else { + $description = sprintf('Function %s()', $scopeFunction->getName()); + } + } else { + throw new ShouldNotHappenException(); + } + + $returnType = TypeUtils::resolveLateResolvableTypes($returnType); + + $isVoidSuperType = $returnType->isSuperTypeOf(new VoidType()); + if ($isVoidSuperType->yes() && !$returnType instanceof MixedType) { + return []; + } + + if ($statementResult->hasYield()) { + if ($this->checkPhpDocMissingReturn) { + $generatorReturnType = $returnType->getTemplateType(Generator::class, 'TReturn'); + if (!$generatorReturnType instanceof ErrorType) { + $returnType = $generatorReturnType; + if ($returnType->isVoid()->yes()) { + return []; + } + if (!$returnType instanceof MixedType) { + return [ + RuleErrorBuilder::message( + sprintf('%s should return %s but return statement is missing.', $description, $returnType->describe(VerbosityLevel::typeOnly())), + ) + ->line($node->getNode()->getStartLine()) + ->identifier('return.missing') + ->build(), + ]; + } + } + } + return []; + } + + if ( + !$node->hasNativeReturnTypehint() + && !$this->checkPhpDocMissingReturn + && TypeCombinator::containsNull($returnType) + ) { + return []; + } + + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + $errorBuilder = RuleErrorBuilder::message(sprintf('%s should always throw an exception or terminate script execution but doesn\'t do that.', $description))->line($node->getNode()->getStartLine()); + + if ($node->hasNativeReturnTypehint()) { + $errorBuilder->nonIgnorable(); + } + + $errorBuilder->identifier('return.never'); + + return [ + $errorBuilder->build(), + ]; + } + + if ( + $returnType instanceof MixedType + && !$returnType instanceof TemplateMixedType + && !$node->hasNativeReturnTypehint() + && ( + !$returnType->isExplicitMixed() + || !$this->checkExplicitMixedMissingReturn + ) + ) { + return []; + } + + $errorBuilder = RuleErrorBuilder::message( + sprintf('%s should return %s but return statement is missing.', $description, $returnType->describe(VerbosityLevel::typeOnly())), + )->line($node->getNode()->getStartLine()); + + if ($node->hasNativeReturnTypehint()) { + $errorBuilder->nonIgnorable(); + } + + $errorBuilder->identifier('return.missing'); + + return [ + $errorBuilder->build(), + ]; + } + +} diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php new file mode 100644 index 00000000..2d37d207 --- /dev/null +++ b/src/Rules/MissingTypehintCheck.php @@ -0,0 +1,185 @@ +getIterableTypesWithMissingValueTypehint($type->getIf()), + $this->getIterableTypesWithMissingValueTypehint($type->getElse()), + ); + + return $type; + } + if ($type->isIterable()->yes()) { + $iterableValue = $type->getIterableValueType(); + if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { + $iterablesWithMissingValueTypehint[] = $type; + } + if ($type instanceof IntersectionType) { + if ($type->isList()->yes()) { + return $traverse($type->getIterableValueType()); + } + + return $type; + } + } + return $traverse($type); + }); + + return $iterablesWithMissingValueTypehint; + } + + /** + * @return array + */ + public function getNonGenericObjectTypesWithGenericClass(Type $type): array + { + $objectTypes = []; + TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$objectTypes): Type { + if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { + $traverse($type); + return $type; + } + if ($type instanceof TemplateType) { + return $type; + } + if ($type instanceof ObjectType) { + $classReflection = $type->getClassReflection(); + if ($classReflection === null) { + return $type; + } + if (in_array($classReflection->getName(), self::ITERABLE_GENERIC_CLASS_NAMES, true)) { + // checked by getIterableTypesWithMissingValueTypehint() already + return $type; + } + if (in_array($classReflection->getName(), $this->skipCheckGenericClasses, true)) { + return $type; + } + if ($classReflection->isTrait()) { + return $type; + } + if (!$classReflection->isGeneric()) { + return $type; + } + + $resolvedType = TemplateTypeHelper::resolveToBounds($type); + if (!$resolvedType instanceof ObjectType) { + throw new ShouldNotHappenException(); + } + + $templateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + $templateTypesCount = count($templateTypes); + $requiredTemplateTypesCount = count(array_filter($templateTypes, static fn (Type $type) => $type instanceof TemplateType && $type->getDefault() === null)); + if ($requiredTemplateTypesCount === 0) { + return $type; + } + + $templateTypesList = implode(', ', array_keys($templateTypes)); + if ($requiredTemplateTypesCount !== $templateTypesCount) { + $templateTypesList .= sprintf(' (%d-%d required)', $requiredTemplateTypesCount, $templateTypesCount); + } + + $objectTypes[] = [ + sprintf('%s %s', strtolower($classReflection->getClassTypeDescription()), $classReflection->getDisplayName(false)), + $templateTypesList, + ]; + return $type; + } + + return $traverse($type); + }); + + return $objectTypes; + } + + /** + * @return Type[] + */ + public function getCallablesWithMissingSignature(Type $type): array + { + if (!$this->checkMissingCallableSignature) { + return []; + } + + $result = []; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$result): Type { + if ( + ($type instanceof CallableType && $type->isCommonCallable()) + || ($type instanceof ClosureType && $type->isCommonCallable()) + || ($type instanceof ObjectType && $type->getClassName() === Closure::class) + ) { + $result[] = $type; + } + return $traverse($type); + }); + + return $result; + } + +} diff --git a/src/Rules/Names/UsedNamesRule.php b/src/Rules/Names/UsedNamesRule.php new file mode 100644 index 00000000..15e511d9 --- /dev/null +++ b/src/Rules/Names/UsedNamesRule.php @@ -0,0 +1,150 @@ + + */ +final class UsedNamesRule implements Rule +{ + + public function getNodeType(): string + { + return FileNode::class; + } + + /** + * @param FileNode $node + */ + public function processNode(Node $node, Scope $scope): array + { + $usedNames = []; + $errors = []; + foreach ($node->getNodes() as $oneNode) { + if ($oneNode instanceof Namespace_) { + $namespaceName = $oneNode->name !== null ? $oneNode->name->toString() : ''; + foreach ($oneNode->stmts as $stmt) { + foreach ($this->findErrorsForNode($stmt, $namespaceName, $usedNames) as $error) { + $errors[] = $error; + } + } + continue; + } + + foreach ($this->findErrorsForNode($oneNode, '', $usedNames) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @param array $usedNames + * @return list + */ + private function findErrorsForNode(Node $node, string $namespace, array &$usedNames): array + { + $lowerNamespace = strtolower($namespace); + if ($node instanceof Use_) { + if ($this->shouldBeIgnored($node)) { + return []; + } + return $this->findErrorsInUses($node->uses, '', $lowerNamespace, $usedNames); + } + + if ($node instanceof GroupUse) { + if ($this->shouldBeIgnored($node)) { + return []; + } + $useGroupPrefix = $node->prefix->toString(); + return $this->findErrorsInUses($node->uses, $useGroupPrefix, $lowerNamespace, $usedNames); + } + + if ($node instanceof ClassLike) { + if ($node->name === null) { + return []; + } + $type = 'class'; + if ($node instanceof Interface_) { + $type = 'interface'; + } elseif ($node instanceof Trait_) { + $type = 'trait'; + } elseif ($node instanceof Enum_) { + $type = 'enum'; + } + $name = $node->name->toLowerString(); + if (in_array($name, $usedNames[$lowerNamespace] ?? [], true)) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot declare %s %s because the name is already in use.', + $type, + $namespace !== '' ? $namespace . '\\' . $node->name->toString() : $node->name->toString(), + )) + ->identifier(sprintf('%s.nameInUse', $type)) + ->line($node->getStartLine()) + ->nonIgnorable() + ->build(), + ]; + } + $usedNames[$lowerNamespace][] = $name; + return []; + } + + return []; + } + + /** + * @param Node\UseItem[] $uses + * @param array $usedNames + * @return list + */ + private function findErrorsInUses(array $uses, string $useGroupPrefix, string $lowerNamespace, array &$usedNames): array + { + $errors = []; + foreach ($uses as $use) { + if ($this->shouldBeIgnored($use)) { + continue; + } + $useAlias = $use->getAlias()->toLowerString(); + if (in_array($useAlias, $usedNames[$lowerNamespace] ?? [], true)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot use %s as %s because the name is already in use.', + $useGroupPrefix !== '' ? $useGroupPrefix . '\\' . $use->name->toString() : $use->name->toString(), + $use->getAlias()->toString(), + )) + ->identifier('use.nameInUse') + ->line($use->getStartLine()) + ->nonIgnorable() + ->build(); + continue; + } + $usedNames[$lowerNamespace][] = $useAlias; + } + return $errors; + } + + private function shouldBeIgnored(Use_|GroupUse|Node\UseItem $use): bool + { + return in_array($use->type, [Use_::TYPE_FUNCTION, Use_::TYPE_CONSTANT], true); + } + +} diff --git a/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php b/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php new file mode 100644 index 00000000..cfd465ee --- /dev/null +++ b/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php @@ -0,0 +1,132 @@ + + */ +final class ExistingNamesInGroupUseRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private bool $checkFunctionNameCase, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\GroupUse::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->uses as $use) { + $error = null; + + /** @var Node\Name $name */ + $name = Node\Name::concat($node->prefix, $use->name, ['startLine' => $use->getStartLine()]); + if ( + $node->type === Use_::TYPE_CONSTANT + || $use->type === Use_::TYPE_CONSTANT + ) { + $error = $this->checkConstant($name); + } elseif ( + $node->type === Use_::TYPE_FUNCTION + || $use->type === Use_::TYPE_FUNCTION + ) { + $error = $this->checkFunction($name); + } elseif ($use->type === Use_::TYPE_NORMAL) { + $error = $this->checkClass($name); + } else { + throw new ShouldNotHappenException(); + } + + if ($error === null) { + continue; + } + + $errors[] = $error; + } + + return $errors; + } + + private function checkConstant(Node\Name $name): ?IdentifierRuleError + { + if (!$this->reflectionProvider->hasConstant($name, null)) { + return RuleErrorBuilder::message(sprintf('Used constant %s not found.', (string) $name)) + ->discoveringSymbolsTip() + ->line($name->getStartLine()) + ->identifier('constant.notFound') + ->build(); + } + + return null; + } + + private function checkFunction(Node\Name $name): ?IdentifierRuleError + { + if (!$this->reflectionProvider->hasFunction($name, null)) { + return RuleErrorBuilder::message(sprintf('Used function %s not found.', (string) $name)) + ->discoveringSymbolsTip() + ->line($name->getStartLine()) + ->identifier('function.notFound') + ->build(); + } + + if ($this->checkFunctionNameCase) { + $functionReflection = $this->reflectionProvider->getFunction($name, null); + $realName = $functionReflection->getName(); + $usedName = (string) $name; + if ( + strtolower($realName) === strtolower($usedName) + && $realName !== $usedName + ) { + return RuleErrorBuilder::message(sprintf( + 'Function %s used with incorrect case: %s.', + $realName, + $usedName, + )) + ->line($name->getStartLine()) + ->identifier('function.nameCase') + ->build(); + } + } + + return null; + } + + private function checkClass(Node\Name $name): ?IdentifierRuleError + { + $errors = $this->classCheck->checkClassNames([ + new ClassNameNodePair((string) $name, $name), + ]); + if (count($errors) === 0) { + return null; + } elseif (count($errors) === 1) { + return $errors[0]; + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Namespaces/ExistingNamesInUseRule.php b/src/Rules/Namespaces/ExistingNamesInUseRule.php new file mode 100644 index 00000000..afa5181d --- /dev/null +++ b/src/Rules/Namespaces/ExistingNamesInUseRule.php @@ -0,0 +1,131 @@ + + */ +final class ExistingNamesInUseRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private bool $checkFunctionNameCase, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Use_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->type === Node\Stmt\Use_::TYPE_UNKNOWN) { + throw new ShouldNotHappenException(); + } + + foreach ($node->uses as $use) { + if ($use->type !== Node\Stmt\Use_::TYPE_UNKNOWN) { + throw new ShouldNotHappenException(); + } + } + + if ($node->type === Node\Stmt\Use_::TYPE_CONSTANT) { + return $this->checkConstants($node->uses); + } + + if ($node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + return $this->checkFunctions($node->uses); + } + + return $this->checkClasses($node->uses); + } + + /** + * @param Node\UseItem[] $uses + * @return list + */ + private function checkConstants(array $uses): array + { + $errors = []; + foreach ($uses as $use) { + if ($this->reflectionProvider->hasConstant($use->name, null)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Used constant %s not found.', (string) $use->name)) + ->line($use->name->getStartLine()) + ->identifier('constant.notFound') + ->discoveringSymbolsTip() + ->build(); + } + + return $errors; + } + + /** + * @param Node\UseItem[] $uses + * @return list + */ + private function checkFunctions(array $uses): array + { + $errors = []; + foreach ($uses as $use) { + if (!$this->reflectionProvider->hasFunction($use->name, null)) { + $errors[] = RuleErrorBuilder::message(sprintf('Used function %s not found.', (string) $use->name)) + ->line($use->name->getStartLine()) + ->identifier('function.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->checkFunctionNameCase) { + $functionReflection = $this->reflectionProvider->getFunction($use->name, null); + $realName = $functionReflection->getName(); + $usedName = (string) $use->name; + if ( + strtolower($realName) === strtolower($usedName) + && $realName !== $usedName + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Function %s used with incorrect case: %s.', + $realName, + $usedName, + )) + ->line($use->name->getStartLine()) + ->identifier('function.nameCase') + ->build(); + } + } + } + + return $errors; + } + + /** + * @param Node\UseItem[] $uses + * @return list + */ + private function checkClasses(array $uses): array + { + return $this->classCheck->checkClassNames( + array_map(static fn (Node\UseItem $use): ClassNameNodePair => new ClassNameNodePair((string) $use->name, $use->name), $uses), + ); + } + +} diff --git a/src/Rules/NonIgnorableRuleError.php b/src/Rules/NonIgnorableRuleError.php new file mode 100644 index 00000000..a42474a5 --- /dev/null +++ b/src/Rules/NonIgnorableRuleError.php @@ -0,0 +1,10 @@ +containsNullSafe($expr->var); + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->containsNullSafe($expr->var); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->containsNullSafe($expr->class); + } + + if ($expr instanceof Expr\MethodCall) { + return $this->containsNullSafe($expr->var); + } + + if ($expr instanceof Expr\StaticCall && $expr->class instanceof Expr) { + return $this->containsNullSafe($expr->class); + } + + if ($expr instanceof Expr\List_) { + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + if ($item->key !== null && $this->containsNullSafe($item->key)) { + return true; + } + + if ($this->containsNullSafe($item->value)) { + return true; + } + } + } + + return false; + } + +} diff --git a/src/Rules/Operators/InvalidAssignVarRule.php b/src/Rules/Operators/InvalidAssignVarRule.php new file mode 100644 index 00000000..7fe33436 --- /dev/null +++ b/src/Rules/Operators/InvalidAssignVarRule.php @@ -0,0 +1,107 @@ + + */ +final class InvalidAssignVarRule implements Rule +{ + + public function __construct(private NullsafeCheck $nullsafeCheck) + { + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Assign + && !$node instanceof AssignOp + && !$node instanceof AssignRef + ) { + return []; + } + + if ($this->nullsafeCheck->containsNullSafe($node->var)) { + return [ + RuleErrorBuilder::message('Nullsafe operator cannot be on left side of assignment.') + ->identifier('nullsafe.assign') + ->nonIgnorable() + ->build(), + ]; + } + + if ($node instanceof AssignRef && $this->nullsafeCheck->containsNullSafe($node->expr)) { + return [ + RuleErrorBuilder::message('Nullsafe operator cannot be on right side of assignment by reference.') + ->identifier('nullsafe.byRef') + ->nonIgnorable() + ->build(), + ]; + } + + if ($this->containsNonAssignableExpression($node->var)) { + return [ + RuleErrorBuilder::message('Expression on left side of assignment is not assignable.') + ->identifier('assign.invalidExpr') + ->nonIgnorable() + ->build(), + ]; + } + + return []; + } + + private function containsNonAssignableExpression(Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return false; + } + + if ($expr instanceof Expr\PropertyFetch) { + return false; + } + + if ($expr instanceof Expr\ArrayDimFetch) { + return false; + } + + if ($expr instanceof Expr\StaticPropertyFetch) { + return false; + } + + if ($expr instanceof Expr\List_) { + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + if (!$this->containsNonAssignableExpression($item->value)) { + continue; + } + + return true; + } + + return false; + } + + return true; + } + +} diff --git a/src/Rules/Operators/InvalidBinaryOperationRule.php b/src/Rules/Operators/InvalidBinaryOperationRule.php new file mode 100644 index 00000000..c5846796 --- /dev/null +++ b/src/Rules/Operators/InvalidBinaryOperationRule.php @@ -0,0 +1,124 @@ + + */ +final class InvalidBinaryOperationRule implements Rule +{ + + public function __construct( + private ExprPrinter $exprPrinter, + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Node\Expr\BinaryOp + && !$node instanceof Node\Expr\AssignOp + ) { + return []; + } + + $leftName = '__PHPSTAN__LEFT__'; + $rightName = '__PHPSTAN__RIGHT__'; + $leftVariable = new Node\Expr\Variable($leftName); + $rightVariable = new Node\Expr\Variable($rightName); + if ($node instanceof Node\Expr\AssignOp) { + $identifier = 'assignOp'; + $newNode = clone $node; + $newNode->setAttribute('phpstan_cache_printer', null); + $left = $node->var; + $right = $node->expr; + $newNode->var = $leftVariable; + $newNode->expr = $rightVariable; + } else { + $identifier = 'binaryOp'; + $newNode = clone $node; + $newNode->setAttribute('phpstan_cache_printer', null); + $left = $node->left; + $right = $node->right; + $newNode->left = $leftVariable; + $newNode->right = $rightVariable; + } + + if ($node instanceof Node\Expr\AssignOp\Concat || $node instanceof Node\Expr\BinaryOp\Concat) { + $callback = static fn (Type $type): bool => !$type->toString() instanceof ErrorType; + } elseif ($node instanceof Node\Expr\AssignOp\Plus || $node instanceof Node\Expr\BinaryOp\Plus) { + $callback = static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType || $type->isArray()->yes(); + } else { + $callback = static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType; + } + + $leftType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $left, + '', + $callback, + )->getType(); + if ($leftType instanceof ErrorType) { + return []; + } + + $rightType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $right, + '', + $callback, + )->getType(); + if ($rightType instanceof ErrorType) { + return []; + } + + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $scope = $scope + ->assignVariable($leftName, $leftType, $leftType, TrinaryLogic::createYes()) + ->assignVariable($rightName, $rightType, $rightType, TrinaryLogic::createYes()); + + if (!$scope->getType($newNode) instanceof ErrorType) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Binary operation "%s" between %s and %s results in an error.', + substr(substr($this->exprPrinter->printExpr($newNode), strlen($leftName) + 2), 0, -(strlen($rightName) + 2)), + $scope->getType($left)->describe(VerbosityLevel::value()), + $scope->getType($right)->describe(VerbosityLevel::value()), + )) + ->line($left->getStartLine()) + ->identifier(sprintf('%s.invalid', $identifier)) + ->build(), + ]; + } + +} diff --git a/src/Rules/Operators/InvalidComparisonOperationRule.php b/src/Rules/Operators/InvalidComparisonOperationRule.php new file mode 100644 index 00000000..4100518f --- /dev/null +++ b/src/Rules/Operators/InvalidComparisonOperationRule.php @@ -0,0 +1,168 @@ + + */ +final class InvalidComparisonOperationRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Node\Expr\BinaryOp::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Node\Expr\BinaryOp\Equal + && !$node instanceof Node\Expr\BinaryOp\NotEqual + && !$node instanceof Node\Expr\BinaryOp\Smaller + && !$node instanceof Node\Expr\BinaryOp\SmallerOrEqual + && !$node instanceof Node\Expr\BinaryOp\Greater + && !$node instanceof Node\Expr\BinaryOp\GreaterOrEqual + && !$node instanceof Node\Expr\BinaryOp\Spaceship + ) { + return []; + } + + if ($this->isNumberType($scope, $node->left) && $this->isNumberType($scope, $node->right)) { + return []; + } + + if ( + ($this->isNumberType($scope, $node->left) && ( + $this->isPossiblyNullableObjectType($scope, $node->right) || $this->isPossiblyNullableArrayType($scope, $node->right) + )) + || ($this->isNumberType($scope, $node->right) && ( + $this->isPossiblyNullableObjectType($scope, $node->left) || $this->isPossiblyNullableArrayType($scope, $node->left) + )) + ) { + switch (get_class($node)) { + case Node\Expr\BinaryOp\Equal::class: + $nodeType = 'equal'; + break; + case Node\Expr\BinaryOp\NotEqual::class: + $nodeType = 'notEqual'; + break; + case Node\Expr\BinaryOp\Greater::class: + $nodeType = 'greater'; + break; + case Node\Expr\BinaryOp\GreaterOrEqual::class: + $nodeType = 'greaterOrEqual'; + break; + case Node\Expr\BinaryOp\Smaller::class: + $nodeType = 'smaller'; + break; + case Node\Expr\BinaryOp\SmallerOrEqual::class: + $nodeType = 'smallerOrEqual'; + break; + case Node\Expr\BinaryOp\Spaceship::class: + $nodeType = 'spaceship'; + break; + default: + throw new ShouldNotHappenException(); + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Comparison operation "%s" between %s and %s results in an error.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + )) + ->line($node->left->getStartLine()) + ->identifier(sprintf('%s.invalid', $nodeType)) + ->build(), + ]; + } + + return []; + } + + private function isNumberType(Scope $scope, Node\Expr $expr): bool + { + $acceptedType = new UnionType([new IntegerType(), new FloatType()]); + $onlyNumber = static fn (Type $type): bool => $acceptedType->isSuperTypeOf($type)->yes(); + + $type = $this->ruleLevelHelper->findTypeToCheck($scope, $expr, '', $onlyNumber)->getType(); + + if ( + $type instanceof ErrorType + || !$type->equals($scope->getType($expr)) + ) { + return false; + } + + // SimpleXMLElement can be cast to number union type + return !$acceptedType->isSuperTypeOf($type)->no() || $acceptedType->equals($type->toNumber()); + } + + private function isPossiblyNullableObjectType(Scope $scope, Node\Expr $expr): bool + { + $acceptedType = new ObjectWithoutClassType(); + + $type = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $expr, + '', + static fn (Type $type): bool => $acceptedType->isSuperTypeOf($type)->yes(), + )->getType(); + + if ($type instanceof ErrorType) { + return false; + } + + if (TypeCombinator::containsNull($type) && !$type->isNull()->yes()) { + $type = TypeCombinator::removeNull($type); + } + + $isSuperType = $acceptedType->isSuperTypeOf($type); + if ($type instanceof BenevolentUnionType) { + return !$isSuperType->no(); + } + + return $isSuperType->yes(); + } + + private function isPossiblyNullableArrayType(Scope $scope, Node\Expr $expr): bool + { + $type = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $expr, + '', + static fn (Type $type): bool => $type->isArray()->yes(), + )->getType(); + + if (TypeCombinator::containsNull($type) && !$type->isNull()->yes()) { + $type = TypeCombinator::removeNull($type); + } + + return !($type instanceof ErrorType) && $type->isArray()->yes(); + } + +} diff --git a/src/Rules/Operators/InvalidIncDecOperationRule.php b/src/Rules/Operators/InvalidIncDecOperationRule.php new file mode 100644 index 00000000..d7a9aed3 --- /dev/null +++ b/src/Rules/Operators/InvalidIncDecOperationRule.php @@ -0,0 +1,113 @@ + + */ +final class InvalidIncDecOperationRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Node\Expr\PreInc + && !$node instanceof Node\Expr\PostInc + && !$node instanceof Node\Expr\PreDec + && !$node instanceof Node\Expr\PostDec + ) { + return []; + } + + switch (get_class($node)) { + case Node\Expr\PreInc::class: + $nodeType = 'preInc'; + break; + case Node\Expr\PostInc::class: + $nodeType = 'postInc'; + break; + case Node\Expr\PreDec::class: + $nodeType = 'preDec'; + break; + case Node\Expr\PostDec::class: + $nodeType = 'postDec'; + break; + default: + throw new ShouldNotHappenException(); + } + + $operatorString = $node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc ? '++' : '--'; + + if ( + !$node->var instanceof Node\Expr\Variable + && !$node->var instanceof Node\Expr\ArrayDimFetch + && !$node->var instanceof Node\Expr\PropertyFetch + && !$node->var instanceof Node\Expr\StaticPropertyFetch + ) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot use %s on a non-variable.', + $operatorString, + )) + ->line($node->var->getStartLine()) + ->identifier(sprintf('%s.expr', $nodeType)) + ->build(), + ]; + } + + $allowedTypes = new UnionType([new BooleanType(), new FloatType(), new IntegerType(), new StringType(), new NullType(), new ObjectType('SimpleXMLElement')]); + $varType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->var, + '', + static fn (Type $type): bool => $allowedTypes->isSuperTypeOf($type)->yes(), + )->getType(); + + if ($varType instanceof ErrorType || $allowedTypes->isSuperTypeOf($varType)->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot use %s on %s.', + $operatorString, + $varType->describe(VerbosityLevel::value()), + )) + ->line($node->var->getStartLine()) + ->identifier(sprintf('%s.type', $nodeType)) + ->build(), + ]; + } + +} diff --git a/src/Rules/Operators/InvalidUnaryOperationRule.php b/src/Rules/Operators/InvalidUnaryOperationRule.php new file mode 100644 index 00000000..d62f7fbe --- /dev/null +++ b/src/Rules/Operators/InvalidUnaryOperationRule.php @@ -0,0 +1,96 @@ + + */ +final class InvalidUnaryOperationRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node instanceof Node\Expr\UnaryPlus + && !$node instanceof Node\Expr\UnaryMinus + && !$node instanceof Node\Expr\BitwiseNot + ) { + return []; + } + + $varName = '__PHPSTAN__LEFT__'; + $variable = new Node\Expr\Variable($varName); + $newNode = clone $node; + $newNode->setAttribute('phpstan_cache_printer', null); + $newNode->expr = $variable; + + if ($node instanceof Node\Expr\BitwiseNot) { + $callback = static fn (Type $type): bool => $type->isString()->yes() || $type->isInteger()->yes() || $type->isFloat()->yes(); + } else { + $callback = static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType; + } + + $exprType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + '', + $callback, + )->getType(); + if ($exprType instanceof ErrorType) { + return []; + } + + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $scope = $scope->assignVariable($varName, $exprType, $exprType, TrinaryLogic::createYes()); + if (!$scope->getType($newNode) instanceof ErrorType) { + return []; + } + + if ($node instanceof Node\Expr\UnaryPlus) { + $operator = '+'; + } elseif ($node instanceof Node\Expr\UnaryMinus) { + $operator = '-'; + } else { + $operator = '~'; + } + return [ + RuleErrorBuilder::message(sprintf( + 'Unary operation "%s" on %s results in an error.', + $operator, + $scope->getType($node->expr)->describe(VerbosityLevel::value()), + )) + ->line($node->expr->getStartLine()) + ->identifier('unaryOp.invalid') + ->build(), + ]; + } + +} diff --git a/src/Rules/ParameterCastableToStringCheck.php b/src/Rules/ParameterCastableToStringCheck.php new file mode 100644 index 00000000..9e433e6c --- /dev/null +++ b/src/Rules/ParameterCastableToStringCheck.php @@ -0,0 +1,73 @@ +unpack) { + return null; + } + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $parameter->value, + '', + static fn (Type $type): bool => $type->isArray()->yes() && !$castFn($type->getIterableValueType()) instanceof ErrorType, + ); + + if ( + ! $typeResult->getType()->isArray()->yes() + || !$castFn($typeResult->getType()->getIterableValueType()) instanceof ErrorType + ) { + return null; + } + + return RuleErrorBuilder::message( + sprintf($errorMessageTemplate, $parameterName, $functionName, $typeResult->getType()->describe(VerbosityLevel::typeOnly())), + )->identifier('argument.type')->build(); + } + + public function getParameterName(Arg $parameter, int $parameterIdx, ?ParameterReflection $parameterReflection): string + { + if ($parameterReflection === null) { + return sprintf('#%d', $parameterIdx + 1); + } + + $paramName = $parameterReflection->getName(); + $origParameter = $parameter->getAttributes()[ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE] ?? null; + + if (!$origParameter instanceof Arg) { + $origParameter = $parameter; + } + + return $origParameter->name !== null + ? sprintf('$%s', $paramName) + : sprintf('#%d $%s', $parameterIdx + 1, $paramName); + } + +} diff --git a/src/Rules/PhpDoc/AssertRuleHelper.php b/src/Rules/PhpDoc/AssertRuleHelper.php new file mode 100644 index 00000000..0be53709 --- /dev/null +++ b/src/Rules/PhpDoc/AssertRuleHelper.php @@ -0,0 +1,210 @@ + + */ + public function check( + Function_|ClassMethod $node, + ExtendedMethodReflection|FunctionReflection $reflection, + ParametersAcceptor $acceptor, + ): array + { + $parametersByName = []; + foreach ($acceptor->getParameters() as $parameter) { + $parametersByName[$parameter->getName()] = $parameter->getType(); + } + + if ($reflection instanceof ExtendedMethodReflection && !$reflection->isStatic()) { + $class = $reflection->getDeclaringClass(); + $parametersByName['this'] = new ObjectType($class->getName(), null, $class); + } + + $context = InitializerExprContext::createEmpty(); + + $errors = []; + foreach ($reflection->getAsserts()->getAll() as $assert) { + $parameterName = substr($assert->getParameter()->getParameterName(), 1); + if (!array_key_exists($parameterName, $parametersByName)) { + $errors[] = RuleErrorBuilder::message(sprintf('Assert references unknown parameter $%s.', $parameterName)) + ->identifier('parameter.notFound') + ->build(); + continue; + } + + if (!$assert->isExplicit()) { + continue; + } + + $assertedExpr = $assert->getParameter()->getExpr(new TypeExpr($parametersByName[$parameterName])); + $assertedExprType = $this->initializerExprTypeResolver->getType($assertedExpr, $context); + $assertedExprString = $assert->getParameter()->describe(); + if ($assertedExprType instanceof ErrorType) { + $errors[] = RuleErrorBuilder::message(sprintf('Assert references unknown %s.', $assertedExprString)) + ->identifier('assert.unknownExpr') + ->build(); + continue; + } + + $assertedType = $assert->getType(); + + $tagName = [ + AssertTag::NULL => '@phpstan-assert', + AssertTag::IF_TRUE => '@phpstan-assert-if-true', + AssertTag::IF_FALSE => '@phpstan-assert-if-false', + ][$assert->getIf()]; + + if ($this->unresolvableTypeHelper->containsUnresolvableType($assertedType)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains unresolvable type.', + $tagName, + $assertedExprString, + ))->identifier('assert.unresolvableType')->build(); + continue; + } + + $isSuperType = $assertedType->isSuperTypeOf($assertedExprType); + if (!$isSuperType->maybe()) { + if ($assert->isNegated() ? $isSuperType->yes() : $isSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Asserted %stype %s for %s with type %s can never happen.', + $assert->isNegated() ? 'negated ' : '', + $assertedType->describe(VerbosityLevel::precise()), + $assertedExprString, + $assertedExprType->describe(VerbosityLevel::precise()), + ))->identifier('assert.impossibleType')->build(); + } elseif ($assert->isNegated() ? $isSuperType->no() : $isSuperType->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Asserted %stype %s for %s with type %s does not narrow down the type.', + $assert->isNegated() ? 'negated ' : '', + $assertedType->describe(VerbosityLevel::precise()), + $assertedExprString, + $assertedExprType->describe(VerbosityLevel::precise()), + ))->identifier('assert.alreadyNarrowedType')->build(); + } + } + + foreach ($assertedType->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains unknown class %s.', + $tagName, + $assertedExprString, + $class, + ))->identifier('class.notFound')->build(); + continue; + } + + $classReflection = $this->reflectionProvider->getClass($class); + if ($classReflection->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains invalid type %s.', + $tagName, + $assertedExprString, + $class, + ))->identifier('assert.trait')->build(); + continue; + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $assertedType, + sprintf('PHPDoc tag %s for %s contains generic type %%s but %%s %%s is not generic.', $tagName, $assertedExprString), + sprintf('Generic type %%s in PHPDoc tag %s for %s does not specify all template types of %%s %%s: %%s', $tagName, $assertedExprString), + sprintf('Generic type %%s in PHPDoc tag %s for %s specifies %%d template types, but %%s %%s supports only %%d: %%s', $tagName, $assertedExprString), + sprintf('Type %%s in generic type %%s in PHPDoc tag %s for %s is not subtype of template type %%s of %%s %%s.', $tagName, $assertedExprString), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for %s is in conflict with %%s template type %%s of %%s %%s.', $tagName, $assertedExprString), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for %s is redundant, template type %%s of %%s %%s has the same variance.', $tagName, $assertedExprString), + )); + + if (!$this->checkMissingTypehints) { + continue; + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($assertedType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s has no value type specified in iterable type %s.', + $tagName, + $assertedExprString, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($assertedType) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains generic %s but does not specify its types: %s', + $tagName, + $assertedExprString, + $innerName, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($assertedType) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s has no signature specified for %s.', + $tagName, + $assertedExprString, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + } + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/ConditionalReturnTypeRuleHelper.php b/src/Rules/PhpDoc/ConditionalReturnTypeRuleHelper.php new file mode 100644 index 00000000..533a1e91 --- /dev/null +++ b/src/Rules/PhpDoc/ConditionalReturnTypeRuleHelper.php @@ -0,0 +1,129 @@ + + */ + public function check(ExtendedParametersAcceptor $acceptor): array + { + $conditionalTypes = []; + $parametersByName = []; + foreach ($acceptor->getParameters() as $parameter) { + TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use (&$conditionalTypes): Type { + if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { + $conditionalTypes[] = $type; + } + + return $traverse($type); + }); + + if ($parameter->getOutType() !== null) { + TypeTraverser::map($parameter->getOutType(), static function (Type $type, callable $traverse) use (&$conditionalTypes): Type { + if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { + $conditionalTypes[] = $type; + } + + return $traverse($type); + }); + } + + if ($parameter->getClosureThisType() !== null) { + TypeTraverser::map($parameter->getClosureThisType(), static function (Type $type, callable $traverse) use (&$conditionalTypes): Type { + if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { + $conditionalTypes[] = $type; + } + + return $traverse($type); + }); + } + + $parametersByName[$parameter->getName()] = $parameter; + } + + TypeTraverser::map($acceptor->getReturnType(), static function (Type $type, callable $traverse) use (&$conditionalTypes): Type { + if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { + $conditionalTypes[] = $type; + } + + return $traverse($type); + }); + + $errors = []; + foreach ($conditionalTypes as $conditionalType) { + if ($conditionalType instanceof ConditionalType) { + $subjectType = $conditionalType->getSubject(); + if ($subjectType instanceof StaticType) { + continue; + } + $templateTypes = []; + TypeTraverser::map($subjectType, static function (Type $type, callable $traverse) use (&$templateTypes): Type { + if ($type instanceof TemplateType) { + $templateTypes[] = $type; + return $type; + } + + return $traverse($type); + }); + + if (count($templateTypes) === 0) { + $errors[] = RuleErrorBuilder::message(sprintf('Conditional return type uses subject type %s which is not part of PHPDoc @template tags.', $subjectType->describe(VerbosityLevel::typeOnly()))) + ->identifier('conditionalType.subjectNotFound') + ->build(); + continue; + } + } else { + $parameterName = substr($conditionalType->getParameterName(), 1); + if (!array_key_exists($parameterName, $parametersByName)) { + $errors[] = RuleErrorBuilder::message(sprintf('Conditional return type references unknown parameter $%s.', $parameterName)) + ->identifier('parameter.notFound') + ->build(); + continue; + } + $subjectType = $parametersByName[$parameterName]->getType(); + } + + $targetType = $conditionalType->getTarget(); + $isTargetSuperType = $targetType->isSuperTypeOf($subjectType); + if ($isTargetSuperType->maybe()) { + continue; + } + + $verbosity = VerbosityLevel::getRecommendedLevelByType($subjectType, $targetType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Condition "%s" in conditional return type is always %s.', + sprintf('%s %s %s', $subjectType->describe($verbosity), $conditionalType->isNegated() ? 'is not' : 'is', $targetType->describe($verbosity)), + $conditionalType->isNegated() + ? ($isTargetSuperType->yes() ? 'false' : 'true') + : ($isTargetSuperType->yes() ? 'true' : 'false'), + )) + ->identifier(sprintf('conditionalType.always%s', $conditionalType->isNegated() + ? ($isTargetSuperType->yes() ? 'False' : 'True') + : ($isTargetSuperType->yes() ? 'True' : 'False'))) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/FunctionAssertRule.php b/src/Rules/PhpDoc/FunctionAssertRule.php new file mode 100644 index 00000000..e2029670 --- /dev/null +++ b/src/Rules/PhpDoc/FunctionAssertRule.php @@ -0,0 +1,38 @@ + + */ +final class FunctionAssertRule implements Rule +{ + + public function __construct(private AssertRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $function = $node->getFunctionReflection(); + $variants = $function->getVariants(); + if (count($variants) !== 1) { + return []; + } + + return $this->helper->check($node->getOriginalNode(), $function, $variants[0]); + } + +} diff --git a/src/Rules/PhpDoc/FunctionConditionalReturnTypeRule.php b/src/Rules/PhpDoc/FunctionConditionalReturnTypeRule.php new file mode 100644 index 00000000..1484a454 --- /dev/null +++ b/src/Rules/PhpDoc/FunctionConditionalReturnTypeRule.php @@ -0,0 +1,38 @@ + + */ +final class FunctionConditionalReturnTypeRule implements Rule +{ + + public function __construct(private ConditionalReturnTypeRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return InFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $function = $node->getFunctionReflection(); + $variants = $function->getVariants(); + if (count($variants) !== 1) { + return []; + } + + return $this->helper->check($variants[0]); + } + +} diff --git a/src/Rules/PhpDoc/GenericCallableRuleHelper.php b/src/Rules/PhpDoc/GenericCallableRuleHelper.php new file mode 100644 index 00000000..5f76dece --- /dev/null +++ b/src/Rules/PhpDoc/GenericCallableRuleHelper.php @@ -0,0 +1,121 @@ + $functionTemplateTags + * + * @return list + */ + public function check( + Node $node, + Scope $scope, + string $location, + Type $callableType, + ?string $functionName, + array $functionTemplateTags, + ?ClassReflection $classReflection, + ): array + { + $errors = []; + + TypeTraverser::map($callableType, function (Type $type, callable $traverse) use (&$errors, $node, $scope, $location, $functionName, $functionTemplateTags, $classReflection) { + if (!($type instanceof CallableType || $type instanceof ClosureType)) { + return $traverse($type); + } + + $typeDescription = $type->describe(VerbosityLevel::precise()); + + $errors = $this->templateTypeCheck->check( + $scope, + $node, + TemplateTypeScope::createWithAnonymousFunction(), + $type->getTemplateTags(), + sprintf('PHPDoc tag %s template of %s cannot have existing class %%s as its name.', $location, $typeDescription), + sprintf('PHPDoc tag %s template of %s cannot have existing type alias %%s as its name.', $location, $typeDescription), + sprintf('PHPDoc tag %s template %%s of %s has invalid bound type %%s.', $location, $typeDescription), + sprintf('PHPDoc tag %s template %%s of %s with bound type %%s is not supported.', $location, $typeDescription), + sprintf('PHPDoc tag %s template %%s of %s has invalid default type %%s.', $location, $typeDescription), + sprintf('Default type %%s in PHPDoc tag %s template %%s of %s is not subtype of bound type %%s.', $location, $typeDescription), + sprintf('PHPDoc tag %s template %%s of %s does not have a default type but follows an optional template %%s.', $location, $typeDescription), + ); + + $templateTags = $type->getTemplateTags(); + + $classDescription = null; + if ($classReflection !== null) { + $classDescription = $classReflection->getDisplayName(); + } + + if ($functionName !== null) { + $functionDescription = sprintf('function %s', $functionName); + if ($classReflection !== null) { + $functionDescription = sprintf('method %s::%s', $classDescription, $functionName); + } + + foreach (array_keys($functionTemplateTags) as $name) { + if (!isset($templateTags[$name])) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s template %s of %s shadows @template %s for %s.', + $location, + $name, + $typeDescription, + $name, + $functionDescription, + ))->identifier('callable.shadowTemplate')->build(); + } + } + + if ($classReflection !== null) { + foreach (array_keys($classReflection->getTemplateTags()) as $name) { + if (!isset($templateTags[$name])) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s template %s of %s shadows @template %s for class %s.', + $location, + $name, + $typeDescription, + $name, + $classDescription, + ))->identifier('callable.shadowTemplate')->build(); + } + } + + return $traverse($type); + }); + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/IncompatibleClassConstantPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatibleClassConstantPhpDocTypeRule.php new file mode 100644 index 00000000..3ae6a9b6 --- /dev/null +++ b/src/Rules/PhpDoc/IncompatibleClassConstantPhpDocTypeRule.php @@ -0,0 +1,139 @@ + + */ +final class IncompatibleClassConstantPhpDocTypeRule implements Rule +{ + + public function __construct( + private GenericObjectTypeCheck $genericObjectTypeCheck, + private UnresolvableTypeHelper $unresolvableTypeHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $nativeType = null; + if ($node->type !== null) { + $nativeType = ParserNodeTypeToPHPStanType::resolve($node->type, $scope->getClassReflection()); + } + + $errors = []; + foreach ($node->consts as $const) { + $constantName = $const->name->toString(); + $errors = array_merge($errors, $this->processSingleConstant($scope->getClassReflection(), $nativeType, $constantName)); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleConstant(ClassReflection $classReflection, ?Type $nativeType, string $constantName): array + { + $constantReflection = $classReflection->getConstant($constantName); + $phpDocType = $constantReflection->getPhpDocType(); + if ($phpDocType === null) { + return []; + } + + $errors = []; + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocType) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var for constant %s::%s contains unresolvable type.', + $constantReflection->getDeclaringClass()->getName(), + $constantName, + ))->identifier('classConstant.unresolvableType')->build(); + } elseif ($nativeType !== null) { + $isSuperType = $nativeType->isSuperTypeOf($phpDocType); + if ($isSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var for constant %s::%s with type %s is incompatible with native type %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $phpDocType->describe(VerbosityLevel::typeOnly()), + $nativeType->describe(VerbosityLevel::typeOnly()), + ))->identifier('classConstant.phpDocType')->build(); + + } elseif ($isSuperType->maybe()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var for constant %s::%s with type %s is not subtype of native type %s.', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $phpDocType->describe(VerbosityLevel::typeOnly()), + $nativeType->describe(VerbosityLevel::typeOnly()), + ))->identifier('classConstant.phpDocType')->build(); + } + } + + $className = SprintfHelper::escapeFormatString($constantReflection->getDeclaringClass()->getDisplayName()); + $escapedConstantName = SprintfHelper::escapeFormatString($constantName); + + return array_merge($errors, $this->genericObjectTypeCheck->check( + $phpDocType, + sprintf( + 'PHPDoc tag @var for constant %s::%s contains generic type %%s but %%s %%s is not generic.', + $className, + $escapedConstantName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag @var for constant %s::%s does not specify all template types of %%s %%s: %%s', + $className, + $escapedConstantName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag @var for constant %s::%s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $className, + $escapedConstantName, + ), + sprintf( + 'Type %%s in generic type %%s in PHPDoc tag @var for constant %s::%s is not subtype of template type %%s of %%s %%s.', + $className, + $escapedConstantName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag @var for constant %s::%s is in conflict with %%s template type %%s of %%s %%s.', + $className, + $escapedConstantName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag @var for constant %s::%s is redundant, template type %%s of %%s %%s has the same variance.', + $className, + $escapedConstantName, + ), + )); + } + +} diff --git a/src/Rules/PhpDoc/IncompatibleParamImmediatelyInvokedCallableRule.php b/src/Rules/PhpDoc/IncompatibleParamImmediatelyInvokedCallableRule.php new file mode 100644 index 00000000..f5bcbb2a --- /dev/null +++ b/src/Rules/PhpDoc/IncompatibleParamImmediatelyInvokedCallableRule.php @@ -0,0 +1,95 @@ + + */ +final class IncompatibleParamImmediatelyInvokedCallableRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + ) + { + } + + public function getNodeType(): string + { + return FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Node\Stmt\ClassMethod) { + $functionName = $node->name->name; + } elseif ($node instanceof Node\Stmt\Function_) { + $functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\'); + } else { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $functionName, + $docComment->getText(), + ); + $nativeParameterTypes = []; + foreach ($node->getParams() as $parameter) { + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $nativeParameterTypes[$parameter->var->name] = $scope->getFunctionType( + $parameter->type, + $scope->isParameterValueNullable($parameter), + false, + ); + } + + $errors = []; + foreach ($resolvedPhpDoc->getParamsImmediatelyInvokedCallable() as $parameterName => $immediately) { + $tagName = $immediately ? '@param-immediately-invoked-callable' : '@param-later-invoked-callable'; + if (!isset($nativeParameterTypes[$parameterName])) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s references unknown parameter: $%s', + $tagName, + $parameterName, + ))->identifier('parameter.notFound')->build(); + } elseif ($nativeParameterTypes[$parameterName]->isCallable()->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s is for parameter $%s with non-callable type %s.', + $tagName, + $parameterName, + $nativeParameterTypes[$parameterName]->describe(VerbosityLevel::typeOnly()), + ))->identifier(sprintf( + '%s.nonCallable', + $immediately ? 'paramImmediatelyInvokedCallable' : 'paramLaterInvokedCallable', + ))->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php new file mode 100644 index 00000000..b67f2b6a --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php @@ -0,0 +1,237 @@ + $nativeParameterTypes + * @param array $byRefParameters + * @return list + */ + public function check( + Scope $scope, + Node $node, + ResolvedPhpDocBlock $resolvedPhpDoc, + string $functionName, + array $nativeParameterTypes, + array $byRefParameters, + Type $nativeReturnType, + ): array + { + $errors = []; + + foreach (['@param' => $resolvedPhpDoc->getParamTags(), '@param-out' => $resolvedPhpDoc->getParamOutTags(), '@param-closure-this' => $resolvedPhpDoc->getParamClosureThisTags()] as $tagName => $parameters) { + foreach ($parameters as $parameterName => $phpDocParamTag) { + $phpDocParamType = $phpDocParamTag->getType(); + + if (!isset($nativeParameterTypes[$parameterName])) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s references unknown parameter: $%s', + $tagName, + $parameterName, + ))->identifier('parameter.notFound')->build(); + + } elseif ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocParamType) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s contains unresolvable type.', + $tagName, + $parameterName, + ))->identifier('parameter.unresolvableType')->build(); + + } else { + $nativeParamType = $nativeParameterTypes[$parameterName]; + if ( + $phpDocParamTag instanceof ParamTag + && $phpDocParamTag->isVariadic() + && $phpDocParamType->isArray()->yes() + && $nativeParamType->isArray()->no() + ) { + $phpDocParamType = $phpDocParamType->getIterableValueType(); + } + + $escapedParameterName = SprintfHelper::escapeFormatString($parameterName); + $escapedTagName = SprintfHelper::escapeFormatString($tagName); + + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $phpDocParamType, + sprintf( + 'PHPDoc tag %s for parameter $%s contains generic type %%s but %%s %%s is not generic.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s for parameter $%s does not specify all template types of %%s %%s: %%s', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s for parameter $%s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Type %%s in generic type %%s in PHPDoc tag %s for parameter $%s is not subtype of template type %%s of %%s %%s.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is in conflict with %%s template type %%s of %%s %%s.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is redundant, template type %%s of %%s %%s has the same variance.', + $escapedTagName, + $escapedParameterName, + ), + )); + + $errors = array_merge($errors, $this->genericCallableRuleHelper->check( + $node, + $scope, + sprintf('%s for parameter $%s', $escapedTagName, $escapedParameterName), + $phpDocParamType, + $functionName, + $resolvedPhpDoc->getTemplateTags(), + $scope->isInClass() ? $scope->getClassReflection() : null, + )); + + if ($phpDocParamTag instanceof ParamOutTag) { + if (!$byRefParameters[$parameterName]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s for PHPDoc tag %s is not passed by reference.', + $parameterName, + $tagName, + ))->identifier('parameter.notByRef')->build(); + + } + continue; + } + + if (in_array($tagName, ['@param', '@param-out'], true)) { + $isParamSuperType = $nativeParamType->isSuperTypeOf($phpDocParamType); + if ($isParamSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s with type %s is incompatible with native type %s.', + $tagName, + $parameterName, + $phpDocParamType->describe(VerbosityLevel::typeOnly()), + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('parameter.phpDocType')->build(); + + } elseif ($isParamSuperType->maybe()) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s with type %s is not subtype of native type %s.', + $tagName, + $parameterName, + $phpDocParamType->describe(VerbosityLevel::typeOnly()), + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('parameter.phpDocType'); + if ($phpDocParamType instanceof TemplateType) { + $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocParamType->getName(), $nativeParamType->describe(VerbosityLevel::typeOnly()))); + } + + $errors[] = $errorBuilder->build(); + } + } + + if ($tagName === '@param-closure-this') { + $isNonClosure = (new ClosureType())->isSuperTypeOf($nativeParamType)->no(); + if ($isNonClosure) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s is for parameter $%s with non-Closure type %s.', + $tagName, + $parameterName, + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('paramClosureThis.nonClosure')->build(); + } + } + } + } + } + + if ($resolvedPhpDoc->getReturnTag() !== null) { + $phpDocReturnType = $resolvedPhpDoc->getReturnTag()->getType(); + + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocReturnType) + ) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @return contains unresolvable type.')->identifier('return.unresolvableType')->build(); + + } else { + $isReturnSuperType = $nativeReturnType->isSuperTypeOf($phpDocReturnType); + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $phpDocReturnType, + 'PHPDoc tag @return contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @return does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @return specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @return is not subtype of template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @return is in conflict with %s template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @return is redundant, template type %s of %s %s has the same variance.', + )); + if ($isReturnSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @return with type %s is incompatible with native type %s.', + $phpDocReturnType->describe(VerbosityLevel::typeOnly()), + $nativeReturnType->describe(VerbosityLevel::typeOnly()), + ))->identifier('return.phpDocType')->build(); + + } elseif ($isReturnSuperType->maybe()) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @return with type %s is not subtype of native type %s.', + $phpDocReturnType->describe(VerbosityLevel::typeOnly()), + $nativeReturnType->describe(VerbosityLevel::typeOnly()), + ))->identifier('return.phpDocType'); + if ($phpDocReturnType instanceof TemplateType) { + $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocReturnType->getName(), $nativeReturnType->describe(VerbosityLevel::typeOnly()))); + } + + $errors[] = $errorBuilder->build(); + } + + $errors = array_merge($errors, $this->genericCallableRuleHelper->check( + $node, + $scope, + '@return', + $phpDocReturnType, + $functionName, + $resolvedPhpDoc->getTemplateTags(), + $scope->isInClass() ? $scope->getClassReflection() : null, + )); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php new file mode 100644 index 00000000..8f9e11e7 --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php @@ -0,0 +1,110 @@ + + */ +final class IncompatiblePhpDocTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private IncompatiblePhpDocTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return Node\FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Node\Stmt\ClassMethod) { + $functionName = $node->name->name; + } elseif ($node instanceof Node\Stmt\Function_) { + $functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\'); + } else { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $functionName, + $docComment->getText(), + ); + + return $this->check->check( + $scope, + $node, + $resolvedPhpDoc, + $functionName, + $this->getNativeParameterTypes($node, $scope), + $this->getByRefParameters($node), + $this->getNativeReturnType($node, $scope), + ); + } + + /** + * @return array + */ + private function getNativeParameterTypes(Node\FunctionLike $node, Scope $scope): array + { + $nativeParameterTypes = []; + foreach ($node->getParams() as $parameter) { + $isNullable = $scope->isParameterValueNullable($parameter); + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $nativeParameterTypes[$parameter->var->name] = $scope->getFunctionType( + $parameter->type, + $isNullable, + false, + ); + } + + return $nativeParameterTypes; + } + + /** + * @return array + */ + private function getByRefParameters(Node\FunctionLike $node): array + { + $nativeParameterTypes = []; + foreach ($node->getParams() as $parameter) { + if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { + throw new ShouldNotHappenException(); + } + $nativeParameterTypes[$parameter->var->name] = $parameter->byRef; + } + + return $nativeParameterTypes; + } + + private function getNativeReturnType(Node\FunctionLike $node, Scope $scope): Type + { + return $scope->getFunctionType($node->getReturnType(), false, false); + } + +} diff --git a/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php new file mode 100644 index 00000000..6fb33362 --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php @@ -0,0 +1,86 @@ + + */ +final class IncompatiblePropertyHookPhpDocTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private IncompatiblePhpDocTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $hookReflection = $node->getHookReflection(); + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $node->getClassReflection()->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $hookReflection->getName(), + $docComment->getText(), + ); + + return $this->check->check( + $scope, + $node, + $resolvedPhpDoc, + $hookReflection->getName(), + $this->getNativeParameterTypes($hookReflection), + $this->getByRefParameters($hookReflection), + $hookReflection->getNativeReturnType(), + ); + } + + /** + * @return array + */ + private function getNativeParameterTypes(PhpMethodFromParserNodeReflection $node): array + { + $parameters = []; + foreach ($node->getParameters() as $parameter) { + $parameters[$parameter->getName()] = $parameter->getNativeType(); + } + + return $parameters; + } + + /** + * @return array + */ + private function getByRefParameters(PhpMethodFromParserNodeReflection $node): array + { + $parameters = []; + foreach ($node->getParameters() as $parameter) { + $parameters[$parameter->getName()] = false; + } + + return $parameters; + } + +} diff --git a/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php new file mode 100644 index 00000000..097a12bc --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php @@ -0,0 +1,154 @@ + + */ +final class IncompatiblePropertyPhpDocTypeRule implements Rule +{ + + public function __construct( + private GenericObjectTypeCheck $genericObjectTypeCheck, + private UnresolvableTypeHelper $unresolvableTypeHelper, + private GenericCallableRuleHelper $genericCallableRuleHelper, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $phpDocType = $node->getPhpDocType(); + if ($phpDocType === null) { + return []; + } + + $propertyName = $node->getName(); + + $description = 'PHPDoc tag @var'; + if ($node->isPromoted()) { + $description = 'PHPDoc type'; + } + + $classReflection = $node->getClassReflection(); + + $messages = []; + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocType) + ) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s for property %s::$%s contains unresolvable type.', + $description, + $classReflection->getDisplayName(), + $propertyName, + ))->identifier('property.unresolvableType')->build(); + } + + $nativeType = $node->getNativeType(); + if ($nativeType !== null) { + $isSuperType = $nativeType->isSuperTypeOf($phpDocType); + if ($isSuperType->no()) { + $messages[] = RuleErrorBuilder::message(sprintf( + '%s for property %s::$%s with type %s is incompatible with native type %s.', + $description, + $classReflection->getDisplayName(), + $propertyName, + $phpDocType->describe(VerbosityLevel::typeOnly()), + $nativeType->describe(VerbosityLevel::typeOnly()), + ))->identifier('property.phpDocType')->build(); + + } elseif ($isSuperType->maybe()) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + '%s for property %s::$%s with type %s is not subtype of native type %s.', + $description, + $classReflection->getDisplayName(), + $propertyName, + $phpDocType->describe(VerbosityLevel::typeOnly()), + $nativeType->describe(VerbosityLevel::typeOnly()), + ))->identifier('property.phpDocType'); + + if ($phpDocType instanceof TemplateType) { + $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocType->getName(), $nativeType->describe(VerbosityLevel::typeOnly()))); + } + + $messages[] = $errorBuilder->build(); + } + } + + $className = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $escapedPropertyName = SprintfHelper::escapeFormatString($propertyName); + + if ($node->isPromoted() === false) { + $messages = array_merge($messages, $this->genericCallableRuleHelper->check( + $node, + $scope, + '@var', + $phpDocType, + null, + [], + $classReflection, + )); + } + + $messages = array_merge($messages, $this->genericObjectTypeCheck->check( + $phpDocType, + sprintf( + '%s for property %s::$%s contains generic type %%s but %%s %%s is not generic.', + $description, + $className, + $escapedPropertyName, + ), + sprintf( + 'Generic type %%s in %s for property %s::$%s does not specify all template types of %%s %%s: %%s', + $description, + $className, + $escapedPropertyName, + ), + sprintf( + 'Generic type %%s in %s for property %s::$%s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $description, + $className, + $escapedPropertyName, + ), + sprintf( + 'Type %%s in generic type %%s in %s for property %s::$%s is not subtype of template type %%s of %%s %%s.', + $description, + $className, + $escapedPropertyName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in %s for property %s::$%s is in conflict with %%s template type %%s of %%s %%s.', + $description, + $className, + $escapedPropertyName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in %s for property %s::$%s is redundant, template type %%s of %%s %%s has the same variance.', + $description, + $className, + $escapedPropertyName, + ), + )); + + return $messages; + } + +} diff --git a/src/Rules/PhpDoc/IncompatibleSelfOutTypeRule.php b/src/Rules/PhpDoc/IncompatibleSelfOutTypeRule.php new file mode 100644 index 00000000..2ec0a35b --- /dev/null +++ b/src/Rules/PhpDoc/IncompatibleSelfOutTypeRule.php @@ -0,0 +1,104 @@ + + */ +final class IncompatibleSelfOutTypeRule implements Rule +{ + + public function __construct( + private UnresolvableTypeHelper $unresolvableTypeHelper, + private GenericObjectTypeCheck $genericObjectTypeCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $selfOutType = $method->getSelfOutType(); + + if ($selfOutType === null) { + return []; + } + + $classReflection = $method->getDeclaringClass(); + $classType = new ObjectType($classReflection->getName(), null, $classReflection); + + $errors = []; + if (!$classType->isSuperTypeOf($selfOutType)->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Self-out type %s of method %s::%s is not subtype of %s.', + $selfOutType->describe(VerbosityLevel::precise()), + $classReflection->getDisplayName(), + $method->getName(), + $classType->describe(VerbosityLevel::precise()), + ))->identifier('selfOut.type')->build(); + } + + if ($method->isStatic()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-self-out is not supported above static method %s::%s().', $classReflection->getName(), $method->getName())) + ->identifier('selfOut.static') + ->build(); + } + + if ($this->unresolvableTypeHelper->containsUnresolvableType($selfOutType)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @phpstan-self-out for method %s::%s() contains unresolvable type.', + $classReflection->getDisplayName(), + $method->getName(), + ))->identifier('selfOut.unresolvableType')->build(); + } + + $escapedTagName = SprintfHelper::escapeFormatString('@phpstan-self-out'); + + return array_merge($errors, $this->genericObjectTypeCheck->check( + $selfOutType, + sprintf( + 'PHPDoc tag %s contains generic type %%s but %%s %%s is not generic.', + $escapedTagName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s does not specify all template types of %%s %%s: %%s', + $escapedTagName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $escapedTagName, + ), + sprintf( + 'Type %%s in generic type %%s in PHPDoc tag %s is not subtype of template type %%s of %%s %%s.', + $escapedTagName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s is in conflict with %%s template type %%s of %%s %%s.', + $escapedTagName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s is redundant, template type %%s of %%s %%s has the same variance.', + $escapedTagName, + ), + )); + } + +} diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php new file mode 100644 index 00000000..30280937 --- /dev/null +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -0,0 +1,119 @@ + + */ +final class InvalidPHPStanDocTagRule implements Rule +{ + + private const POSSIBLE_PHPSTAN_TAGS = [ + '@phpstan-param', + '@phpstan-param-out', + '@phpstan-var', + '@phpstan-extends', + '@phpstan-implements', + '@phpstan-use', + '@phpstan-template', + '@phpstan-template-contravariant', + '@phpstan-template-covariant', + '@phpstan-return', + '@phpstan-throws', + '@phpstan-ignore', + '@phpstan-ignore-next-line', + '@phpstan-ignore-line', + '@phpstan-method', + '@phpstan-pure', + '@phpstan-impure', + '@phpstan-immutable', + '@phpstan-type', + '@phpstan-import-type', + '@phpstan-property', + '@phpstan-property-read', + '@phpstan-property-write', + '@phpstan-consistent-constructor', + '@phpstan-assert', + '@phpstan-assert-if-true', + '@phpstan-assert-if-false', + '@phpstan-self-out', + '@phpstan-this-out', + '@phpstan-allow-private-mutation', + '@phpstan-readonly', + '@phpstan-readonly-allow-private-mutation', + '@phpstan-require-extends', + '@phpstan-require-implements', + '@phpstan-param-immediately-invoked-callable', + '@phpstan-param-later-invoked-callable', + '@phpstan-param-closure-this', + ]; + + public function __construct( + private Lexer $phpDocLexer, + private PhpDocParser $phpDocParser, + ) + { + } + + public function getNodeType(): string + { + return NodeAbstract::class; + } + + public function processNode(Node $node, Scope $scope): array + { + // mirrored with InvalidPhpDocTagValueRule + if ($node instanceof VirtualNode) { + return []; + } + if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { + return []; + } + if ($node instanceof Node\Stmt\Expression) { + if (!$node->expr instanceof Node\Expr\Assign && !$node->expr instanceof Node\Expr\AssignRef) { + return []; + } + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + $phpDocString = $docComment->getText(); + $tokens = new TokenIterator($this->phpDocLexer->tokenize($phpDocString)); + $phpDocNode = $this->phpDocParser->parse($tokens); + + $errors = []; + foreach ($phpDocNode->getTags() as $phpDocTag) { + if (!str_starts_with($phpDocTag->name, '@phpstan-') + || in_array($phpDocTag->name, self::POSSIBLE_PHPSTAN_TAGS, true) + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Unknown PHPDoc tag: %s', + $phpDocTag->name, + )) + ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) + ->identifier('phpDoc.phpstanTag')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php b/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php new file mode 100644 index 00000000..38c86789 --- /dev/null +++ b/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php @@ -0,0 +1,101 @@ + + */ +final class InvalidPhpDocTagValueRule implements Rule +{ + + public function __construct( + private Lexer $phpDocLexer, + private PhpDocParser $phpDocParser, + ) + { + } + + public function getNodeType(): string + { + return NodeAbstract::class; + } + + public function processNode(Node $node, Scope $scope): array + { + // mirrored with InvalidPHPStanDocTagRule + if ($node instanceof VirtualNode) { + return []; + } + if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { + return []; + } + if ($node instanceof Node\Stmt\Expression) { + if (!$node->expr instanceof Node\Expr\Assign && !$node->expr instanceof Node\Expr\AssignRef) { + return []; + } + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $phpDocString = $docComment->getText(); + $tokens = new TokenIterator($this->phpDocLexer->tokenize($phpDocString)); + $phpDocNode = $this->phpDocParser->parse($tokens); + + $errors = []; + foreach ($phpDocNode->getTags() as $phpDocTag) { + if (str_starts_with($phpDocTag->name, '@phan-') || str_starts_with($phpDocTag->name, '@psalm-')) { + continue; + } + + if ($phpDocTag->value instanceof TypeAliasTagValueNode) { + if (!$phpDocTag->value->type instanceof InvalidTypeNode) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s %s has invalid value: %s', + $phpDocTag->name, + $phpDocTag->value->alias, + $phpDocTag->value->type->getException()->getMessage(), + )) + ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) + ->identifier('phpDoc.parseError')->build(); + + continue; + } elseif (!($phpDocTag->value instanceof InvalidTagValueNode)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s has invalid value (%s): %s', + $phpDocTag->name, + $phpDocTag->value->value, + $phpDocTag->value->exception->getMessage(), + )) + ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) + ->identifier('phpDoc.parseError')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php new file mode 100644 index 00000000..e4dc50e4 --- /dev/null +++ b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php @@ -0,0 +1,161 @@ + + */ +final class InvalidPhpDocVarTagTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private GenericObjectTypeCheck $genericObjectTypeCheck, + private MissingTypehintCheck $missingTypehintCheck, + private UnresolvableTypeHelper $unresolvableTypeHelper, + private bool $checkClassCaseSensitivity, + private bool $checkMissingVarTagTypehint, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node instanceof Node\Stmt\Property + || $node instanceof Node\Stmt\ClassConst + || $node instanceof Node\Stmt\Const_ + ) { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $function = $scope->getFunction(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $docComment->getText(), + ); + + $errors = []; + foreach ($resolvedPhpDoc->getVarTags() as $name => $varTag) { + $varTagType = $varTag->getType(); + $identifier = 'PHPDoc tag @var'; + if (is_string($name)) { + $identifier .= sprintf(' for variable $%s', $name); + } + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($varTagType) + ) { + $errors[] = RuleErrorBuilder::message(sprintf('%s contains unresolvable type.', $identifier)) + ->line($docComment->getStartLine()) + ->identifier('varTag.unresolvableType') + ->build(); + continue; + } + + if ($this->checkMissingVarTagTypehint) { + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($varTagType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s has no value type specified in iterable type %s.', + $identifier, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($varTagType) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s contains generic %s but does not specify its types: %s', + $identifier, + $innerName, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + } + + $escapedIdentifier = SprintfHelper::escapeFormatString($identifier); + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $varTagType, + sprintf('%s contains generic type %%s but %%s %%s is not generic.', $escapedIdentifier), + sprintf('Generic type %%s in %s does not specify all template types of %%s %%s: %%s', $escapedIdentifier), + sprintf('Generic type %%s in %s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedIdentifier), + sprintf('Type %%s in generic type %%s in %s is not subtype of template type %%s of %%s %%s.', $escapedIdentifier), + sprintf('Call-site variance of %%s in generic type %%s in %s is in conflict with %%s template type %%s of %%s %%s.', $escapedIdentifier), + sprintf('Call-site variance of %%s in generic type %%s in %s is redundant, template type %%s of %%s %%s has the same variance.', $escapedIdentifier), + )); + + $referencedClasses = $varTagType->getReferencedClasses(); + foreach ($referencedClasses as $referencedClass) { + if ($this->reflectionProvider->hasClass($referencedClass)) { + if ($this->reflectionProvider->getClass($referencedClass)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf( + sprintf('%s has invalid type %%s.', $identifier), + $referencedClass, + ))->identifier('varTag.trait')->build(); + } + continue; + } + + if ($scope->isInClassExists($referencedClass)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + sprintf('%s contains unknown class %%s.', $identifier), + $referencedClass, + )) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $node), $referencedClasses), + $this->checkClassCaseSensitivity, + ), + ); + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php new file mode 100644 index 00000000..018d2201 --- /dev/null +++ b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php @@ -0,0 +1,113 @@ + + */ +final class InvalidThrowsPhpDocValueRule implements Rule +{ + + public function __construct(private FileTypeMapper $fileTypeMapper) + { + } + + public function getNodeType(): string + { + return NodeAbstract::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Node\Stmt) { + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + return []; // is handled by virtual nodes + } + } elseif (!$node instanceof InPropertyHookNode) { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $functionName = null; + if ($scope->getFunction() !== null) { + $functionName = $scope->getFunction()->getName(); + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $functionName, + $docComment->getText(), + ); + + if ($resolvedPhpDoc->getThrowsTag() === null) { + return []; + } + + $phpDocThrowsType = $resolvedPhpDoc->getThrowsTag()->getType(); + if ($phpDocThrowsType->isVoid()->yes()) { + return []; + } + + if ($this->isThrowsValid($phpDocThrowsType)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @throws with type %s is not subtype of Throwable', + $phpDocThrowsType->describe(VerbosityLevel::typeOnly()), + ))->identifier('throws.notThrowable')->build(), + ]; + } + + private function isThrowsValid(Type $phpDocThrowsType): bool + { + $throwType = new ObjectType(Throwable::class); + if ($phpDocThrowsType instanceof UnionType) { + foreach ($phpDocThrowsType->getTypes() as $innerType) { + if (!$this->isThrowsValid($innerType)) { + return false; + } + } + + return true; + } + + $toIntersectWith = []; + foreach ($phpDocThrowsType->getObjectClassReflections() as $classReflection) { + if (!$classReflection->isInterface()) { + continue; + } + foreach ($classReflection->getRequireExtendsTags() as $requireExtendsTag) { + $toIntersectWith[] = $requireExtendsTag->getType(); + } + } + + return $throwType->isSuperTypeOf( + TypeCombinator::intersect($phpDocThrowsType, ...$toIntersectWith), + )->yes(); + } + +} diff --git a/src/Rules/PhpDoc/MethodAssertRule.php b/src/Rules/PhpDoc/MethodAssertRule.php new file mode 100644 index 00000000..52ed0ee5 --- /dev/null +++ b/src/Rules/PhpDoc/MethodAssertRule.php @@ -0,0 +1,38 @@ + + */ +final class MethodAssertRule implements Rule +{ + + public function __construct(private AssertRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $variants = $method->getVariants(); + if (count($variants) !== 1) { + return []; + } + + return $this->helper->check($node->getOriginalNode(), $method, $variants[0]); + } + +} diff --git a/src/Rules/PhpDoc/MethodConditionalReturnTypeRule.php b/src/Rules/PhpDoc/MethodConditionalReturnTypeRule.php new file mode 100644 index 00000000..c0f824cd --- /dev/null +++ b/src/Rules/PhpDoc/MethodConditionalReturnTypeRule.php @@ -0,0 +1,38 @@ + + */ +final class MethodConditionalReturnTypeRule implements Rule +{ + + public function __construct(private ConditionalReturnTypeRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + $variants = $method->getVariants(); + if (count($variants) !== 1) { + return []; + } + + return $this->helper->check($variants[0]); + } + +} diff --git a/src/Rules/PhpDoc/PhpDocLineHelper.php b/src/Rules/PhpDoc/PhpDocLineHelper.php new file mode 100644 index 00000000..b441d3ce --- /dev/null +++ b/src/Rules/PhpDoc/PhpDocLineHelper.php @@ -0,0 +1,29 @@ +getAttribute('startLine'); + $phpDoc = $node->getDocComment(); + + if ($phpDocTagLine === null || $phpDoc === null) { + return $node->getStartLine(); + } + + return $phpDoc->getStartLine() + $phpDocTagLine - 1; + } + +} diff --git a/src/Rules/PhpDoc/RequireExtendsCheck.php b/src/Rules/PhpDoc/RequireExtendsCheck.php new file mode 100644 index 00000000..cdde0dc3 --- /dev/null +++ b/src/Rules/PhpDoc/RequireExtendsCheck.php @@ -0,0 +1,84 @@ + $extendsTags + * @return list + */ + public function checkExtendsTags(Node $node, array $extendsTags): array + { + $errors = []; + + if (count($extendsTags) > 1) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-extends can only be used once.')) + ->identifier('requireExtends.duplicate') + ->build(); + } + + foreach ($extendsTags as $extendsTag) { + $type = $extendsTag->getType(); + if (!$type instanceof ObjectType) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-extends contains non-object type %s.', $type->describe(VerbosityLevel::typeOnly()))) + ->identifier('requireExtends.nonObject') + ->build(); + continue; + } + + $class = $type->getClassName(); + $referencedClassReflection = $type->getClassReflection(); + + if ($referencedClassReflection === null) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-extends contains unknown class %s.', $class)) + ->discoveringSymbolsTip() + ->identifier('class.notFound') + ->build(); + continue; + } + + if (!$referencedClassReflection->isClass()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-extends cannot contain non-class type %s.', $class)) + ->identifier(sprintf('requireExtends.%s', strtolower($referencedClassReflection->getClassTypeDescription()))) + ->build(); + } elseif ($referencedClassReflection->isFinal()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-extends cannot contain final class %s.', $class)) + ->identifier('requireExtends.finalClass') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/RequireExtendsDefinitionClassRule.php b/src/Rules/PhpDoc/RequireExtendsDefinitionClassRule.php new file mode 100644 index 00000000..cc9d6d79 --- /dev/null +++ b/src/Rules/PhpDoc/RequireExtendsDefinitionClassRule.php @@ -0,0 +1,51 @@ + + */ +final class RequireExtendsDefinitionClassRule implements Rule +{ + + public function __construct( + private RequireExtendsCheck $requireExtendsCheck, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $extendsTags = $classReflection->getRequireExtendsTags(); + + if (count($extendsTags) === 0) { + return []; + } + + if (!$classReflection->isInterface()) { + return [ + RuleErrorBuilder::message('PHPDoc tag @phpstan-require-extends is only valid on trait or interface.') + ->identifier(sprintf('requireExtends.on%s', $classReflection->getClassTypeDescription())) + ->build(), + ]; + } + + return $this->requireExtendsCheck->checkExtendsTags($node, $extendsTags); + } + +} diff --git a/src/Rules/PhpDoc/RequireExtendsDefinitionTraitRule.php b/src/Rules/PhpDoc/RequireExtendsDefinitionTraitRule.php new file mode 100644 index 00000000..8731bcab --- /dev/null +++ b/src/Rules/PhpDoc/RequireExtendsDefinitionTraitRule.php @@ -0,0 +1,44 @@ + + */ +final class RequireExtendsDefinitionTraitRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RequireExtendsCheck $requireExtendsCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node->namespacedName === null + || !$this->reflectionProvider->hasClass($node->namespacedName->toString()) + ) { + return []; + } + + $traitReflection = $this->reflectionProvider->getClass($node->namespacedName->toString()); + $extendsTags = $traitReflection->getRequireExtendsTags(); + + return $this->requireExtendsCheck->checkExtendsTags($node, $extendsTags); + } + +} diff --git a/src/Rules/PhpDoc/RequireImplementsDefinitionClassRule.php b/src/Rules/PhpDoc/RequireImplementsDefinitionClassRule.php new file mode 100644 index 00000000..c0dccb79 --- /dev/null +++ b/src/Rules/PhpDoc/RequireImplementsDefinitionClassRule.php @@ -0,0 +1,41 @@ + + */ +final class RequireImplementsDefinitionClassRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $implementsTags = $classReflection->getRequireImplementsTags(); + + if (count($implementsTags) === 0) { + return []; + } + + return [ + RuleErrorBuilder::message('PHPDoc tag @phpstan-require-implements is only valid on trait.') + ->identifier(sprintf('requireImplements.on%s', $classReflection->getClassTypeDescription())) + ->build(), + ]; + } + +} diff --git a/src/Rules/PhpDoc/RequireImplementsDefinitionTraitRule.php b/src/Rules/PhpDoc/RequireImplementsDefinitionTraitRule.php new file mode 100644 index 00000000..a04daa3f --- /dev/null +++ b/src/Rules/PhpDoc/RequireImplementsDefinitionTraitRule.php @@ -0,0 +1,87 @@ + + */ +final class RequireImplementsDefinitionTraitRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private bool $checkClassCaseSensitivity, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node->namespacedName === null + || !$this->reflectionProvider->hasClass($node->namespacedName->toString()) + ) { + return []; + } + + $traitReflection = $this->reflectionProvider->getClass($node->namespacedName->toString()); + $implementsTags = $traitReflection->getRequireImplementsTags(); + + $errors = []; + foreach ($implementsTags as $implementsTag) { + $type = $implementsTag->getType(); + if (!$type instanceof ObjectType) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-implements contains non-object type %s.', $type->describe(VerbosityLevel::typeOnly()))) + ->identifier('requireImplements.nonObject') + ->build(); + continue; + } + + $class = $type->getClassName(); + $referencedClassReflection = $type->getClassReflection(); + if ($referencedClassReflection === null) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-implements contains unknown class %s.', $class)) + ->discoveringSymbolsTip() + ->identifier('class.notFound') + ->build(); + continue; + } + + if (!$referencedClassReflection->isInterface()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-require-implements cannot contain non-interface type %s.', $class)) + ->identifier(sprintf('requireImplements.%s', strtolower($referencedClassReflection->getClassTypeDescription()))) + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/UnresolvableTypeHelper.php b/src/Rules/PhpDoc/UnresolvableTypeHelper.php new file mode 100644 index 00000000..1e83e9ea --- /dev/null +++ b/src/Rules/PhpDoc/UnresolvableTypeHelper.php @@ -0,0 +1,33 @@ +isExplicit()) { + $containsUnresolvable = true; + return $type; + } + + return $traverse($type); + }); + + return $containsUnresolvable; + } + +} diff --git a/src/Rules/PhpDoc/VarTagChangedExpressionTypeRule.php b/src/Rules/PhpDoc/VarTagChangedExpressionTypeRule.php new file mode 100644 index 00000000..1609c248 --- /dev/null +++ b/src/Rules/PhpDoc/VarTagChangedExpressionTypeRule.php @@ -0,0 +1,31 @@ + + */ +final class VarTagChangedExpressionTypeRule implements Rule +{ + + public function __construct(private VarTagTypeRuleHelper $varTagTypeRuleHelper) + { + } + + public function getNodeType(): string + { + return VarTagChangedExpressionTypeNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->varTagTypeRuleHelper->checkExprType($scope, $node->getExpr(), $node->getVarTag()->getType()); + } + +} diff --git a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php new file mode 100644 index 00000000..90260867 --- /dev/null +++ b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php @@ -0,0 +1,193 @@ + + */ + public function checkVarType(Scope $scope, Node\Expr $var, Node\Expr $expr, array $varTags, array $assignedVariables): array + { + $errors = []; + + if ($var instanceof Expr\Variable && is_string($var->name)) { + if (array_key_exists($var->name, $varTags)) { + $varTagType = $varTags[$var->name]->getType(); + } elseif (count($assignedVariables) === 1 && array_key_exists(0, $varTags)) { + $varTagType = $varTags[0]->getType(); + } else { + return []; + } + + return $this->checkExprType($scope, $expr, $varTagType); + } elseif ($var instanceof Expr\List_ || $var instanceof Expr\Array_) { + foreach ($var->items as $i => $arrayItem) { + if ($arrayItem === null) { + continue; + } + if ($arrayItem->key === null) { + $dimExpr = new Node\Scalar\Int_($i); + } else { + $dimExpr = $arrayItem->key; + } + + $itemErrors = $this->checkVarType($scope, $arrayItem->value, new GetOffsetValueTypeExpr($expr, $dimExpr), $varTags, $assignedVariables); + foreach ($itemErrors as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * @return list + */ + public function checkExprType(Scope $scope, Node\Expr $expr, Type $varTagType): array + { + $errors = []; + $exprNativeType = $scope->getNativeType($expr); + $containsPhpStanType = $this->containsPhpStanType($varTagType); + if ($this->shouldVarTagTypeBeReported($expr, $exprNativeType, $varTagType)) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($exprNativeType, $varTagType); + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var with type %s is not subtype of native type %s.', + $varTagType->describe($verbosity), + $exprNativeType->describe($verbosity), + ))->identifier('varTag.nativeType')->build(); + } else { + $exprType = $scope->getType($expr); + if ( + $this->shouldVarTagTypeBeReported($expr, $exprType, $varTagType) + && ($this->checkTypeAgainstPhpDocType || $containsPhpStanType) + ) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($exprType, $varTagType); + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var with type %s is not subtype of type %s.', + $varTagType->describe($verbosity), + $exprType->describe($verbosity), + ))->identifier('varTag.type')->build(); + } + } + + if (count($errors) === 0 && $containsPhpStanType) { + $exprType = $scope->getType($expr); + if (!$exprType->equals($varTagType)) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($exprType, $varTagType); + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var assumes the expression with type %s is always %s but it\'s error-prone and dangerous.', + $exprType->describe($verbosity), + $varTagType->describe($verbosity), + ))->identifier('phpstanApi.varTagAssumption')->build(); + } + } + + return $errors; + } + + private function containsPhpStanType(Type $type): bool + { + $classReflections = TypeUtils::toBenevolentUnion($type)->getObjectClassReflections(); + foreach ($classReflections as $classReflection) { + if (!$classReflection->isSubclassOf(Type::class)) { + continue; + } + + return true; + } + + return false; + } + + private function shouldVarTagTypeBeReported(Node\Expr $expr, Type $type, Type $varTagType): bool + { + if ($expr instanceof Expr\Array_) { + if ($expr->items === []) { + $type = new ArrayType(new MixedType(), new MixedType()); + } + + return $type->isSuperTypeOf($varTagType)->no(); + } + + if ($expr instanceof Expr\ConstFetch) { + return $type->isSuperTypeOf($varTagType)->no(); + } + + if ($expr instanceof Node\Scalar) { + return $type->isSuperTypeOf($varTagType)->no(); + } + + if ($expr instanceof Expr\New_) { + if ($type instanceof GenericObjectType) { + $type = new ObjectType($type->getClassName()); + } + } + + return $this->checkType($type, $varTagType); + } + + private function checkType(Type $type, Type $varTagType, int $depth = 0): bool + { + if ($this->strictWideningCheck) { + return !$type->isSuperTypeOf($varTagType)->yes(); + } + + if ($type->isConstantArray()->yes()) { + if ($type->isIterableAtLeastOnce()->no()) { + $type = new ArrayType(new MixedType(), new MixedType()); + return $type->isSuperTypeOf($varTagType)->no(); + } + } + + if ($type->isIterable()->yes() && $varTagType->isIterable()->yes()) { + if ($type->isSuperTypeOf($varTagType)->no()) { + return true; + } + + $innerType = $type->getIterableValueType(); + $innerVarTagType = $varTagType->getIterableValueType(); + + if ($type->equals($innerType) || $varTagType->equals($innerVarTagType)) { + return !$innerType->isSuperTypeOf($innerVarTagType)->yes(); + } + + return $this->checkType($innerType, $innerVarTagType, $depth + 1); + } + + if ($type->isConstantValue()->yes() && $depth === 0) { + return $type->isSuperTypeOf($varTagType)->no(); + } + + return !$type->isSuperTypeOf($varTagType)->yes(); + } + +} diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php new file mode 100644 index 00000000..efa11e61 --- /dev/null +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -0,0 +1,415 @@ + + */ +final class WrongVariableNameInVarTagRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private VarTagTypeRuleHelper $varTagTypeRuleHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node instanceof Node\Stmt\Property + || $node instanceof Node\Stmt\ClassConst + || $node instanceof Node\Stmt\Const_ + || ($node instanceof VirtualNode && !$node instanceof InFunctionNode && !$node instanceof InClassMethodNode && !$node instanceof InClassNode) + ) { + return []; + } + + $varTags = []; + $function = $scope->getFunction(); + foreach ($node->getComments() as $comment) { + if (!$comment instanceof Doc) { + continue; + } + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $comment->getText(), + ); + foreach ($resolvedPhpDoc->getVarTags() as $key => $varTag) { + $varTags[$key] = $varTag; + } + } + + if (count($varTags) === 0) { + return []; + } + + if ($node instanceof Node\Stmt\Foreach_) { + return $this->processForeach($scope, $node->expr, $node->keyVar, $node->valueVar, $varTags); + } + + if ($node instanceof Node\Stmt\Static_) { + return $this->processStatic($scope, $node->vars, $varTags); + } + + if ($node instanceof Node\Stmt\Expression) { + if ($node->expr instanceof Expr\Throw_) { + return $this->processStmt($scope, $varTags, $node->expr); + } + return $this->processExpression($scope, $node->expr, $varTags); + } + + if ($node instanceof Node\Stmt\Return_) { + return $this->processStmt($scope, $varTags, $node->expr); + } + + if ($node instanceof Node\Stmt\Global_) { + return $this->processGlobal($scope, $node, $varTags); + } + + if ($node instanceof InClassNode || $node instanceof InClassMethodNode || $node instanceof InFunctionNode) { + $description = 'a function'; + $originalNode = $node->getOriginalNode(); + if ($originalNode instanceof Node\Stmt\Interface_) { + $description = 'an interface'; + } elseif ($originalNode instanceof Node\Stmt\Class_) { + $description = 'a class'; + } elseif ($originalNode instanceof Node\Stmt\Enum_) { + $description = 'an enum'; + } elseif ($originalNode instanceof Node\Stmt\Trait_) { + throw new ShouldNotHappenException(); + } elseif ($originalNode instanceof Node\Stmt\ClassMethod) { + $description = 'a method'; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @var above %s has no effect.', + $description, + ))->identifier('varTag.misplaced')->build(), + ]; + } + + return $this->processStmt($scope, $varTags, null); + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processAssign(Scope $scope, Node\Expr $var, Node\Expr $expr, array $varTags): array + { + $errors = []; + $hasMultipleMessage = false; + $assignedVariables = $this->getAssignedVariables($var); + foreach (array_keys($varTags) as $key) { + if (is_int($key)) { + if (count($varTags) !== 1) { + if (!$hasMultipleMessage) { + $errors[] = RuleErrorBuilder::message('Multiple PHPDoc @var tags above single variable assignment are not supported.') + ->identifier('varTag.multipleTags') + ->build(); + $hasMultipleMessage = true; + } + } elseif (count($assignedVariables) !== 1) { + $errors[] = RuleErrorBuilder::message( + 'PHPDoc tag @var above assignment does not specify variable name.', + )->identifier('varTag.noVariable')->build(); + } + continue; + } + + if (!$scope->hasVariableType($key)->no()) { + continue; + } + + if (in_array($key, $assignedVariables, true)) { + continue; + } + + if (count($assignedVariables) === 1 && count($varTags) === 1) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variable $%s in PHPDoc tag @var does not match assigned variable $%s.', + $key, + $assignedVariables[0], + ))->identifier('varTag.differentVariable')->build(); + } else { + $errors[] = RuleErrorBuilder::message(sprintf('Variable $%s in PHPDoc tag @var does not exist.', $key)) + ->identifier('varTag.variableNotFound') + ->build(); + } + } + + if (count($errors) === 0) { + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $var, $expr, $varTags, $assignedVariables) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return string[] + */ + private function getAssignedVariables(Expr $expr): array + { + if ($expr instanceof Expr\Variable) { + if (is_string($expr->name)) { + return [$expr->name]; + } + + return []; + } + + if ($expr instanceof Expr\List_) { + $names = []; + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + $names = array_merge($names, $this->getAssignedVariables($item->value)); + } + + return $names; + } + + return []; + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processForeach(Scope $scope, Node\Expr $iterateeExpr, ?Node\Expr $keyVar, Node\Expr $valueVar, array $varTags): array + { + $variableNames = []; + if ($iterateeExpr instanceof Node\Expr\Variable && is_string($iterateeExpr->name)) { + $variableNames[] = $iterateeExpr->name; + } + if ($keyVar instanceof Node\Expr\Variable && is_string($keyVar->name)) { + $variableNames[] = $keyVar->name; + } + $variableNames = array_merge($variableNames, $this->getAssignedVariables($valueVar)); + + $errors = []; + foreach (array_keys($varTags) as $name) { + if (is_int($name)) { + if (count($variableNames) === 1) { + continue; + } + $errors[] = RuleErrorBuilder::message( + 'PHPDoc tag @var above foreach loop does not specify variable name.', + )->identifier('varTag.noVariable')->build(); + continue; + } + + if (in_array($name, $variableNames, true)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variable $%s in PHPDoc tag @var does not match any variable in the foreach loop: %s', + $name, + implode(', ', array_map(static fn (string $name): string => sprintf('$%s', $name), $variableNames)), + ))->identifier('varTag.differentVariable')->build(); + } + + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $iterateeExpr, $iterateeExpr, $varTags, $variableNames) as $error) { + $errors[] = $error; + } + if ($keyVar !== null) { + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $keyVar, new GetIterableKeyTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $errors[] = $error; + } + } + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $valueVar, new GetIterableValueTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $errors[] = $error; + } + + return $errors; + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processExpression(Scope $scope, Expr $expr, array $varTags): array + { + if ($expr instanceof Node\Expr\Assign || $expr instanceof Node\Expr\AssignRef) { + return $this->processAssign($scope, $expr->var, $expr->expr, $varTags); + } + + return $this->processStmt($scope, $varTags, null); + } + + /** + * @param Node\Stmt\StaticVar[] $vars + * @param VarTag[] $varTags + * @return list + */ + private function processStatic(Scope $scope, array $vars, array $varTags): array + { + $variableNames = []; + foreach ($vars as $var) { + if (!is_string($var->var->name)) { + continue; + } + + $variableNames[] = $var->var->name; + } + + $errors = []; + foreach (array_keys($varTags) as $name) { + if (is_int($name)) { + if (count($vars) === 1) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + 'PHPDoc tag @var above multiple static variables does not specify variable name.', + )->identifier('varTag.noVariable')->build(); + continue; + } + + if (in_array($name, $variableNames, true)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variable $%s in PHPDoc tag @var does not match any static variable: %s', + $name, + implode(', ', array_map(static fn (string $name): string => sprintf('$%s', $name), $variableNames)), + ))->identifier('varTag.differentVariable')->build(); + } + + foreach ($vars as $var) { + if ($var->default === null) { + continue; + } + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $var->var, $var->default, $varTags, $variableNames) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processStmt(Scope $scope, array $varTags, ?Expr $defaultExpr): array + { + $errors = []; + + $variableLessVarTags = []; + foreach ($varTags as $name => $varTag) { + if (is_int($name)) { + $variableLessVarTags[] = $varTag; + continue; + } + + if (!$scope->hasVariableType($name)->no()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Variable $%s in PHPDoc tag @var does not exist.', $name)) + ->identifier('varTag.variableNotFound') + ->build(); + } + + if (count($variableLessVarTags) !== 1 || $defaultExpr === null) { + if (count($variableLessVarTags) > 0) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @var does not specify variable name.') + ->identifier('varTag.noVariable') + ->build(); + } + } + + return $errors; + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processGlobal(Scope $scope, Node\Stmt\Global_ $node, array $varTags): array + { + $variableNames = []; + foreach ($node->vars as $var) { + if (!$var instanceof Expr\Variable) { + continue; + } + if (!is_string($var->name)) { + continue; + } + + $variableNames[$var->name] = true; + } + + $errors = []; + foreach (array_keys($varTags) as $name) { + if (is_int($name)) { + if (count($variableNames) === 1) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + 'PHPDoc tag @var above multiple global variables does not specify variable name.', + )->identifier('varTag.noVariable')->build(); + continue; + } + + if (isset($variableNames[$name])) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variable $%s in PHPDoc tag @var does not match any global variable: %s', + $name, + implode(', ', array_map(static fn (string $name): string => sprintf('$%s', $name), array_keys($variableNames))), + ))->identifier('varTag.differentVariable')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Playground/FunctionNeverRule.php b/src/Rules/Playground/FunctionNeverRule.php new file mode 100644 index 00000000..65120a5a --- /dev/null +++ b/src/Rules/Playground/FunctionNeverRule.php @@ -0,0 +1,52 @@ + + */ +final class FunctionNeverRule implements Rule +{ + + public function __construct(private NeverRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($node->getReturnStatements()) > 0) { + return []; + } + + $function = $node->getFunctionReflection(); + + $returnType = $function->getReturnType(); + $helperResult = $this->helper->shouldReturnNever($node, $returnType); + if ($helperResult === false) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Function %s() always %s, it should have return type "never".', + $function->getName(), + count($helperResult) === 0 ? 'throws an exception' : 'terminates script execution', + ))->identifier('phpstanPlayground.never')->build(), + ]; + } + +} diff --git a/src/Rules/Playground/MethodNeverRule.php b/src/Rules/Playground/MethodNeverRule.php new file mode 100644 index 00000000..6185e9e6 --- /dev/null +++ b/src/Rules/Playground/MethodNeverRule.php @@ -0,0 +1,53 @@ + + */ +final class MethodNeverRule implements Rule +{ + + public function __construct(private NeverRuleHelper $helper) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($node->getReturnStatements()) > 0) { + return []; + } + + $method = $node->getMethodReflection(); + + $returnType = $method->getReturnType(); + $helperResult = $this->helper->shouldReturnNever($node, $returnType); + if ($helperResult === false) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() always %s, it should have return type "never".', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + count($helperResult) === 0 ? 'throws an exception' : 'terminates script execution', + ))->identifier('phpstanPlayground.never')->build(), + ]; + } + +} diff --git a/src/Rules/Playground/NeverRuleHelper.php b/src/Rules/Playground/NeverRuleHelper.php new file mode 100644 index 00000000..25670a48 --- /dev/null +++ b/src/Rules/Playground/NeverRuleHelper.php @@ -0,0 +1,50 @@ +|false + */ + public function shouldReturnNever(ReturnStatementsNode $node, Type $returnType): array|false + { + if ($returnType instanceof NeverType && $returnType->isExplicit()) { + return false; + } + + if ($node->isGenerator()) { + return false; + } + + $other = []; + foreach ($node->getExecutionEnds() as $executionEnd) { + if ($executionEnd->getStatementResult()->isAlwaysTerminating()) { + $executionEndNode = $executionEnd->getNode(); + if (!$executionEndNode instanceof Node\Stmt\Expression) { + $other[] = $executionEnd->getNode(); + continue; + } + + if ($executionEndNode->expr instanceof Node\Expr\Throw_) { + continue; + } + + $other[] = $executionEnd->getNode(); + continue; + } + + return false; + } + + return $other; + } + +} diff --git a/src/Rules/Playground/NoPhpCodeRule.php b/src/Rules/Playground/NoPhpCodeRule.php new file mode 100644 index 00000000..e34fce83 --- /dev/null +++ b/src/Rules/Playground/NoPhpCodeRule.php @@ -0,0 +1,42 @@ + + */ +final class NoPhpCodeRule implements Rule +{ + + public function getNodeType(): string + { + return FileNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($node->getNodes()) !== 1) { + return []; + } + + $html = $node->getNodes()[0]; + if (!$html instanceof Node\Stmt\InlineHTML) { + return []; + } + + return [ + RuleErrorBuilder::message('The example does not contain any PHP code. Did you forget the opening identifier('phpstanPlayground.noPhp') + ->build(), + ]; + } + +} diff --git a/src/Rules/Playground/NotAnalysedTraitRule.php b/src/Rules/Playground/NotAnalysedTraitRule.php new file mode 100644 index 00000000..b4f6860d --- /dev/null +++ b/src/Rules/Playground/NotAnalysedTraitRule.php @@ -0,0 +1,63 @@ + + */ +final class NotAnalysedTraitRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitDeclarationData = $node->get(TraitDeclarationCollector::class); + $traitUseData = $node->get(TraitUseCollector::class); + + $declaredTraits = []; + foreach ($traitDeclarationData as $file => $declaration) { + foreach ($declaration as [$name, $line]) { + $declaredTraits[strtolower($name)] = [$file, $name, $line]; + } + } + + foreach ($traitUseData as $usedNamesData) { + foreach ($usedNamesData as $usedNames) { + foreach ($usedNames as $usedName) { + unset($declaredTraits[strtolower($usedName)]); + } + } + } + + $errors = []; + foreach ($declaredTraits as [$file, $name, $line]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Trait %s is used zero times and is not analysed.', + $name, + )) + ->identifier('phpstanPlayground.traitUnused') + ->file($file) + ->line($line) + ->tip('See: https://phpstan.org/blog/how-phpstan-analyses-traits') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Playground/PromoteParameterRule.php b/src/Rules/Playground/PromoteParameterRule.php new file mode 100644 index 00000000..fed4371e --- /dev/null +++ b/src/Rules/Playground/PromoteParameterRule.php @@ -0,0 +1,62 @@ + + */ +final class PromoteParameterRule implements Rule +{ + + /** + * @param Rule $rule + * @param class-string $nodeType + */ + public function __construct( + private Rule $rule, + private string $nodeType, + private bool $parameterValue, + private string $parameterName, + ) + { + } + + public function getNodeType(): string + { + return $this->nodeType; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($this->parameterValue) { + return []; + } + + if ($this->nodeType !== $this->rule->getNodeType()) { + return []; + } + + $errors = []; + foreach ($this->rule->processNode($node, $scope) as $error) { + $builder = RuleErrorBuilder::message($error->getMessage()) + ->identifier('phpstanPlayground.configParameter') + ->tip(sprintf('This error would be reported if the %s: true parameter was enabled in your %%configurationFile%%.', $this->parameterName)); + if ($error instanceof LineRuleError) { + $builder->line($error->getLine()); + } + $errors[] = $builder->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Playground/StaticVarWithoutTypeRule.php b/src/Rules/Playground/StaticVarWithoutTypeRule.php new file mode 100644 index 00000000..489daead --- /dev/null +++ b/src/Rules/Playground/StaticVarWithoutTypeRule.php @@ -0,0 +1,82 @@ + + */ +final class StaticVarWithoutTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Static_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + $ruleError = RuleErrorBuilder::message('Static variable needs to be typed with PHPDoc @var tag.') + ->identifier('phpstanPlayground.staticWithoutType') + ->build(); + if ($docComment === null) { + return [$ruleError]; + } + $variableNames = []; + foreach ($node->vars as $var) { + if (!is_string($var->var->name)) { + throw new ShouldNotHappenException(); + } + + $variableNames[] = $var->var->name; + } + + $function = $scope->getFunction(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $function !== null ? $function->getName() : null, + $docComment->getText(), + ); + $varTags = []; + foreach ($resolvedPhpDoc->getVarTags() as $key => $varTag) { + $varTags[$key] = $varTag; + } + + if (count($varTags) === 0) { + return [$ruleError]; + } + + if (count($variableNames) === 1 && count($varTags) === 1 && isset($varTags[0])) { + return []; + } + + foreach ($variableNames as $variableName) { + if (isset($varTags[$variableName])) { + continue; + } + + return [$ruleError]; + } + + return []; + } + +} diff --git a/src/Rules/Properties/AccessPrivatePropertyThroughStaticRule.php b/src/Rules/Properties/AccessPrivatePropertyThroughStaticRule.php new file mode 100644 index 00000000..94139654 --- /dev/null +++ b/src/Rules/Properties/AccessPrivatePropertyThroughStaticRule.php @@ -0,0 +1,65 @@ + + */ +final class AccessPrivatePropertyThroughStaticRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\StaticPropertyFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\VarLikeIdentifier) { + return []; + } + if (!$node->class instanceof Name) { + return []; + } + + $propertyName = $node->name->name; + $className = $node->class; + if ($className->toLowerString() !== 'static') { + return []; + } + + $classType = $scope->resolveTypeByName($className); + if (!$classType->hasProperty($propertyName)->yes()) { + return []; + } + + $property = $classType->getProperty($propertyName, $scope); + if (!$property->isPrivate()) { + return []; + } + if (!$property->isStatic()) { + return []; + } + + if ($scope->isInClass() && $scope->getClassReflection()->isFinal()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unsafe access to private property %s::$%s through static::.', + $property->getDeclaringClass()->getDisplayName(), + $propertyName, + ))->identifier('staticClassAccess.privateProperty')->build(), + ]; + } + +} diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php new file mode 100644 index 00000000..f845cfe6 --- /dev/null +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -0,0 +1,202 @@ + + */ + public function check(PropertyFetch $node, Scope $scope, bool $write): array + { + if ($node->name instanceof Identifier) { + $names = [$node->name->name]; + } else { + $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); + } + + $errors = []; + foreach ($names as $name) { + $errors = array_merge($errors, $this->processSingleProperty($scope, $node, $name, $write)); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleProperty(Scope $scope, PropertyFetch $node, string $name, bool $write): array + { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->var), + sprintf('Access to property $%s on an unknown class %%s.', SprintfHelper::escapeFormatString($name)), + static fn (Type $type): bool => $type->canAccessProperties()->yes() && $type->hasProperty($name)->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + if ($scope->isInExpressionAssign($node)) { + return []; + } + + $typeForDescribe = $type; + if ($type instanceof StaticType) { + $typeForDescribe = $type->getStaticObjectType(); + } + + if ($type->canAccessProperties()->no() || $type->canAccessProperties()->maybe() && !$scope->isUndefinedExpressionAllowed($node)) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot access property $%s on %s.', + $name, + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + ))->identifier('property.nonObject')->build(), + ]; + } + + $has = $type->hasProperty($name); + if (!$has->no() && $this->canAccessUndefinedProperties($scope, $node)) { + return []; + } + + if (!$has->yes()) { + if ($scope->hasExpressionType($node)->yes()) { + return []; + } + + $classNames = $type->getObjectClassNames(); + if (!$this->reportMagicProperties) { + foreach ($classNames as $className) { + if (!$this->reflectionProvider->hasClass($className)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if ( + $classReflection->hasNativeMethod('__get') + || $classReflection->hasNativeMethod('__set') + ) { + return []; + } + } + } + + if (count($classNames) === 1) { + $propertyClassReflection = $this->reflectionProvider->getClass($classNames[0]); + $parentClassReflection = $propertyClassReflection->getParentClass(); + while ($parentClassReflection !== null) { + if ($parentClassReflection->hasProperty($name)) { + if ($write) { + if ($scope->canWriteProperty($parentClassReflection->getProperty($name, $scope))) { + return []; + } + } elseif ($scope->canReadProperty($parentClassReflection->getProperty($name, $scope))) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Access to private property $%s of parent class %s.', + $name, + $parentClassReflection->getDisplayName(), + ))->identifier('property.private')->build(), + ]; + } + + $parentClassReflection = $parentClassReflection->getParentClass(); + } + } + + $ruleErrorBuilder = RuleErrorBuilder::message(sprintf( + 'Access to an undefined property %s::$%s.', + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + $name, + ))->identifier('property.notFound'); + if ($typeResult->getTip() !== null) { + $ruleErrorBuilder->tip($typeResult->getTip()); + } else { + $ruleErrorBuilder->tip('Learn more: https://phpstan.org/blog/solving-phpstan-access-to-undefined-property'); + } + + return [ + $ruleErrorBuilder->build(), + ]; + } + + $propertyReflection = $type->getProperty($name, $scope); + if ($write) { + if ($scope->canWriteProperty($propertyReflection)) { + return []; + } + } elseif ($scope->canReadProperty($propertyReflection)) { + return []; + } + + if ( + !$this->phpVersion->supportsAsymmetricVisibility() + || !$write + || (!$propertyReflection->isPrivateSet() && !$propertyReflection->isProtectedSet()) + ) { + return [ + RuleErrorBuilder::message(sprintf( + 'Access to %s property %s::$%s.', + $propertyReflection->isPrivate() ? 'private' : 'protected', + $type->describe(VerbosityLevel::typeOnly()), + $name, + ))->identifier(sprintf('property.%s', $propertyReflection->isPrivate() ? 'private' : 'protected'))->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Assign to %s property %s::$%s.', + $propertyReflection->isPrivateSet() ? 'private(set)' : 'protected(set)', + $type->describe(VerbosityLevel::typeOnly()), + $name, + ))->identifier(sprintf('assign.property%s', $propertyReflection->isPrivateSet() ? 'PrivateSet' : 'ProtectedSet'))->build(), + ]; + } + + private function canAccessUndefinedProperties(Scope $scope, Expr $node): bool + { + return $scope->isUndefinedExpressionAllowed($node) && !$this->checkDynamicProperties; + } + +} diff --git a/src/Rules/Properties/AccessPropertiesInAssignRule.php b/src/Rules/Properties/AccessPropertiesInAssignRule.php new file mode 100644 index 00000000..342e1a50 --- /dev/null +++ b/src/Rules/Properties/AccessPropertiesInAssignRule.php @@ -0,0 +1,39 @@ + + */ +final class AccessPropertiesInAssignRule implements Rule +{ + + public function __construct(private AccessPropertiesCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getPropertyFetch() instanceof Node\Expr\PropertyFetch) { + return []; + } + + if ($node->isAssignOp()) { + return []; + } + + return $this->check->check($node->getPropertyFetch(), $scope, true); + } + +} diff --git a/src/Rules/Properties/AccessPropertiesRule.php b/src/Rules/Properties/AccessPropertiesRule.php new file mode 100644 index 00000000..a4ff3463 --- /dev/null +++ b/src/Rules/Properties/AccessPropertiesRule.php @@ -0,0 +1,31 @@ + + */ +final class AccessPropertiesRule implements Rule +{ + + public function __construct(private AccessPropertiesCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check($node, $scope, false); + } + +} diff --git a/src/Rules/Properties/AccessStaticPropertiesInAssignRule.php b/src/Rules/Properties/AccessStaticPropertiesInAssignRule.php new file mode 100644 index 00000000..1edb631a --- /dev/null +++ b/src/Rules/Properties/AccessStaticPropertiesInAssignRule.php @@ -0,0 +1,39 @@ + + */ +final class AccessStaticPropertiesInAssignRule implements Rule +{ + + public function __construct(private AccessStaticPropertiesRule $accessStaticPropertiesRule) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getPropertyFetch() instanceof Node\Expr\StaticPropertyFetch) { + return []; + } + + if ($node->isAssignOp()) { + return []; + } + + return $this->accessStaticPropertiesRule->processNode($node->getPropertyFetch(), $scope); + } + +} diff --git a/src/Rules/Properties/AccessStaticPropertiesRule.php b/src/Rules/Properties/AccessStaticPropertiesRule.php new file mode 100644 index 00000000..5b669795 --- /dev/null +++ b/src/Rules/Properties/AccessStaticPropertiesRule.php @@ -0,0 +1,245 @@ + + */ +final class AccessStaticPropertiesRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuleLevelHelper $ruleLevelHelper, + private ClassNameCheck $classCheck, + ) + { + } + + public function getNodeType(): string + { + return StaticPropertyFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->name instanceof Node\VarLikeIdentifier) { + $names = [$node->name->name]; + } else { + $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); + } + + $errors = []; + foreach ($names as $name) { + $errors = array_merge($errors, $this->processSingleProperty($scope, $node, $name)); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleProperty(Scope $scope, StaticPropertyFetch $node, string $name): array + { + $messages = []; + if ($node->class instanceof Name) { + $class = (string) $node->class; + $lowercasedClass = strtolower($class); + if (in_array($lowercasedClass, ['self', 'static'], true)) { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Accessing %s::$%s outside of class scope.', + $class, + $name, + ))->identifier(sprintf('outOfClass.%s', $lowercasedClass))->build(), + ]; + } + $classType = $scope->resolveTypeByName($node->class); + } elseif ($lowercasedClass === 'parent') { + if (!$scope->isInClass()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Accessing %s::$%s outside of class scope.', + $class, + $name, + ))->identifier('outOfClass.parent')->build(), + ]; + } + if ($scope->getClassReflection()->getParentClass() === null) { + return [ + RuleErrorBuilder::message(sprintf( + '%s::%s() accesses parent::$%s but %s does not extend any class.', + $scope->getClassReflection()->getDisplayName(), + $scope->getFunctionName(), + $name, + $scope->getClassReflection()->getDisplayName(), + ))->identifier('class.noParent')->build(), + ]; + } + + $classType = $scope->resolveTypeByName($node->class); + } else { + if (!$this->reflectionProvider->hasClass($class)) { + if ($scope->isInClassExists($class)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Access to static property $%s on an unknown class %s.', + $name, + $class, + )) + ->discoveringSymbolsTip() + ->identifier('class.notFound') + ->build(), + ]; + } + + $messages = $this->classCheck->checkClassNames([new ClassNameNodePair($class, $node->class)]); + + $classType = $scope->resolveTypeByName($node->class); + } + } else { + $classTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->class), + sprintf('Access to static property $%s on an unknown class %%s.', SprintfHelper::escapeFormatString($name)), + static fn (Type $type): bool => $type->canAccessProperties()->yes() && $type->hasProperty($name)->yes(), + ); + $classType = $classTypeResult->getType(); + if ($classType instanceof ErrorType) { + return $classTypeResult->getUnknownClassErrors(); + } + } + + if ($classType->isString()->yes()) { + return []; + } + + $typeForDescribe = $classType; + if ($classType instanceof ThisType) { + $typeForDescribe = $classType->getStaticObjectType(); + } + $classType = TypeCombinator::remove($classType, new StringType()); + + if ($scope->isInExpressionAssign($node)) { + return []; + } + + if ($classType->canAccessProperties()->no() || $classType->canAccessProperties()->maybe() && !$scope->isUndefinedExpressionAllowed($node)) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Cannot access static property $%s on %s.', + $name, + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + ))->identifier('staticProperty.nonObject')->build(), + ]); + } + + $has = $classType->hasProperty($name); + if (!$has->no() && $scope->isUndefinedExpressionAllowed($node)) { + return []; + } + + if (!$has->yes()) { + if ($scope->hasExpressionType($node)->yes()) { + return $messages; + } + + $classNames = $classType->getObjectClassNames(); + if (count($classNames) === 1) { + $propertyClassReflection = $this->reflectionProvider->getClass($classNames[0]); + $parentClassReflection = $propertyClassReflection->getParentClass(); + + while ($parentClassReflection !== null) { + if ($parentClassReflection->hasProperty($name)) { + if ($scope->canReadProperty($parentClassReflection->getProperty($name, $scope))) { + return []; + } + return [ + RuleErrorBuilder::message(sprintf( + 'Access to private static property $%s of parent class %s.', + $name, + $parentClassReflection->getDisplayName(), + ))->identifier('staticProperty.private')->build(), + ]; + } + + $parentClassReflection = $parentClassReflection->getParentClass(); + } + } + + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Access to an undefined static property %s::$%s.', + $typeForDescribe->describe(VerbosityLevel::typeOnly()), + $name, + ))->identifier('staticProperty.notFound')->build(), + ]); + } + + $property = $classType->getProperty($name, $scope); + if (!$property->isStatic()) { + $hasPropertyTypes = TypeUtils::getHasPropertyTypes($classType); + foreach ($hasPropertyTypes as $hasPropertyType) { + if ($hasPropertyType->getPropertyName() === $name) { + return []; + } + } + + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Static access to instance property %s::$%s.', + $property->getDeclaringClass()->getDisplayName(), + $name, + ))->identifier('property.staticAccess')->build(), + ]); + } + + if (!$scope->canReadProperty($property)) { + return array_merge($messages, [ + RuleErrorBuilder::message(sprintf( + 'Access to %s property $%s of class %s.', + $property->isPrivate() ? 'private' : 'protected', + $name, + $property->getDeclaringClass()->getDisplayName(), + ))->identifier(sprintf('staticProperty.%s', $property->isPrivate() ? 'private' : 'protected'))->build(), + ]); + } + + return $messages; + } + +} diff --git a/src/Rules/Properties/DefaultValueTypesAssignedToPropertiesRule.php b/src/Rules/Properties/DefaultValueTypesAssignedToPropertiesRule.php new file mode 100644 index 00000000..4ff0333d --- /dev/null +++ b/src/Rules/Properties/DefaultValueTypesAssignedToPropertiesRule.php @@ -0,0 +1,69 @@ + + */ +final class DefaultValueTypesAssignedToPropertiesRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $default = $node->getDefault(); + if ($default === null) { + return []; + } + + $classReflection = $node->getClassReflection(); + + $propertyReflection = $classReflection->getNativeProperty($node->getName()); + $propertyType = $propertyReflection->getWritableType(); + if (!$propertyReflection->hasNativeType()) { + if ($default instanceof Node\Expr\ConstFetch && $default->name->toLowerString() === 'null') { + return []; + } + } + $defaultValueType = $scope->getType($default); + $accepts = $this->ruleLevelHelper->accepts($propertyType, $defaultValueType, true); + if ($accepts->result) { + return []; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($propertyType, $defaultValueType); + + return [ + RuleErrorBuilder::message(sprintf( + '%s %s::$%s (%s) does not accept default value of type %s.', + $node->isStatic() ? 'Static property' : 'Property', + $classReflection->getDisplayName(), + $node->getName(), + $propertyType->describe($verbosityLevel), + $defaultValueType->describe($verbosityLevel), + )) + ->identifier('property.defaultValue') + ->acceptsReasonsTip($accepts->reasons) + ->build(), + ]; + } + +} diff --git a/src/Rules/Properties/DirectReadWritePropertiesExtensionProvider.php b/src/Rules/Properties/DirectReadWritePropertiesExtensionProvider.php new file mode 100644 index 00000000..28fc2815 --- /dev/null +++ b/src/Rules/Properties/DirectReadWritePropertiesExtensionProvider.php @@ -0,0 +1,24 @@ +extensions; + } + +} diff --git a/src/Rules/Properties/ExistingClassesInPropertiesRule.php b/src/Rules/Properties/ExistingClassesInPropertiesRule.php new file mode 100644 index 00000000..db179208 --- /dev/null +++ b/src/Rules/Properties/ExistingClassesInPropertiesRule.php @@ -0,0 +1,98 @@ + + */ +final class ExistingClassesInPropertiesRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClassNameCheck $classCheck, + private UnresolvableTypeHelper $unresolvableTypeHelper, + private PhpVersion $phpVersion, + private bool $checkClassCaseSensitivity, + private bool $checkThisOnly, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyReflection = $node->getClassReflection()->getNativeProperty($node->getName()); + if ($this->checkThisOnly) { + $referencedClasses = $propertyReflection->getNativeType()->getReferencedClasses(); + } else { + $referencedClasses = array_merge( + $propertyReflection->getNativeType()->getReferencedClasses(), + $propertyReflection->getPhpDocType()->getReferencedClasses(), + ); + } + + $errors = []; + foreach ($referencedClasses as $referencedClass) { + if ($this->reflectionProvider->hasClass($referencedClass)) { + if ($this->reflectionProvider->getClass($referencedClass)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s has invalid type %s.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $referencedClass, + ))->identifier('property.trait')->build(); + } + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s has unknown class %s as its type.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $referencedClass, + ))->identifier('class.notFound')->discoveringSymbolsTip()->build(); + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames( + array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $node), $referencedClasses), + $this->checkClassCaseSensitivity, + ), + ); + + if ( + $this->phpVersion->supportsPureIntersectionTypes() + && $this->unresolvableTypeHelper->containsUnresolvableType($propertyReflection->getNativeType()) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s has unresolvable native type.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.unresolvableNativeType')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php b/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php new file mode 100644 index 00000000..61e714bf --- /dev/null +++ b/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php @@ -0,0 +1,88 @@ + + */ +final class ExistingClassesInPropertyHookTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + $className = SprintfHelper::escapeFormatString($node->getClassReflection()->getDisplayName()); + $hookName = $hookReflection->getPropertyHookName(); + $propertyName = SprintfHelper::escapeFormatString($hookReflection->getHookedPropertyName()); + + $originalHookNode = $node->getOriginalNode(); + if ($hookReflection->getPropertyHookName() === 'set' && $originalHookNode->params === []) { + $originalHookNode = clone $originalHookNode; + $originalHookNode->params = [ + new Node\Param(new Variable('value'), null, null), + ]; + } + + return $this->check->checkClassMethod( + $hookReflection, + $originalHookNode, + sprintf( + 'Parameter $%%s of %s hook for property %s::$%s has invalid type %%s.', + $hookName, + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has invalid return type %%s.', + ucfirst($hookName), + $className, + $propertyName, + ), + sprintf('%s hook for property %s::$%s uses native union types but they\'re supported only on PHP 8.0 and later.', $hookName, $className, $propertyName), + sprintf('Template type %%s of %s hook for property %s::$%s is not referenced in a parameter.', $hookName, $className, $propertyName), + sprintf( + 'Parameter $%%s of %s hook for property %s::$%s has unresolvable native type.', + $hookName, + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has unresolvable native return type.', + ucfirst($hookName), + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has invalid @phpstan-self-out type %%s.', + ucfirst($hookName), + $className, + $propertyName, + ), + ); + } + +} diff --git a/src/Rules/Properties/FoundPropertyReflection.php b/src/Rules/Properties/FoundPropertyReflection.php new file mode 100644 index 00000000..49228105 --- /dev/null +++ b/src/Rules/Properties/FoundPropertyReflection.php @@ -0,0 +1,182 @@ +scope; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->originalPropertyReflection->getDeclaringClass(); + } + + public function getName(): string + { + return $this->propertyName; + } + + public function isStatic(): bool + { + return $this->originalPropertyReflection->isStatic(); + } + + public function isPrivate(): bool + { + return $this->originalPropertyReflection->isPrivate(); + } + + public function isPublic(): bool + { + return $this->originalPropertyReflection->isPublic(); + } + + public function getDocComment(): ?string + { + return $this->originalPropertyReflection->getDocComment(); + } + + public function hasPhpDocType(): bool + { + return $this->originalPropertyReflection->hasPhpDocType(); + } + + public function getPhpDocType(): Type + { + return $this->originalPropertyReflection->getPhpDocType(); + } + + public function hasNativeType(): bool + { + return $this->originalPropertyReflection->hasNativeType(); + } + + public function getNativeType(): Type + { + return $this->originalPropertyReflection->getNativeType(); + } + + public function getReadableType(): Type + { + return $this->readableType; + } + + public function getWritableType(): Type + { + return $this->writableType; + } + + public function canChangeTypeAfterAssignment(): bool + { + return $this->originalPropertyReflection->canChangeTypeAfterAssignment(); + } + + public function isReadable(): bool + { + return $this->originalPropertyReflection->isReadable(); + } + + public function isWritable(): bool + { + return $this->originalPropertyReflection->isWritable(); + } + + public function isDeprecated(): TrinaryLogic + { + return $this->originalPropertyReflection->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->originalPropertyReflection->getDeprecatedDescription(); + } + + public function isInternal(): TrinaryLogic + { + return $this->originalPropertyReflection->isInternal(); + } + + public function isNative(): bool + { + return $this->getNativeReflection() !== null; + } + + public function getNativeReflection(): ?PhpPropertyReflection + { + $reflection = $this->originalPropertyReflection; + while ($reflection instanceof WrapperPropertyReflection) { + $reflection = $reflection->getOriginalReflection(); + } + + if (!$reflection instanceof PhpPropertyReflection) { + return null; + } + + return $reflection; + } + + public function isAbstract(): TrinaryLogic + { + return $this->originalPropertyReflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->originalPropertyReflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->originalPropertyReflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->originalPropertyReflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return $this->originalPropertyReflection->getHook($hookType); + } + + public function isProtectedSet(): bool + { + return $this->originalPropertyReflection->isProtectedSet(); + } + + public function isPrivateSet(): bool + { + return $this->originalPropertyReflection->isPrivateSet(); + } + + public function getAttributes(): array + { + return $this->originalPropertyReflection->getAttributes(); + } + +} diff --git a/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php b/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php new file mode 100644 index 00000000..413133e1 --- /dev/null +++ b/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php @@ -0,0 +1,129 @@ + + */ +final class GetNonVirtualPropertyHookReadRule implements Rule +{ + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $reads = []; + $classReflection = $node->getClassReflection(); + foreach ($node->getPropertyUsages() as $propertyUsage) { + if (!$propertyUsage instanceof PropertyRead) { + continue; + } + + $fetch = $propertyUsage->getFetch(); + if (!$fetch instanceof Node\Expr\PropertyFetch) { + continue; + } + + if (!$fetch->name instanceof Node\Identifier) { + continue; + } + + $propertyName = $fetch->name->toString(); + if (!$fetch->var instanceof Node\Expr\Variable || $fetch->var->name !== 'this') { + continue; + } + + $usageScope = $propertyUsage->getScope(); + $inFunction = $usageScope->getFunction(); + if (!$inFunction instanceof PhpMethodFromParserNodeReflection) { + continue; + } + + if (!$inFunction->isPropertyHook()) { + continue; + } + + if ($inFunction->getPropertyHookName() !== 'get') { + continue; + } + + if ($propertyName !== $inFunction->getHookedPropertyName()) { + continue; + } + + $reads[$propertyName] = true; + } + + $errors = []; + foreach ($node->getProperties() as $propertyNode) { + $hasGetHook = false; + foreach ($propertyNode->getHooks() as $hook) { + if ($hook->name->toLowerString() !== 'get') { + continue; + } + + $hasGetHook = true; + break; + } + + if (!$hasGetHook) { + continue; + } + + if (array_key_exists($propertyNode->getName(), $reads)) { + continue; + } + + $propertyReflection = $classReflection->getNativeProperty($propertyNode->getName()); + if ($propertyReflection->isVirtual()->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Get hook for non-virtual property %s::$%s does not read its value.', + $classReflection->getDisplayName(), + $propertyNode->getName(), + )) + ->line($this->getGetHookLine($propertyNode)) + ->identifier('propertyGetHook.noRead') + ->build(); + } + + return $errors; + } + + private function getGetHookLine(ClassPropertyNode $propertyNode): int + { + $getHook = null; + foreach ($propertyNode->getHooks() as $hook) { + if ($hook->name->toLowerString() !== 'get') { + continue; + } + + $getHook = $hook; + break; + } + + if ($getHook === null) { + return $propertyNode->getStartLine(); + } + + return $getHook->getStartLine(); + } + +} diff --git a/src/Rules/Properties/InvalidCallablePropertyTypeRule.php b/src/Rules/Properties/InvalidCallablePropertyTypeRule.php new file mode 100644 index 00000000..2e2ecdbc --- /dev/null +++ b/src/Rules/Properties/InvalidCallablePropertyTypeRule.php @@ -0,0 +1,66 @@ + + */ +final class InvalidCallablePropertyTypeRule implements Rule +{ + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $propertyReflection = $classReflection->getNativeProperty($node->getName()); + + if (!$propertyReflection->hasNativeType()) { + return []; + } + + $nativeType = $propertyReflection->getNativeType(); + $callableTypes = []; + + TypeTraverser::map($nativeType, static function (Type $type, callable $traverse) use (&$callableTypes): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof CallableType) { + $callableTypes[] = $type; + } + + return $type; + }); + + if ($callableTypes === []) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Property %s::$%s cannot have callable in its type declaration.', + $classReflection->getDisplayName(), + $node->getName(), + ))->identifier('property.callableType')->nonIgnorable()->build(), + ]; + } + +} diff --git a/src/Rules/Properties/LazyReadWritePropertiesExtensionProvider.php b/src/Rules/Properties/LazyReadWritePropertiesExtensionProvider.php new file mode 100644 index 00000000..c167db63 --- /dev/null +++ b/src/Rules/Properties/LazyReadWritePropertiesExtensionProvider.php @@ -0,0 +1,27 @@ +extensions === null) { + $this->extensions = $this->container->getServicesByTag(ReadWritePropertiesExtensionProvider::EXTENSION_TAG); + } + + return $this->extensions; + } + +} diff --git a/src/Rules/Properties/MissingPropertyTypehintRule.php b/src/Rules/Properties/MissingPropertyTypehintRule.php new file mode 100644 index 00000000..e9850b33 --- /dev/null +++ b/src/Rules/Properties/MissingPropertyTypehintRule.php @@ -0,0 +1,89 @@ + + */ +final class MissingPropertyTypehintRule implements Rule +{ + + public function __construct(private MissingTypehintCheck $missingTypehintCheck) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyReflection = $node->getClassReflection()->getNativeProperty($node->getName()); + + if ($propertyReflection->isPromoted()) { + return []; + } + + $propertyType = $propertyReflection->getReadableType(); + + if ($propertyType instanceof MixedType && !$propertyType->isExplicitMixed()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Property %s::$%s has no type specified.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('missingType.property')->build(), + ]; + } + + $messages = []; + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($propertyType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $messages[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s type has no value type specified in iterable type %s.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($propertyType) as [$name, $genericTypeNames]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s with generic %s does not specify its types: %s', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($propertyType) as $callableType) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s type has no signature specified for %s.', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRule.php b/src/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRule.php new file mode 100644 index 00000000..5fd4db8e --- /dev/null +++ b/src/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRule.php @@ -0,0 +1,80 @@ + + */ +final class MissingReadOnlyByPhpDocPropertyAssignRule implements Rule +{ + + public function __construct( + private ConstructorsHelper $constructorsHelper, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + [$properties, $prematureAccess, $additionalAssigns] = $node->getUninitializedProperties($scope, $this->constructorsHelper->getConstructors($classReflection)); + + $errors = []; + foreach ($properties as $propertyName => $propertyNode) { + if (!$propertyNode->isReadOnlyByPhpDoc() || $propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Class %s has an uninitialized @readonly property $%s. Assign it in the constructor.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($propertyNode->getStartLine()) + ->identifier('property.uninitializedReadonlyByPhpDoc') + ->build(); + } + + foreach ($prematureAccess as [$propertyName, $line, $propertyNode, $file, $fileDescription]) { + if (!$propertyNode->isReadOnlyByPhpDoc() || $propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Access to an uninitialized @readonly property %s::$%s.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->identifier('property.uninitializedReadonlyByPhpDoc') + ->line($line) + ->file($file, $fileDescription) + ->build(); + } + + foreach ($additionalAssigns as [$propertyName, $line, $propertyNode]) { + if (!$propertyNode->isReadOnlyByPhpDoc() || $propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + '@readonly property %s::$%s is already assigned.', + $classReflection->getDisplayName(), + $propertyName, + ))->identifier('assign.readOnlyPropertyByPhpDoc')->line($line)->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/MissingReadOnlyPropertyAssignRule.php b/src/Rules/Properties/MissingReadOnlyPropertyAssignRule.php new file mode 100644 index 00000000..d70a3521 --- /dev/null +++ b/src/Rules/Properties/MissingReadOnlyPropertyAssignRule.php @@ -0,0 +1,83 @@ + + */ +final class MissingReadOnlyPropertyAssignRule implements Rule +{ + + public function __construct( + private ConstructorsHelper $constructorsHelper, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + [$properties, $prematureAccess, $additionalAssigns] = $node->getUninitializedProperties($scope, $this->constructorsHelper->getConstructors($classReflection)); + + $errors = []; + foreach ($properties as $propertyName => $propertyNode) { + if (!$propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Class %s has an uninitialized readonly property $%s. Assign it in the constructor.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($propertyNode->getStartLine()) + ->identifier('property.uninitializedReadonly') + ->build(); + } + + foreach ($prematureAccess as [$propertyName, $line, $propertyNode, $file, $fileDescription]) { + if (!$propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Access to an uninitialized readonly property %s::$%s.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($line) + ->file($file, $fileDescription) + ->identifier('property.uninitializedReadonly') + ->build(); + } + + foreach ($additionalAssigns as [$propertyName, $line, $propertyNode]) { + if (!$propertyNode->isReadOnly()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Readonly property %s::$%s is already assigned.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($line) + ->identifier('assign.readOnlyProperty') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/NullsafePropertyFetchRule.php b/src/Rules/Properties/NullsafePropertyFetchRule.php new file mode 100644 index 00000000..4cdf51d4 --- /dev/null +++ b/src/Rules/Properties/NullsafePropertyFetchRule.php @@ -0,0 +1,46 @@ + + */ +final class NullsafePropertyFetchRule implements Rule +{ + + public function __construct() + { + } + + public function getNodeType(): string + { + return Node\Expr\NullsafePropertyFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $calledOnType = $scope->getType($node->var); + if (!$calledOnType->isNull()->no()) { + return []; + } + + if ($scope->isUndefinedExpressionAllowed($node)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Using nullsafe property access on non-nullable type %s. Use -> instead.', $calledOnType->describe(VerbosityLevel::typeOnly()))) + ->identifier('nullsafe.neverNull') + ->build(), + ]; + } + +} diff --git a/src/Rules/Properties/OverridingPropertyRule.php b/src/Rules/Properties/OverridingPropertyRule.php new file mode 100644 index 00000000..7e30b3f3 --- /dev/null +++ b/src/Rules/Properties/OverridingPropertyRule.php @@ -0,0 +1,346 @@ + + */ +final class OverridingPropertyRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + private bool $checkPhpDocMethodSignatures, + private bool $reportMaybes, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $prototype = $this->findPrototype($classReflection, $node->getName()); + if ($prototype === null) { + return []; + } + + $errors = []; + if ($prototype->isStatic()) { + if (!$node->isStatic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Non-static property %s::$%s overrides static property %s::$%s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.nonStatic')->nonIgnorable()->build(); + } + } elseif ($node->isStatic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Static property %s::$%s overrides non-static property %s::$%s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.static')->nonIgnorable()->build(); + } + + if ($prototype->isReadOnly()) { + if (!$node->isReadOnly()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Readwrite property %s::$%s overrides readonly property %s::$%s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.readWrite')->nonIgnorable()->build(); + } + } elseif ($node->isReadOnly()) { + if ( + !$this->phpVersion->supportsPropertyHooks() + || $prototype->isWritable() + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Readonly property %s::$%s overrides readwrite property %s::$%s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.readOnly')->nonIgnorable()->build(); + } + } + + $propertyReflection = $classReflection->getNativeProperty($node->getName()); + if ($this->phpVersion->supportsPropertyHooks()) { + if ($prototype->isReadable()) { + if (!$propertyReflection->isReadable()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s overriding readable property %s::$%s also has to be readable.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.notReadable')->nonIgnorable()->build(); + } + } + if ($prototype->isWritable()) { + if (!$propertyReflection->isWritable()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s overriding writable property %s::$%s also has to be writable.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.notWritable')->nonIgnorable()->build(); + } + } + } + + if ($prototype->isPublic()) { + if (!$node->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s property %s::$%s overriding public property %s::$%s should also be public.', + $node->isPrivate() ? 'Private' : 'Protected', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.visibility')->nonIgnorable()->build(); + } + } elseif ($node->isPrivate()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Private property %s::$%s overriding protected property %s::$%s should be protected or public.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.visibility')->nonIgnorable()->build(); + } + + if ($prototype->isFinal()->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s overrides final property %s::$%s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.parentPropertyFinal') + ->nonIgnorable() + ->build(); + } + + $typeErrors = []; + $nativeType = $node->getNativeType(); + if ($prototype->hasNativeType()) { + if ($nativeType === null) { + $typeErrors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s overriding property %s::$%s (%s) should also have native type %s.', + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + $prototype->getNativeType()->describe(VerbosityLevel::typeOnly()), + $prototype->getNativeType()->describe(VerbosityLevel::typeOnly()), + ))->identifier('property.missingNativeType')->nonIgnorable()->build(); + } else { + if (!$prototype->getNativeType()->equals($nativeType)) { + if ( + $this->phpVersion->supportsPropertyHooks() + && ($prototype->isVirtual()->yes() || $prototype->isAbstract()->yes()) + && (!$prototype->isReadable() || !$prototype->isWritable()) + ) { + if (!$prototype->isReadable()) { + if (!$nativeType->isSuperTypeOf($prototype->getNativeType())->yes()) { + $typeErrors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of property %s::$%s is not contravariant with type %s of overridden property %s::$%s.', + $nativeType->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getNativeType()->describe(VerbosityLevel::typeOnly()), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.nativeType')->nonIgnorable()->build(); + } + } elseif (!$prototype->getNativeType()->isSuperTypeOf($nativeType)->yes()) { + $typeErrors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of property %s::$%s is not covariant with type %s of overridden property %s::$%s.', + $nativeType->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getNativeType()->describe(VerbosityLevel::typeOnly()), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.nativeType')->nonIgnorable()->build(); + } + } else { + $typeErrors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of property %s::$%s is not the same as type %s of overridden property %s::$%s.', + $nativeType->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getNativeType()->describe(VerbosityLevel::typeOnly()), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.nativeType')->nonIgnorable()->build(); + } + } + } + } elseif ($nativeType !== null) { + $typeErrors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s (%s) overriding property %s::$%s should not have a native type.', + $classReflection->getDisplayName(), + $node->getName(), + $nativeType->describe(VerbosityLevel::typeOnly()), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.extraNativeType')->nonIgnorable()->build(); + } + + $errors = array_merge($errors, $typeErrors); + + if (!$this->checkPhpDocMethodSignatures) { + return $errors; + } + + if (count($typeErrors) > 0) { + return $errors; + } + + if ($prototype->getReadableType()->equals($propertyReflection->getReadableType())) { + return $errors; + } + + $verbosity = VerbosityLevel::getRecommendedLevelByType($prototype->getReadableType(), $propertyReflection->getReadableType()); + + if ( + $this->phpVersion->supportsPropertyHooks() + && ($prototype->isVirtual()->yes() || $prototype->isAbstract()->yes()) + && (!$prototype->isReadable() || !$prototype->isWritable()) + ) { + if (!$prototype->isReadable()) { + if (!$propertyReflection->getReadableType()->isSuperTypeOf($prototype->getReadableType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc type %s of property %s::$%s is not contravariant with PHPDoc type %s of overridden property %s::$%s.', + $propertyReflection->getReadableType()->describe($verbosity), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getReadableType()->describe($verbosity), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.phpDocType')->tip(sprintf( + "You can fix 3rd party PHPDoc types with stub files:\n %s", + 'https://phpstan.org/user-guide/stub-files', + ))->build(); + } + } elseif (!$prototype->getReadableType()->isSuperTypeOf($propertyReflection->getReadableType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc type %s of property %s::$%s is not covariant with PHPDoc type %s of overridden property %s::$%s.', + $propertyReflection->getReadableType()->describe($verbosity), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getReadableType()->describe($verbosity), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.phpDocType')->tip(sprintf( + "You can fix 3rd party PHPDoc types with stub files:\n %s", + 'https://phpstan.org/user-guide/stub-files', + ))->build(); + } + + return $errors; + } + + $isSuperType = $prototype->getReadableType()->isSuperTypeOf($propertyReflection->getReadableType()); + $canBeTurnedOffError = RuleErrorBuilder::message(sprintf( + 'PHPDoc type %s of property %s::$%s is not the same as PHPDoc type %s of overridden property %s::$%s.', + $propertyReflection->getReadableType()->describe($verbosity), + $classReflection->getDisplayName(), + $node->getName(), + $prototype->getReadableType()->describe($verbosity), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.phpDocType')->tip(sprintf( + "You can fix 3rd party PHPDoc types with stub files:\n %s\n This error can be turned off by setting\n %s", + 'https://phpstan.org/user-guide/stub-files', + 'reportMaybesInPropertyPhpDocTypes: false in your %configurationFile%.', + ))->build(); + $cannotBeTurnedOffError = RuleErrorBuilder::message(sprintf( + 'PHPDoc type %s of property %s::$%s is %s PHPDoc type %s of overridden property %s::$%s.', + $propertyReflection->getReadableType()->describe($verbosity), + $classReflection->getDisplayName(), + $node->getName(), + $this->reportMaybes ? 'not the same as' : 'not covariant with', + $prototype->getReadableType()->describe($verbosity), + $prototype->getDeclaringClass()->getDisplayName(), + $node->getName(), + ))->identifier('property.phpDocType')->tip(sprintf( + "You can fix 3rd party PHPDoc types with stub files:\n %s", + 'https://phpstan.org/user-guide/stub-files', + ))->build(); + if ($this->reportMaybes) { + if (!$isSuperType->yes()) { + $errors[] = $cannotBeTurnedOffError; + } else { + $errors[] = $canBeTurnedOffError; + } + } else { + if (!$isSuperType->yes()) { + $errors[] = $cannotBeTurnedOffError; + } + } + + return $errors; + } + + private function findPrototype(ClassReflection $classReflection, string $propertyName): ?PhpPropertyReflection + { + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null) { + return $this->findPrototypeInInterfaces($classReflection, $propertyName); + } + + if (!$parentClass->hasNativeProperty($propertyName)) { + return $this->findPrototypeInInterfaces($classReflection, $propertyName); + } + + $property = $parentClass->getNativeProperty($propertyName); + if ($property->isPrivate()) { + return $this->findPrototypeInInterfaces($classReflection, $propertyName); + } + + return $property; + } + + private function findPrototypeInInterfaces(ClassReflection $classReflection, string $propertyName): ?PhpPropertyReflection + { + foreach ($classReflection->getInterfaces() as $interface) { + if (!$interface->hasNativeProperty($propertyName)) { + continue; + } + + return $interface->getNativeProperty($propertyName); + } + + return null; + } + +} diff --git a/src/Rules/Properties/PropertiesInInterfaceRule.php b/src/Rules/Properties/PropertiesInInterfaceRule.php new file mode 100644 index 00000000..e1907378 --- /dev/null +++ b/src/Rules/Properties/PropertiesInInterfaceRule.php @@ -0,0 +1,102 @@ + + */ +final class PropertiesInInterfaceRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->getClassReflection()->isInterface()) { + return []; + } + + if (!$this->phpVersion->supportsPropertyHooks()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include properties.') + ->nonIgnorable() + ->identifier('property.inInterface') + ->build(), + ]; + } + + if (!$node->hasHooks()) { + return [ + RuleErrorBuilder::message('Interfaces can only include hooked properties.') + ->nonIgnorable() + ->identifier('property.nonHookedInInterface') + ->build(), + ]; + } + + if (!$node->isPublic()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include non-public properties.') + ->nonIgnorable() + ->identifier('property.nonPublicInInterface') + ->build(), + ]; + } + + if ($node->isReadOnly()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include readonly hooked properties.') + ->nonIgnorable() + ->identifier('property.readOnlyInInterface') + ->build(), + ]; + } + + if ($node->isStatic()) { + return [ + RuleErrorBuilder::message('Hooked properties cannot be static.') + ->nonIgnorable() + ->identifier('property.hookedStatic') + ->build(), + ]; + } + + if ($this->hasAnyHookBody($node)) { + return [ + RuleErrorBuilder::message('Interfaces cannot include property hooks with bodies.') + ->nonIgnorable() + ->identifier('property.hookBodyInInterface') + ->build(), + ]; + } + + return []; + } + + private function hasAnyHookBody(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body !== null) { + return true; + } + } + + return false; + } + +} diff --git a/src/Rules/Properties/PropertyAssignRefRule.php b/src/Rules/Properties/PropertyAssignRefRule.php new file mode 100644 index 00000000..10dd2fca --- /dev/null +++ b/src/Rules/Properties/PropertyAssignRefRule.php @@ -0,0 +1,72 @@ + + */ +final class PropertyAssignRefRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + private PropertyReflectionFinder $propertyReflectionFinder, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\AssignRef::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->supportsAsymmetricVisibility()) { + return []; + } + + if (!$node->expr instanceof Node\Expr\PropertyFetch) { + return []; + } + + $propertyFetch = $node->expr; + + $errors = []; + $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + foreach ($reflections as $propertyReflection) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null) { + continue; + } + if ($scope->canWriteProperty($propertyReflection)) { + continue; + } + + $declaringClass = $nativeReflection->getDeclaringClass(); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s with %s visibility is assigned by reference.', + $declaringClass->getDisplayName(), + $propertyReflection->getName(), + $propertyReflection->isPrivateSet() ? 'private(set)' : ( + $propertyReflection->isProtectedSet() ? 'protected(set)' : ( + $propertyReflection->isPrivate() ? 'private' : 'protected' + ) + ), + )) + ->identifier('property.assignByRef') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/PropertyAttributesRule.php b/src/Rules/Properties/PropertyAttributesRule.php new file mode 100644 index 00000000..234752d5 --- /dev/null +++ b/src/Rules/Properties/PropertyAttributesRule.php @@ -0,0 +1,37 @@ + + */ +final class PropertyAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Property::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->attrGroups, + Attribute::TARGET_PROPERTY, + 'property', + ); + } + +} diff --git a/src/Rules/Properties/PropertyDescriptor.php b/src/Rules/Properties/PropertyDescriptor.php new file mode 100644 index 00000000..118a49d5 --- /dev/null +++ b/src/Rules/Properties/PropertyDescriptor.php @@ -0,0 +1,42 @@ +getType($propertyFetch->var); + $declaringClassType = new ObjectType($property->getDeclaringClass()->getName()); + if ($declaringClassType->isSuperTypeOf($fetchedOnType)->yes()) { + $classDescription = $property->getDeclaringClass()->getDisplayName(); + } else { + $classDescription = $fetchedOnType->describe(VerbosityLevel::typeOnly()); + } + } else { + $classDescription = $property->getDeclaringClass()->getDisplayName(); + } + + /** @var Node\Identifier $name */ + $name = $propertyFetch->name; + if (!$property->isStatic()) { + return sprintf('Property %s::$%s', $classDescription, $name->name); + } + + return sprintf('Static property %s::$%s', $classDescription, $name->name); + } + +} diff --git a/src/Rules/Properties/PropertyHookAttributesRule.php b/src/Rules/Properties/PropertyHookAttributesRule.php new file mode 100644 index 00000000..f4d0e371 --- /dev/null +++ b/src/Rules/Properties/PropertyHookAttributesRule.php @@ -0,0 +1,38 @@ + + */ +final class PropertyHookAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_METHOD, + 'method', + ); + } + +} diff --git a/src/Rules/Properties/PropertyInClassRule.php b/src/Rules/Properties/PropertyInClassRule.php new file mode 100644 index 00000000..db56243e --- /dev/null +++ b/src/Rules/Properties/PropertyInClassRule.php @@ -0,0 +1,143 @@ + + */ +final class PropertyInClassRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (!$classReflection->isClass()) { + return []; + } + + if (!$this->phpVersion->supportsPropertyHooks()) { + if ($node->hasHooks()) { + return [ + RuleErrorBuilder::message('Property hooks are supported only on PHP 8.4 and later.') + ->nonIgnorable() + ->identifier('property.hooksNotSupported') + ->build(), + ]; + } + + return []; + } + + if ($node->isAbstract()) { + if (!$node->hasHooks()) { + return [ + RuleErrorBuilder::message('Only hooked properties can be declared abstract.') + ->nonIgnorable() + ->identifier('property.abstractNonHooked') + ->build(), + ]; + } + + if (!$this->isAtLeastOneHookBodyEmpty($node)) { + return [ + RuleErrorBuilder::message('Abstract properties must specify at least one abstract hook.') + ->nonIgnorable() + ->identifier('property.abstractWithoutAbstractHook') + ->build(), + ]; + } + + if (!$classReflection->isAbstract()) { + return [ + RuleErrorBuilder::message('Non-abstract classes cannot include abstract properties.') + ->nonIgnorable() + ->identifier('property.abstract') + ->build(), + ]; + } + } elseif (!$this->doAllHooksHaveBody($node)) { + return [ + RuleErrorBuilder::message('Non-abstract properties cannot include hooks without bodies.') + ->nonIgnorable() + ->identifier('property.hookWithoutBody') + ->build(), + ]; + } + + if ($node->isReadOnly()) { + if ($node->hasHooks()) { + return [ + RuleErrorBuilder::message('Hooked properties cannot be readonly.') + ->nonIgnorable() + ->identifier('property.hookReadOnly') + ->build(), + ]; + } + } + + if ($node->isStatic()) { + if ($node->hasHooks()) { + return [ + RuleErrorBuilder::message('Hooked properties cannot be static.') + ->nonIgnorable() + ->identifier('property.hookedStatic') + ->build(), + ]; + } + } + + if ($node->isVirtual()) { + if ($node->getDefault() !== null) { + return [ + RuleErrorBuilder::message('Virtual hooked properties cannot have a default value.') + ->nonIgnorable() + ->identifier('property.virtualDefault') + ->build(), + ]; + } + } + + return []; + } + + private function doAllHooksHaveBody(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body === null) { + return false; + } + } + + return true; + } + + private function isAtLeastOneHookBodyEmpty(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body === null) { + return true; + } + } + + return false; + } + +} diff --git a/src/Rules/Properties/PropertyReflectionFinder.php b/src/Rules/Properties/PropertyReflectionFinder.php new file mode 100644 index 00000000..ef732c5a --- /dev/null +++ b/src/Rules/Properties/PropertyReflectionFinder.php @@ -0,0 +1,127 @@ +name instanceof Node\Identifier) { + $names = [$propertyFetch->name->name]; + } else { + $names = array_map(static fn (ConstantStringType $name): string => $name->getValue(), $scope->getType($propertyFetch->name)->getConstantStrings()); + } + + $reflections = []; + $propertyHolderType = $scope->getType($propertyFetch->var); + foreach ($names as $name) { + $reflection = $this->findPropertyReflection( + $propertyHolderType, + $name, + $propertyFetch->name instanceof Expr ? $scope->filterByTruthyValue(new Expr\BinaryOp\Identical( + $propertyFetch->name, + new String_($name), + )) : $scope, + ); + if ($reflection === null) { + continue; + } + + $reflections[] = $reflection; + } + + return $reflections; + } + + if ($propertyFetch->class instanceof Node\Name) { + $propertyHolderType = $scope->resolveTypeByName($propertyFetch->class); + } else { + $propertyHolderType = $scope->getType($propertyFetch->class); + } + + if ($propertyFetch->name instanceof VarLikeIdentifier) { + $names = [$propertyFetch->name->name]; + } else { + $names = array_map(static fn (ConstantStringType $name): string => $name->getValue(), $scope->getType($propertyFetch->name)->getConstantStrings()); + } + + $reflections = []; + foreach ($names as $name) { + $reflection = $this->findPropertyReflection( + $propertyHolderType, + $name, + $propertyFetch->name instanceof Expr ? $scope->filterByTruthyValue(new Expr\BinaryOp\Identical( + $propertyFetch->name, + new String_($name), + )) : $scope, + ); + if ($reflection === null) { + continue; + } + + $reflections[] = $reflection; + } + + return $reflections; + } + + /** + * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch + */ + public function findPropertyReflectionFromNode($propertyFetch, Scope $scope): ?FoundPropertyReflection + { + if ($propertyFetch instanceof Node\Expr\PropertyFetch) { + if (!$propertyFetch->name instanceof Node\Identifier) { + return null; + } + $propertyHolderType = $scope->getType($propertyFetch->var); + return $this->findPropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + + if (!$propertyFetch->name instanceof Node\Identifier) { + return null; + } + + if ($propertyFetch->class instanceof Node\Name) { + $propertyHolderType = $scope->resolveTypeByName($propertyFetch->class); + } else { + $propertyHolderType = $scope->getType($propertyFetch->class); + } + + return $this->findPropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + + private function findPropertyReflection(Type $propertyHolderType, string $propertyName, Scope $scope): ?FoundPropertyReflection + { + if (!$propertyHolderType->hasProperty($propertyName)->yes()) { + return null; + } + + $originalProperty = $propertyHolderType->getProperty($propertyName, $scope); + + return new FoundPropertyReflection( + $originalProperty, + $scope, + $propertyName, + $originalProperty->getReadableType(), + $originalProperty->getWritableType(), + ); + } + +} diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRefRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRefRule.php new file mode 100644 index 00000000..98d808ad --- /dev/null +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRefRule.php @@ -0,0 +1,58 @@ + + */ +final class ReadOnlyByPhpDocPropertyAssignRefRule implements Rule +{ + + public function __construct(private PropertyReflectionFinder $propertyReflectionFinder) + { + } + + public function getNodeType(): string + { + return Node\Expr\AssignRef::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->expr instanceof Node\Expr\PropertyFetch && !$node->expr instanceof Node\Expr\StaticPropertyFetch) { + return []; + } + + $propertyFetch = $node->expr; + + $errors = []; + $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + foreach ($reflections as $propertyReflection) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null) { + continue; + } + if (!$scope->canWriteProperty($propertyReflection)) { + continue; + } + if (!$nativeReflection->isReadOnlyByPhpDoc() || $nativeReflection->isReadOnly()) { + continue; + } + + $declaringClass = $nativeReflection->getDeclaringClass(); + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned by reference.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyByPhpDocAssignByRef') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php new file mode 100644 index 00000000..21e0b75e --- /dev/null +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php @@ -0,0 +1,118 @@ + + */ +final class ReadOnlyByPhpDocPropertyAssignRule implements Rule +{ + + public function __construct( + private PropertyReflectionFinder $propertyReflectionFinder, + private ConstructorsHelper $constructorsHelper, + ) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyFetch = $node->getPropertyFetch(); + if (!$propertyFetch instanceof Node\Expr\PropertyFetch) { + return []; + } + + $inFunction = $scope->getFunction(); + if ( + $inFunction instanceof PhpMethodFromParserNodeReflection + && $inFunction->isPropertyHook() + && $propertyFetch->var instanceof Node\Expr\Variable + && $propertyFetch->var->name === 'this' + && $propertyFetch->name instanceof Node\Identifier + && $inFunction->getHookedPropertyName() === $propertyFetch->name->toString() + ) { + return []; + } + + $errors = []; + $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + foreach ($reflections as $propertyReflection) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null) { + continue; + } + if (!$scope->canWriteProperty($propertyReflection)) { + continue; + } + if (!$nativeReflection->isReadOnlyByPhpDoc() || $nativeReflection->isReadOnly()) { + continue; + } + + $declaringClass = $nativeReflection->getDeclaringClass(); + + if (!$scope->isInClass()) { + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyByPhpDocAssignOutOfClass') + ->build(); + continue; + } + + $scopeClassReflection = $scope->getClassReflection(); + if ($scopeClassReflection->getName() !== $declaringClass->getName()) { + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyByPhpDocAssignOutOfClass') + ->build(); + continue; + } + + $scopeMethod = $scope->getFunction(); + if (!$scopeMethod instanceof MethodReflection) { + throw new ShouldNotHappenException(); + } + + if ( + in_array($scopeMethod->getName(), $this->constructorsHelper->getConstructors($scopeClassReflection), true) + || strtolower($scopeMethod->getName()) === '__unserialize' + ) { + if (TypeUtils::findThisType($scope->getType($propertyFetch->var)) === null) { + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is not assigned on $this.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyByPhpDocAssignNotOnThis') + ->build(); + } + + continue; + } + + if ($nativeReflection->isAllowedPrivateMutation()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of the constructor.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyByPhpDocAssignNotInConstructor') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyRule.php new file mode 100644 index 00000000..917f0f7a --- /dev/null +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyRule.php @@ -0,0 +1,39 @@ + + */ +final class ReadOnlyByPhpDocPropertyRule implements Rule +{ + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->isReadOnlyByPhpDoc() && !$node->isAllowedPrivateMutation()) || $node->isReadOnly()) { + return []; + } + + $errors = []; + if ($node->getDefault() !== null) { + $errors[] = RuleErrorBuilder::message('@readonly property cannot have a default value.') + ->identifier('property.readOnlyByPhpDocDefaultValue') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadOnlyPropertyAssignRefRule.php b/src/Rules/Properties/ReadOnlyPropertyAssignRefRule.php new file mode 100644 index 00000000..a62c345e --- /dev/null +++ b/src/Rules/Properties/ReadOnlyPropertyAssignRefRule.php @@ -0,0 +1,58 @@ + + */ +final class ReadOnlyPropertyAssignRefRule implements Rule +{ + + public function __construct(private PropertyReflectionFinder $propertyReflectionFinder) + { + } + + public function getNodeType(): string + { + return Node\Expr\AssignRef::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->expr instanceof Node\Expr\PropertyFetch) { + return []; + } + + $propertyFetch = $node->expr; + + $errors = []; + $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + foreach ($reflections as $propertyReflection) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null) { + continue; + } + if (!$scope->canWriteProperty($propertyReflection)) { + continue; + } + if (!$nativeReflection->isReadOnly()) { + continue; + } + + $declaringClass = $nativeReflection->getDeclaringClass(); + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned by reference.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyAssignByRef') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php new file mode 100644 index 00000000..1a9a2fdf --- /dev/null +++ b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php @@ -0,0 +1,101 @@ + + */ +final class ReadOnlyPropertyAssignRule implements Rule +{ + + public function __construct( + private PropertyReflectionFinder $propertyReflectionFinder, + private ConstructorsHelper $constructorsHelper, + ) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyFetch = $node->getPropertyFetch(); + if (!$propertyFetch instanceof Node\Expr\PropertyFetch) { + return []; + } + + $errors = []; + $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + foreach ($reflections as $propertyReflection) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null) { + continue; + } + if (!$scope->canWriteProperty($propertyReflection)) { + continue; + } + if (!$nativeReflection->isReadOnly()) { + continue; + } + + $declaringClass = $nativeReflection->getDeclaringClass(); + + if (!$scope->isInClass()) { + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyAssignOutOfClass') + ->build(); + continue; + } + + $scopeClassReflection = $scope->getClassReflection(); + if ($scopeClassReflection->getName() !== $declaringClass->getName()) { + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyAssignOutOfClass') + ->build(); + continue; + } + + $scopeMethod = $scope->getFunction(); + if (!$scopeMethod instanceof MethodReflection) { + throw new ShouldNotHappenException(); + } + + if ( + in_array($scopeMethod->getName(), $this->constructorsHelper->getConstructors($scopeClassReflection), true) + || strtolower($scopeMethod->getName()) === '__unserialize' + ) { + if (TypeUtils::findThisType($scope->getType($propertyFetch->var)) === null) { + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is not assigned on $this.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyAssignNotOnThis') + ->build(); + } + + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of the constructor.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->identifier('property.readOnlyAssignNotInConstructor') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadOnlyPropertyRule.php b/src/Rules/Properties/ReadOnlyPropertyRule.php new file mode 100644 index 00000000..d239593b --- /dev/null +++ b/src/Rules/Properties/ReadOnlyPropertyRule.php @@ -0,0 +1,63 @@ + + */ +final class ReadOnlyPropertyRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->isReadOnly()) { + return []; + } + + $errors = []; + if (!$this->phpVersion->supportsReadOnlyProperties()) { + $errors[] = RuleErrorBuilder::message('Readonly properties are supported only on PHP 8.1 and later.')->nonIgnorable() + ->identifier('property.readOnlyNotSupported') + ->build(); + } + + if ($node->getNativeType() === null) { + $errors[] = RuleErrorBuilder::message('Readonly property must have a native type.') + ->identifier('property.readOnlyNoNativeType') + ->nonIgnorable() + ->build(); + } + + if ($node->getDefault() !== null) { + $errors[] = RuleErrorBuilder::message('Readonly property cannot have a default value.')->nonIgnorable() + ->identifier('property.readOnlyDefaultValue') + ->build(); + } + + if ($node->isStatic()) { + $errors[] = RuleErrorBuilder::message('Readonly property cannot be static.')->nonIgnorable() + ->identifier('property.readOnlyStatic') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/ReadWritePropertiesExtension.php b/src/Rules/Properties/ReadWritePropertiesExtension.php new file mode 100644 index 00000000..a322a3b5 --- /dev/null +++ b/src/Rules/Properties/ReadWritePropertiesExtension.php @@ -0,0 +1,35 @@ + + */ +final class ReadingWriteOnlyPropertiesRule implements Rule +{ + + public function __construct( + private PropertyDescriptor $propertyDescriptor, + private PropertyReflectionFinder $propertyReflectionFinder, + private RuleLevelHelper $ruleLevelHelper, + private bool $checkThisOnly, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + !($node instanceof Node\Expr\PropertyFetch) + && !($node instanceof Node\Expr\StaticPropertyFetch) + ) { + return []; + } + + if ( + $node instanceof Node\Expr\PropertyFetch + && $this->checkThisOnly + && !$this->ruleLevelHelper->isThis($node->var) + ) { + return []; + } + + if ($scope->isInExpressionAssign($node)) { + return []; + } + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($node, $scope); + if ($propertyReflection === null) { + return []; + } + if (!$scope->canReadProperty($propertyReflection)) { + return []; + } + + if (!$propertyReflection->isReadable()) { + $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $node); + + return [ + RuleErrorBuilder::message(sprintf( + '%s is not readable.', + $propertyDescription, + )) + ->identifier('property.writeOnly') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php new file mode 100644 index 00000000..0d03e197 --- /dev/null +++ b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php @@ -0,0 +1,94 @@ + + */ +final class SetNonVirtualPropertyHookAssignRule implements Rule +{ + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookNode = $node->getPropertyHookNode(); + if ($hookNode->name->toLowerString() !== 'set') { + return []; + } + + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + + $propertyName = $hookReflection->getHookedPropertyName(); + $classReflection = $node->getClassReflection(); + $propertyReflection = $node->getPropertyReflection(); + if ($propertyReflection->isVirtual()->yes()) { + return []; + } + + $finalHookScope = null; + foreach ($node->getExecutionEnds() as $executionEnd) { + $statementResult = $executionEnd->getStatementResult(); + $endNode = $executionEnd->getNode(); + if ($statementResult->isAlwaysTerminating()) { + if ($endNode instanceof Node\Stmt\Expression) { + $exprType = $statementResult->getScope()->getType($endNode->expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + continue; + } + } + } + if ($finalHookScope === null) { + $finalHookScope = $statementResult->getScope(); + continue; + } + + $finalHookScope = $finalHookScope->mergeWith($statementResult->getScope()); + } + + foreach ($node->getReturnStatements() as $returnStatement) { + if ($finalHookScope === null) { + $finalHookScope = $returnStatement->getScope(); + continue; + } + $finalHookScope = $finalHookScope->mergeWith($returnStatement->getScope()); + } + + if ($finalHookScope === null) { + return []; + } + + $initExpr = new PropertyInitializationExpr($propertyName); + $hasInit = $finalHookScope->hasExpressionType($initExpr); + if ($hasInit->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Set hook for non-virtual property %s::$%s does not %sassign value to it.', + $classReflection->getDisplayName(), + $propertyName, + $hasInit->maybe() ? 'always ' : '', + ))->identifier('propertySetHook.noAssign')->build(), + ]; + } + +} diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php new file mode 100644 index 00000000..b66c502e --- /dev/null +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -0,0 +1,158 @@ + + */ +final class SetPropertyHookParameterRule implements Rule +{ + + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + private bool $checkPhpDocMethodSignatures, + private bool $checkMissingTypehints, + ) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + return []; + } + + if ($hookReflection->getPropertyHookName() !== 'set') { + return []; + } + + $propertyReflection = $node->getPropertyReflection(); + $parameters = $hookReflection->getParameters(); + if (!isset($parameters[0])) { + throw new ShouldNotHappenException(); + } + + $classReflection = $node->getClassReflection(); + + $errors = []; + $parameter = $parameters[0]; + if (!$propertyReflection->hasNativeType()) { + if ($parameter->hasNativeType()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s of set hook has a native type but the property %s::$%s does not.', + $parameter->getName(), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } + } elseif (!$parameter->hasNativeType()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s of set hook does not have a native type but the property %s::$%s does.', + $parameter->getName(), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } else { + if (!$parameter->getNativeType()->isSuperTypeOf($propertyReflection->getNativeType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Native type %s of set hook parameter $%s is not contravariant with native type %s of property %s::$%s.', + $parameter->getNativeType()->describe(VerbosityLevel::typeOnly()), + $parameter->getName(), + $propertyReflection->getNativeType()->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } + } + + if (!$this->checkPhpDocMethodSignatures || count($errors) > 0) { + return $errors; + } + + $parameterType = $parameter->getType(); + + if (!$parameterType->isSuperTypeOf($propertyReflection->getReadableType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of set hook parameter $%s is not contravariant with type %s of property %s::$%s.', + $parameterType->describe(VerbosityLevel::value()), + $parameter->getName(), + $propertyReflection->getReadableType()->describe(VerbosityLevel::value()), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.parameterType') + ->build(); + } + + if (!$this->checkMissingTypehints) { + return $errors; + } + + if ($parameter->getNativeType()->equals($propertyReflection->getReadableType())) { + return $errors; + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with no value type specified in iterable type %s.', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($parameterType) as [$name, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with generic %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with no signature specified for %s.', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/TypesAssignedToPropertiesRule.php b/src/Rules/Properties/TypesAssignedToPropertiesRule.php new file mode 100644 index 00000000..e7b125c1 --- /dev/null +++ b/src/Rules/Properties/TypesAssignedToPropertiesRule.php @@ -0,0 +1,119 @@ + + */ +final class TypesAssignedToPropertiesRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private PropertyReflectionFinder $propertyReflectionFinder, + ) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyFetch = $node->getPropertyFetch(); + $propertyReflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); + + $errors = []; + foreach ($propertyReflections as $propertyReflection) { + $errors = array_merge($errors, $this->processSingleProperty( + $propertyReflection, + $propertyFetch, + $node->getAssignedExpr(), + )); + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleProperty( + FoundPropertyReflection $propertyReflection, + PropertyFetch|StaticPropertyFetch $fetch, + Node\Expr $assignedExpr, + ): array + { + if (!$propertyReflection->isWritable()) { + return []; + } + + $scope = $propertyReflection->getScope(); + $inFunction = $scope->getFunction(); + if ( + $fetch instanceof PropertyFetch + && $fetch->var instanceof Node\Expr\Variable + && is_string($fetch->var->name) + && $fetch->var->name === 'this' + && $fetch->name instanceof Node\Identifier + && $inFunction instanceof PhpMethodFromParserNodeReflection + && $inFunction->isPropertyHook() + && $inFunction->getHookedPropertyName() === $fetch->name->toString() + ) { + $propertyType = $propertyReflection->getReadableType(); + } else { + $propertyType = $propertyReflection->getWritableType(); + } + + $assignedValueType = $scope->getType($assignedExpr); + + $accepts = $this->ruleLevelHelper->accepts($propertyType, $assignedValueType, $scope->isDeclareStrictTypes()); + if (!$accepts->result) { + $propertyDescription = $this->describePropertyByName($propertyReflection, $propertyReflection->getName()); + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($propertyType, $assignedValueType); + + return [ + RuleErrorBuilder::message(sprintf( + '%s (%s) does not accept %s.', + $propertyDescription, + $propertyType->describe($verbosityLevel), + $assignedValueType->describe($verbosityLevel), + )) + ->identifier('assign.propertyType') + ->acceptsReasonsTip($accepts->reasons) + ->build(), + ]; + } + + return []; + } + + private function describePropertyByName(PropertyReflection $property, string $propertyName): string + { + if (!$property->isStatic()) { + return sprintf('Property %s::$%s', $property->getDeclaringClass()->getDisplayName(), $propertyName); + } + + return sprintf('Static property %s::$%s', $property->getDeclaringClass()->getDisplayName(), $propertyName); + } + +} diff --git a/src/Rules/Properties/UninitializedPropertyRule.php b/src/Rules/Properties/UninitializedPropertyRule.php new file mode 100644 index 00000000..eeef71dc --- /dev/null +++ b/src/Rules/Properties/UninitializedPropertyRule.php @@ -0,0 +1,69 @@ + + */ +final class UninitializedPropertyRule implements Rule +{ + + public function __construct( + private ConstructorsHelper $constructorsHelper, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + [$properties, $prematureAccess] = $node->getUninitializedProperties($scope, $this->constructorsHelper->getConstructors($classReflection)); + + $errors = []; + foreach ($properties as $propertyName => $propertyNode) { + if ($propertyNode->isReadOnly() || $propertyNode->isReadOnlyByPhpDoc()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Class %s has an uninitialized property $%s. Give it default value or assign it in the constructor.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($propertyNode->getStartLine()) + ->identifier('property.uninitialized') + ->build(); + } + + foreach ($prematureAccess as [$propertyName, $line, $propertyNode, $file, $fileDescription]) { + if ($propertyNode->isReadOnly() || $propertyNode->isReadOnlyByPhpDoc()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Access to an uninitialized property %s::$%s.', + $classReflection->getDisplayName(), + $propertyName, + )) + ->line($line) + ->file($file, $fileDescription) + ->identifier('property.uninitialized') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Properties/WritingToReadOnlyPropertiesRule.php b/src/Rules/Properties/WritingToReadOnlyPropertiesRule.php new file mode 100644 index 00000000..52ffe826 --- /dev/null +++ b/src/Rules/Properties/WritingToReadOnlyPropertiesRule.php @@ -0,0 +1,68 @@ + + */ +final class WritingToReadOnlyPropertiesRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + private PropertyDescriptor $propertyDescriptor, + private PropertyReflectionFinder $propertyReflectionFinder, + private bool $checkThisOnly, + ) + { + } + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $propertyFetch = $node->getPropertyFetch(); + if ( + $propertyFetch instanceof Node\Expr\PropertyFetch + && $this->checkThisOnly + && !$this->ruleLevelHelper->isThis($propertyFetch->var) + ) { + return []; + } + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); + if ($propertyReflection === null) { + return []; + } + + if (!$scope->canWriteProperty($propertyReflection)) { + return []; + } + + if (!$propertyReflection->isWritable()) { + $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $propertyFetch); + + return [ + RuleErrorBuilder::message(sprintf( + '%s is not writable.', + $propertyDescription, + ))->identifier('assign.propertyReadOnly')->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php new file mode 100644 index 00000000..538f1e38 --- /dev/null +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -0,0 +1,144 @@ + + */ + public function check( + string $functionDescription, + string $identifier, + FunctionReflection|ExtendedMethodReflection $functionReflection, + array $parameters, + Type $returnType, + array $impurePoints, + array $throwPoints, + array $statements, + bool $isConstructor, + ): array + { + $errors = []; + $isPure = $functionReflection->isPure(); + + if ($isPure->yes()) { + foreach ($parameters as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is marked as pure but parameter $%s is passed by reference.', + $functionDescription, + $parameter->getName(), + ))->identifier(sprintf('pure%s.parameterByRef', $identifier))->build(); + } + + if ($returnType->isVoid()->yes() && !$isConstructor) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is marked as pure but returns void.', + $functionDescription, + ))->identifier(sprintf('pure%s.void', $identifier))->build(); + } + + foreach ($impurePoints as $impurePoint) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s in pure %s.', + $impurePoint->isCertain() ? 'Impure' : 'Possibly impure', + $impurePoint->getDescription(), + lcfirst($functionDescription), + )) + ->line($impurePoint->getNode()->getStartLine()) + ->identifier(sprintf( + '%s.%s', + $impurePoint->isCertain() ? 'impure' : 'possiblyImpure', + $impurePoint->getIdentifier(), + )) + ->build(); + } + } elseif ($isPure->no()) { + if ( + count($throwPoints) === 0 + && count($impurePoints) === 0 + && count($functionReflection->getAsserts()->getAll()) === 0 + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is marked as impure but does not have any side effects.', + $functionDescription, + ))->identifier(sprintf('impure%s.pure', $identifier))->build(); + } + } elseif ($returnType->isVoid()->yes()) { + if ( + count($throwPoints) === 0 + && count($impurePoints) === 0 + && !$isConstructor + && (!$functionReflection instanceof ExtendedMethodReflection || $functionReflection->isPrivate()) + && count($functionReflection->getAsserts()->getAll()) === 0 + ) { + $hasByRef = false; + foreach ($parameters as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + $hasByRef = true; + break; + } + + $statements = array_filter($statements, static function (Stmt $stmt): bool { + if ($stmt instanceof Stmt\Nop) { + return false; + } + + if (!$stmt instanceof Stmt\Expression) { + return true; + } + if (!$stmt->expr instanceof FuncCall) { + return true; + } + if (!$stmt->expr->name instanceof Name) { + return true; + } + + return !in_array($stmt->expr->name->toString(), CallToFunctionStatementWithoutSideEffectsRule::PHPSTAN_TESTING_FUNCTIONS, true); + }); + + if (!$hasByRef && count($statements) > 0) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s returns void but does not have any side effects.', + $functionDescription, + ))->identifier('void.pure')->build(); + } + } + } + + return $errors; + } + +} diff --git a/src/Rules/Pure/PureFunctionRule.php b/src/Rules/Pure/PureFunctionRule.php new file mode 100644 index 00000000..a009aca7 --- /dev/null +++ b/src/Rules/Pure/PureFunctionRule.php @@ -0,0 +1,44 @@ + + */ +final class PureFunctionRule implements Rule +{ + + public function __construct(private FunctionPurityCheck $check) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $function = $node->getFunctionReflection(); + + return $this->check->check( + sprintf('Function %s()', $function->getName()), + 'Function', + $function, + $function->getParameters(), + $function->getReturnType(), + $node->getImpurePoints(), + $node->getStatementResult()->getThrowPoints(), + $node->getStatements(), + false, + ); + } + +} diff --git a/src/Rules/Pure/PureMethodRule.php b/src/Rules/Pure/PureMethodRule.php new file mode 100644 index 00000000..1231277e --- /dev/null +++ b/src/Rules/Pure/PureMethodRule.php @@ -0,0 +1,44 @@ + + */ +final class PureMethodRule implements Rule +{ + + public function __construct(private FunctionPurityCheck $check) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $method = $node->getMethodReflection(); + + return $this->check->check( + sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()), + 'Method', + $method, + $method->getParameters(), + $method->getReturnType(), + $node->getImpurePoints(), + $node->getStatementResult()->getThrowPoints(), + $node->getStatements(), + $method->isConstructor(), + ); + } + +} diff --git a/src/Rules/Regexp/RegularExpressionPatternRule.php b/src/Rules/Regexp/RegularExpressionPatternRule.php new file mode 100644 index 00000000..5a66cbec --- /dev/null +++ b/src/Rules/Regexp/RegularExpressionPatternRule.php @@ -0,0 +1,133 @@ + + */ +final class RegularExpressionPatternRule implements Rule +{ + + public function __construct( + private RegexExpressionHelper $regexExpressionHelper, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $patterns = $this->extractPatterns($node, $scope); + + $errors = []; + foreach ($patterns as $pattern) { + $errorMessage = $this->validatePattern($pattern); + if ($errorMessage === null) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build(); + } + + return $errors; + } + + /** + * @return string[] + */ + private function extractPatterns(FuncCall $functionCall, Scope $scope): array + { + if (!$functionCall->name instanceof Node\Name) { + return []; + } + $functionName = strtolower((string) $functionCall->name); + if (!str_starts_with($functionName, 'preg_')) { + return []; + } + + if (!isset($functionCall->getArgs()[0])) { + return []; + } + $patternNode = $functionCall->getArgs()[0]->value; + $patternType = $scope->getType($patternNode); + + $patternStrings = []; + + if ( + in_array($functionName, [ + 'preg_match', + 'preg_match_all', + 'preg_split', + 'preg_grep', + 'preg_replace', + 'preg_replace_callback', + 'preg_filter', + ], true) + ) { + if ($patternNode instanceof Node\Expr\BinaryOp\Concat) { + $patternType = $this->regexExpressionHelper->resolvePatternConcat($patternNode, $scope); + } + foreach ($patternType->getConstantStrings() as $constantStringType) { + $patternStrings[] = $constantStringType->getValue(); + } + } + + if ( + in_array($functionName, [ + 'preg_replace', + 'preg_replace_callback', + 'preg_filter', + ], true) + ) { + foreach ($patternType->getConstantArrays() as $constantArrayType) { + foreach ($constantArrayType->getValueTypes() as $arrayKeyType) { + foreach ($arrayKeyType->getConstantStrings() as $constantString) { + $patternStrings[] = $constantString->getValue(); + } + } + } + } + + if ($functionName === 'preg_replace_callback_array') { + foreach ($patternType->getConstantArrays() as $constantArrayType) { + foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) { + foreach ($arrayKeyType->getConstantStrings() as $constantString) { + $patternStrings[] = $constantString->getValue(); + } + } + } + } + + return $patternStrings; + } + + private function validatePattern(string $pattern): ?string + { + try { + Strings::match('', $pattern); + } catch (RegexpException $e) { + return $e->getMessage(); + } + + return null; + } + +} diff --git a/src/Rules/Regexp/RegularExpressionQuotingRule.php b/src/Rules/Regexp/RegularExpressionQuotingRule.php new file mode 100644 index 00000000..f1b0ee9e --- /dev/null +++ b/src/Rules/Regexp/RegularExpressionQuotingRule.php @@ -0,0 +1,243 @@ + + */ +final class RegularExpressionQuotingRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RegexExpressionHelper $regexExpressionHelper, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ( + !in_array($functionReflection->getName(), [ + 'preg_match', + 'preg_match_all', + 'preg_filter', + 'preg_grep', + 'preg_replace', + 'preg_replace_callback', + 'preg_split', + ], true) + ) { + return []; + } + + $normalizedArgs = $this->getNormalizedArgs($node, $scope, $functionReflection); + if ($normalizedArgs === null) { + return []; + } + if (!isset($normalizedArgs[0])) { + return []; + } + if (!$normalizedArgs[0]->value instanceof Concat) { + return []; + } + + $patternDelimiters = $this->regexExpressionHelper->getPatternDelimiters($normalizedArgs[0]->value, $scope); + return $this->validateQuoteDelimiters($normalizedArgs[0]->value, $scope, $patternDelimiters); + } + + /** + * @param string[] $patternDelimiters + * + * @return list + */ + private function validateQuoteDelimiters(Concat $concat, Scope $scope, array $patternDelimiters): array + { + if ($patternDelimiters === []) { + return []; + } + + $errors = []; + if ( + $concat->left instanceof FuncCall + && $concat->left->name instanceof Name + && $concat->left->name->toLowerString() === 'preg_quote' + ) { + $pregError = $this->validatePregQuote($concat->left, $scope, $patternDelimiters); + if ($pregError !== null) { + $errors[] = $pregError; + } + } elseif ($concat->left instanceof Concat) { + $errors = array_merge($errors, $this->validateQuoteDelimiters($concat->left, $scope, $patternDelimiters)); + } + + if ( + $concat->right instanceof FuncCall + && $concat->right->name instanceof Name + && $concat->right->name->toLowerString() === 'preg_quote' + ) { + $pregError = $this->validatePregQuote($concat->right, $scope, $patternDelimiters); + if ($pregError !== null) { + $errors[] = $pregError; + } + } elseif ($concat->right instanceof Concat) { + $errors = array_merge($errors, $this->validateQuoteDelimiters($concat->right, $scope, $patternDelimiters)); + } + + return $errors; + } + + /** + * @param string[] $patternDelimiters + */ + private function validatePregQuote(FuncCall $pregQuote, Scope $scope, array $patternDelimiters): ?IdentifierRuleError + { + if (!$pregQuote->name instanceof Node\Name) { + return null; + } + + if (!$this->reflectionProvider->hasFunction($pregQuote->name, $scope)) { + return null; + } + $functionReflection = $this->reflectionProvider->getFunction($pregQuote->name, $scope); + + $args = $this->getNormalizedArgs($pregQuote, $scope, $functionReflection); + if ($args === null) { + return null; + } + + $patternDelimiters = $this->removeDefaultEscapedDelimiters($patternDelimiters); + if ($patternDelimiters === []) { + return null; + } + + if (count($args) === 1) { + if (count($patternDelimiters) === 1) { + return RuleErrorBuilder::message(sprintf('Call to preg_quote() is missing delimiter %s to be effective.', $patternDelimiters[0])) + ->line($pregQuote->getStartLine()) + ->identifier('argument.invalidPregQuote') + ->build(); + } + + return RuleErrorBuilder::message('Call to preg_quote() is missing delimiter parameter to be effective.') + ->line($pregQuote->getStartLine()) + ->identifier('argument.invalidPregQuote') + ->build(); + } + + if (count($args) >= 2) { + + foreach ($scope->getType($args[1]->value)->getConstantStrings() as $quoteDelimiterType) { + $quoteDelimiter = $quoteDelimiterType->getValue(); + + $quoteDelimiters = $this->removeDefaultEscapedDelimiters([$quoteDelimiter]); + if ($quoteDelimiters === []) { + continue; + } + + if (count($quoteDelimiters) !== 1) { + throw new ShouldNotHappenException(); + } + $quoteDelimiter = $quoteDelimiters[0]; + + if (!in_array($quoteDelimiter, $patternDelimiters, true)) { + if (count($patternDelimiters) === 1) { + return RuleErrorBuilder::message(sprintf('Call to preg_quote() uses invalid delimiter %s while pattern uses %s.', $quoteDelimiter, $patternDelimiters[0])) + ->line($pregQuote->getStartLine()) + ->identifier('argument.invalidPregQuote') + ->build(); + } + + return RuleErrorBuilder::message(sprintf('Call to preg_quote() uses invalid delimiter %s.', $quoteDelimiter)) + ->line($pregQuote->getStartLine()) + ->identifier('argument.invalidPregQuote') + ->build(); + } + } + } + + return null; + } + + /** + * @param string[] $delimiters + * + * @return list + */ + private function removeDefaultEscapedDelimiters(array $delimiters): array + { + return array_values(array_filter($delimiters, fn (string $delimiter): bool => !$this->isDefaultEscaped($delimiter))); + } + + private function isDefaultEscaped(string $delimiter): bool + { + if (strlen($delimiter) !== 1) { + return false; + } + + return in_array( + $delimiter, + // these delimiters are escaped, no matter what preg_quote() 2nd arg looks like + ['.', '\\', '+', '*', '?', '[', '^', ']', '$', '(', ')', '{', '}', '=', '!', '<', '>', '|', ':', '-', '#'], + true, + ); + } + + /** + * @return Node\Arg[]|null + */ + private function getNormalizedArgs(FuncCall $functionCall, Scope $scope, FunctionReflection $functionReflection): ?array + { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $functionCall); + if ($normalizedFuncCall === null) { + return null; + } + + return $normalizedFuncCall->getArgs(); + } + +} diff --git a/src/Rules/Registry.php b/src/Rules/Registry.php new file mode 100644 index 00000000..dc245cea --- /dev/null +++ b/src/Rules/Registry.php @@ -0,0 +1,18 @@ + $nodeType + * @return array> + */ + public function getRules(string $nodeType): array; + +} diff --git a/src/Rules/Rule.php b/src/Rules/Rule.php new file mode 100644 index 00000000..46093178 --- /dev/null +++ b/src/Rules/Rule.php @@ -0,0 +1,40 @@ + + */ + public function getNodeType(): string; + + /** + * @param TNodeType $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array; + +} diff --git a/src/Rules/RuleError.php b/src/Rules/RuleError.php new file mode 100644 index 00000000..de5b46a6 --- /dev/null +++ b/src/Rules/RuleError.php @@ -0,0 +1,12 @@ + */ + private array $tips = []; + + private function __construct(string $message) + { + $this->properties['message'] = $message; + $this->type = self::TYPE_MESSAGE; + } + + /** + * @return array}> + */ + public static function getRuleErrorTypes(): array + { + return [ + self::TYPE_MESSAGE => [ + RuleError::class, + [ + [ + 'message', + 'string', + 'string', + ], + ], + ], + self::TYPE_LINE => [ + LineRuleError::class, + [ + [ + 'line', + 'int', + 'int', + ], + ], + ], + self::TYPE_FILE => [ + FileRuleError::class, + [ + [ + 'file', + 'string', + 'string', + ], + [ + 'fileDescription', + 'string', + 'string', + ], + ], + ], + self::TYPE_TIP => [ + TipRuleError::class, + [ + [ + 'tip', + 'string', + 'string', + ], + ], + ], + self::TYPE_IDENTIFIER => [ + IdentifierRuleError::class, + [ + [ + 'identifier', + 'string', + 'string', + ], + ], + ], + self::TYPE_METADATA => [ + MetadataRuleError::class, + [ + [ + 'metadata', + 'array', + 'mixed[]', + ], + ], + ], + self::TYPE_NON_IGNORABLE => [ + NonIgnorableRuleError::class, + [], + ], + ]; + } + + /** + * @return self + */ + public static function message(string $message): self + { + return new self($message); + } + + /** + * @phpstan-this-out self + * @return self + */ + public function line(int $line): self + { + $this->properties['line'] = $line; + $this->type |= self::TYPE_LINE; + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function file(string $file, ?string $fileDescription = null): self + { + if (!is_file($file)) { + throw new ShouldNotHappenException(sprintf('File %s does not exist.', $file)); + } + $this->properties['file'] = $file; + $this->properties['fileDescription'] = $fileDescription ?? $file; + $this->type |= self::TYPE_FILE; + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function tip(string $tip): self + { + $this->tips = [$tip]; + $this->type |= self::TYPE_TIP; + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function addTip(string $tip): self + { + $this->tips[] = $tip; + $this->type |= self::TYPE_TIP; + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function discoveringSymbolsTip(): self + { + return $this->tip('Learn more at https://phpstan.org/user-guide/discovering-symbols'); + } + + /** + * @param list $reasons + * @phpstan-this-out self + * @return self + */ + public function acceptsReasonsTip(array $reasons): self + { + foreach ($reasons as $reason) { + $this->addTip($reason); + } + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function treatPhpDocTypesAsCertainTip(): self + { + return $this->tip('Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'); + } + + /** + * Sets an error identifier. + * + * List of all current error identifiers in PHPStan: https://phpstan.org/error-identifiers + * + * @phpstan-this-out self + * @return self + */ + public function identifier(string $identifier): self + { + if (!Error::validateIdentifier($identifier)) { + throw new ShouldNotHappenException(sprintf('Invalid identifier: %s, error identifiers must match /%s/', $identifier, Error::PATTERN_IDENTIFIER)); + } + + $this->properties['identifier'] = $identifier; + $this->type |= self::TYPE_IDENTIFIER; + + return $this; + } + + /** + * @param mixed[] $metadata + * @phpstan-this-out self + * @return self + */ + public function metadata(array $metadata): self + { + $this->properties['metadata'] = $metadata; + $this->type |= self::TYPE_METADATA; + + return $this; + } + + /** + * @phpstan-this-out self + * @return self + */ + public function nonIgnorable(): self + { + $this->type |= self::TYPE_NON_IGNORABLE; + + return $this; + } + + /** + * @return T + */ + public function build(): RuleError + { + /** @var class-string $className */ + $className = sprintf('PHPStan\\Rules\\RuleErrors\\RuleError%d', $this->type); + if (!class_exists($className)) { + throw new ShouldNotHappenException(sprintf('Class %s does not exist.', $className)); + } + + $ruleError = new $className(); + foreach ($this->properties as $propertyName => $value) { + $ruleError->{$propertyName} = $value; + } + + if (count($this->tips) > 0) { + if (count($this->tips) === 1) { + $ruleError->tip = $this->tips[0]; + } else { + $ruleError->tip = implode("\n", array_map(static fn (string $tip) => sprintf('• %s', $tip), $this->tips)); + } + } + + return $ruleError; + } + +} diff --git a/src/Rules/RuleErrors/RuleError1.php b/src/Rules/RuleErrors/RuleError1.php new file mode 100644 index 00000000..b28b0a79 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError1.php @@ -0,0 +1,21 @@ +message; + } + +} diff --git a/src/Rules/RuleErrors/RuleError101.php b/src/Rules/RuleErrors/RuleError101.php new file mode 100644 index 00000000..8bc0a297 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError101.php @@ -0,0 +1,49 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError103.php b/src/Rules/RuleErrors/RuleError103.php new file mode 100644 index 00000000..f46d5c2d --- /dev/null +++ b/src/Rules/RuleErrors/RuleError103.php @@ -0,0 +1,57 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError105.php b/src/Rules/RuleErrors/RuleError105.php new file mode 100644 index 00000000..e2152168 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError105.php @@ -0,0 +1,42 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError107.php b/src/Rules/RuleErrors/RuleError107.php new file mode 100644 index 00000000..a2768961 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError107.php @@ -0,0 +1,50 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError109.php b/src/Rules/RuleErrors/RuleError109.php new file mode 100644 index 00000000..fb11c3be --- /dev/null +++ b/src/Rules/RuleErrors/RuleError109.php @@ -0,0 +1,57 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError11.php b/src/Rules/RuleErrors/RuleError11.php new file mode 100644 index 00000000..bdb304ea --- /dev/null +++ b/src/Rules/RuleErrors/RuleError11.php @@ -0,0 +1,37 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError111.php b/src/Rules/RuleErrors/RuleError111.php new file mode 100644 index 00000000..d8aad0b4 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError111.php @@ -0,0 +1,65 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError113.php b/src/Rules/RuleErrors/RuleError113.php new file mode 100644 index 00000000..12cd2b78 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError113.php @@ -0,0 +1,42 @@ +message; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError115.php b/src/Rules/RuleErrors/RuleError115.php new file mode 100644 index 00000000..7e0292dd --- /dev/null +++ b/src/Rules/RuleErrors/RuleError115.php @@ -0,0 +1,50 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError117.php b/src/Rules/RuleErrors/RuleError117.php new file mode 100644 index 00000000..af873f86 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError117.php @@ -0,0 +1,57 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError119.php b/src/Rules/RuleErrors/RuleError119.php new file mode 100644 index 00000000..81d1197e --- /dev/null +++ b/src/Rules/RuleErrors/RuleError119.php @@ -0,0 +1,65 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError121.php b/src/Rules/RuleErrors/RuleError121.php new file mode 100644 index 00000000..89f3cc00 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError121.php @@ -0,0 +1,50 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError123.php b/src/Rules/RuleErrors/RuleError123.php new file mode 100644 index 00000000..161c8d97 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError123.php @@ -0,0 +1,58 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError125.php b/src/Rules/RuleErrors/RuleError125.php new file mode 100644 index 00000000..0a2301bf --- /dev/null +++ b/src/Rules/RuleErrors/RuleError125.php @@ -0,0 +1,65 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError127.php b/src/Rules/RuleErrors/RuleError127.php new file mode 100644 index 00000000..6ac96cce --- /dev/null +++ b/src/Rules/RuleErrors/RuleError127.php @@ -0,0 +1,73 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError13.php b/src/Rules/RuleErrors/RuleError13.php new file mode 100644 index 00000000..3199a5ae --- /dev/null +++ b/src/Rules/RuleErrors/RuleError13.php @@ -0,0 +1,44 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError15.php b/src/Rules/RuleErrors/RuleError15.php new file mode 100644 index 00000000..b4c78251 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError15.php @@ -0,0 +1,52 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError17.php b/src/Rules/RuleErrors/RuleError17.php new file mode 100644 index 00000000..ba5fdb38 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError17.php @@ -0,0 +1,29 @@ +message; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError19.php b/src/Rules/RuleErrors/RuleError19.php new file mode 100644 index 00000000..ba75523a --- /dev/null +++ b/src/Rules/RuleErrors/RuleError19.php @@ -0,0 +1,37 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError21.php b/src/Rules/RuleErrors/RuleError21.php new file mode 100644 index 00000000..d7e00255 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError21.php @@ -0,0 +1,44 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError23.php b/src/Rules/RuleErrors/RuleError23.php new file mode 100644 index 00000000..b78819af --- /dev/null +++ b/src/Rules/RuleErrors/RuleError23.php @@ -0,0 +1,52 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError25.php b/src/Rules/RuleErrors/RuleError25.php new file mode 100644 index 00000000..98b261cb --- /dev/null +++ b/src/Rules/RuleErrors/RuleError25.php @@ -0,0 +1,37 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError27.php b/src/Rules/RuleErrors/RuleError27.php new file mode 100644 index 00000000..cc02e858 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError27.php @@ -0,0 +1,45 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError29.php b/src/Rules/RuleErrors/RuleError29.php new file mode 100644 index 00000000..3958e271 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError29.php @@ -0,0 +1,52 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError3.php b/src/Rules/RuleErrors/RuleError3.php new file mode 100644 index 00000000..2e5694c5 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError3.php @@ -0,0 +1,29 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + +} diff --git a/src/Rules/RuleErrors/RuleError31.php b/src/Rules/RuleErrors/RuleError31.php new file mode 100644 index 00000000..c9f7e8b4 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError31.php @@ -0,0 +1,60 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError33.php b/src/Rules/RuleErrors/RuleError33.php new file mode 100644 index 00000000..c23995e7 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError33.php @@ -0,0 +1,33 @@ +message; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError35.php b/src/Rules/RuleErrors/RuleError35.php new file mode 100644 index 00000000..d4aa4e95 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError35.php @@ -0,0 +1,41 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError37.php b/src/Rules/RuleErrors/RuleError37.php new file mode 100644 index 00000000..dd0d700a --- /dev/null +++ b/src/Rules/RuleErrors/RuleError37.php @@ -0,0 +1,48 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError39.php b/src/Rules/RuleErrors/RuleError39.php new file mode 100644 index 00000000..2223196f --- /dev/null +++ b/src/Rules/RuleErrors/RuleError39.php @@ -0,0 +1,56 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError41.php b/src/Rules/RuleErrors/RuleError41.php new file mode 100644 index 00000000..de70255e --- /dev/null +++ b/src/Rules/RuleErrors/RuleError41.php @@ -0,0 +1,41 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError43.php b/src/Rules/RuleErrors/RuleError43.php new file mode 100644 index 00000000..8d164cfe --- /dev/null +++ b/src/Rules/RuleErrors/RuleError43.php @@ -0,0 +1,49 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError45.php b/src/Rules/RuleErrors/RuleError45.php new file mode 100644 index 00000000..5a5f300f --- /dev/null +++ b/src/Rules/RuleErrors/RuleError45.php @@ -0,0 +1,56 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError47.php b/src/Rules/RuleErrors/RuleError47.php new file mode 100644 index 00000000..b8cd17d1 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError47.php @@ -0,0 +1,64 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError49.php b/src/Rules/RuleErrors/RuleError49.php new file mode 100644 index 00000000..b2932d7e --- /dev/null +++ b/src/Rules/RuleErrors/RuleError49.php @@ -0,0 +1,41 @@ +message; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError5.php b/src/Rules/RuleErrors/RuleError5.php new file mode 100644 index 00000000..d37d1dc7 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError5.php @@ -0,0 +1,36 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + +} diff --git a/src/Rules/RuleErrors/RuleError51.php b/src/Rules/RuleErrors/RuleError51.php new file mode 100644 index 00000000..2911bcaa --- /dev/null +++ b/src/Rules/RuleErrors/RuleError51.php @@ -0,0 +1,49 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError53.php b/src/Rules/RuleErrors/RuleError53.php new file mode 100644 index 00000000..c6bc5879 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError53.php @@ -0,0 +1,56 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError55.php b/src/Rules/RuleErrors/RuleError55.php new file mode 100644 index 00000000..32061ba4 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError55.php @@ -0,0 +1,64 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError57.php b/src/Rules/RuleErrors/RuleError57.php new file mode 100644 index 00000000..a1923a62 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError57.php @@ -0,0 +1,49 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError59.php b/src/Rules/RuleErrors/RuleError59.php new file mode 100644 index 00000000..2efc5e4d --- /dev/null +++ b/src/Rules/RuleErrors/RuleError59.php @@ -0,0 +1,57 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError61.php b/src/Rules/RuleErrors/RuleError61.php new file mode 100644 index 00000000..04f29405 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError61.php @@ -0,0 +1,64 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError63.php b/src/Rules/RuleErrors/RuleError63.php new file mode 100644 index 00000000..87e4f86e --- /dev/null +++ b/src/Rules/RuleErrors/RuleError63.php @@ -0,0 +1,72 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError65.php b/src/Rules/RuleErrors/RuleError65.php new file mode 100644 index 00000000..c8ceb02c --- /dev/null +++ b/src/Rules/RuleErrors/RuleError65.php @@ -0,0 +1,22 @@ +message; + } + +} diff --git a/src/Rules/RuleErrors/RuleError67.php b/src/Rules/RuleErrors/RuleError67.php new file mode 100644 index 00000000..26c26def --- /dev/null +++ b/src/Rules/RuleErrors/RuleError67.php @@ -0,0 +1,30 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + +} diff --git a/src/Rules/RuleErrors/RuleError69.php b/src/Rules/RuleErrors/RuleError69.php new file mode 100644 index 00000000..fcda633d --- /dev/null +++ b/src/Rules/RuleErrors/RuleError69.php @@ -0,0 +1,37 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + +} diff --git a/src/Rules/RuleErrors/RuleError7.php b/src/Rules/RuleErrors/RuleError7.php new file mode 100644 index 00000000..13c0c1aa --- /dev/null +++ b/src/Rules/RuleErrors/RuleError7.php @@ -0,0 +1,44 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + +} diff --git a/src/Rules/RuleErrors/RuleError71.php b/src/Rules/RuleErrors/RuleError71.php new file mode 100644 index 00000000..c44595f4 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError71.php @@ -0,0 +1,45 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + +} diff --git a/src/Rules/RuleErrors/RuleError73.php b/src/Rules/RuleErrors/RuleError73.php new file mode 100644 index 00000000..c3098c46 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError73.php @@ -0,0 +1,30 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError75.php b/src/Rules/RuleErrors/RuleError75.php new file mode 100644 index 00000000..0c81d8f7 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError75.php @@ -0,0 +1,38 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError77.php b/src/Rules/RuleErrors/RuleError77.php new file mode 100644 index 00000000..9382fd2b --- /dev/null +++ b/src/Rules/RuleErrors/RuleError77.php @@ -0,0 +1,45 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError79.php b/src/Rules/RuleErrors/RuleError79.php new file mode 100644 index 00000000..2262e0de --- /dev/null +++ b/src/Rules/RuleErrors/RuleError79.php @@ -0,0 +1,53 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError81.php b/src/Rules/RuleErrors/RuleError81.php new file mode 100644 index 00000000..fd79d8b8 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError81.php @@ -0,0 +1,30 @@ +message; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError83.php b/src/Rules/RuleErrors/RuleError83.php new file mode 100644 index 00000000..56b17e03 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError83.php @@ -0,0 +1,38 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError85.php b/src/Rules/RuleErrors/RuleError85.php new file mode 100644 index 00000000..7ac9561f --- /dev/null +++ b/src/Rules/RuleErrors/RuleError85.php @@ -0,0 +1,45 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError87.php b/src/Rules/RuleErrors/RuleError87.php new file mode 100644 index 00000000..8c96dd1f --- /dev/null +++ b/src/Rules/RuleErrors/RuleError87.php @@ -0,0 +1,53 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError89.php b/src/Rules/RuleErrors/RuleError89.php new file mode 100644 index 00000000..36582092 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError89.php @@ -0,0 +1,38 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError9.php b/src/Rules/RuleErrors/RuleError9.php new file mode 100644 index 00000000..b344b1f0 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError9.php @@ -0,0 +1,29 @@ +message; + } + + public function getTip(): string + { + return $this->tip; + } + +} diff --git a/src/Rules/RuleErrors/RuleError91.php b/src/Rules/RuleErrors/RuleError91.php new file mode 100644 index 00000000..cdb45140 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError91.php @@ -0,0 +1,46 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError93.php b/src/Rules/RuleErrors/RuleError93.php new file mode 100644 index 00000000..074ef2df --- /dev/null +++ b/src/Rules/RuleErrors/RuleError93.php @@ -0,0 +1,53 @@ +message; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError95.php b/src/Rules/RuleErrors/RuleError95.php new file mode 100644 index 00000000..a788201a --- /dev/null +++ b/src/Rules/RuleErrors/RuleError95.php @@ -0,0 +1,61 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + public function getFile(): string + { + return $this->file; + } + + public function getFileDescription(): string + { + return $this->fileDescription; + } + + public function getTip(): string + { + return $this->tip; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + +} diff --git a/src/Rules/RuleErrors/RuleError97.php b/src/Rules/RuleErrors/RuleError97.php new file mode 100644 index 00000000..c7b0520f --- /dev/null +++ b/src/Rules/RuleErrors/RuleError97.php @@ -0,0 +1,34 @@ +message; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleErrors/RuleError99.php b/src/Rules/RuleErrors/RuleError99.php new file mode 100644 index 00000000..1ec91701 --- /dev/null +++ b/src/Rules/RuleErrors/RuleError99.php @@ -0,0 +1,42 @@ +message; + } + + public function getLine(): int + { + return $this->line; + } + + /** + * @return mixed[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + +} diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php new file mode 100644 index 00000000..1fe70e02 --- /dev/null +++ b/src/Rules/RuleLevelHelper.php @@ -0,0 +1,320 @@ +name === 'this'; + } + + private function transformCommonType(Type $type): Type + { + if (!$this->checkExplicitMixed && !$this->checkImplicitMixed) { + return $type; + } + + return TypeTraverser::map($type, function (Type $type, callable $traverse) { + if ($type instanceof TemplateMixedType) { + if ($this->checkExplicitMixed) { + return $type->toStrictMixedType(); + } + } + if ( + $type instanceof MixedType + && ( + ($type->isExplicitMixed() && $this->checkExplicitMixed) + || (!$type->isExplicitMixed() && $this->checkImplicitMixed) + ) + ) { + return new StrictMixedType(); + } + + return $traverse($type); + }); + } + + /** + * @return array{Type, bool} + */ + private function transformAcceptedType(Type $acceptingType, Type $acceptedType): array + { + $checkForUnion = $this->checkUnionTypes; + $acceptedType = TypeTraverser::map($acceptedType, function (Type $acceptedType, callable $traverse) use ($acceptingType, &$checkForUnion): Type { + if ($acceptedType instanceof CallableType) { + if ($acceptedType->isCommonCallable()) { + return $acceptedType; + } + + return new CallableType( + $acceptedType->getParameters(), + $traverse($this->transformCommonType($acceptedType->getReturnType())), + $acceptedType->isVariadic(), + $acceptedType->getTemplateTypeMap(), + $acceptedType->getResolvedTemplateTypeMap(), + $acceptedType->getTemplateTags(), + $acceptedType->isPure(), + ); + } + + if ($acceptedType instanceof ClosureType) { + if ($acceptedType->isCommonCallable()) { + return $acceptedType; + } + + return new ClosureType( + $acceptedType->getParameters(), + $traverse($this->transformCommonType($acceptedType->getReturnType())), + $acceptedType->isVariadic(), + $acceptedType->getTemplateTypeMap(), + $acceptedType->getResolvedTemplateTypeMap(), + $acceptedType->getCallSiteVarianceMap(), + $acceptedType->getTemplateTags(), + $acceptedType->getThrowPoints(), + $acceptedType->getImpurePoints(), + $acceptedType->getInvalidateExpressions(), + $acceptedType->getUsedVariables(), + $acceptedType->acceptsNamedArguments(), + ); + } + + if ( + !$this->checkNullables + && !$acceptingType instanceof NullType + && !$acceptedType instanceof NullType + && !$acceptedType instanceof BenevolentUnionType + ) { + return $traverse(TypeCombinator::removeNull($acceptedType)); + } + + if ($this->checkBenevolentUnionTypes) { + if ($acceptedType instanceof BenevolentUnionType) { + $checkForUnion = true; + return $traverse(TypeUtils::toStrictUnion($acceptedType)); + } + } + + return $traverse($this->transformCommonType($acceptedType)); + }); + + return [$acceptedType, $checkForUnion]; + } + + /** @api */ + public function accepts(Type $acceptingType, Type $acceptedType, bool $strictTypes): RuleLevelHelperAcceptsResult + { + [$acceptedType, $checkForUnion] = $this->transformAcceptedType($acceptingType, $acceptedType); + $acceptingType = $this->transformCommonType($acceptingType); + + $accepts = $acceptingType->accepts($acceptedType, $strictTypes); + + return new RuleLevelHelperAcceptsResult( + $checkForUnion ? $accepts->yes() : !$accepts->no(), + $accepts->reasons, + ); + } + + /** + * @api + * @param callable(Type $type): bool $unionTypeCriteriaCallback + */ + public function findTypeToCheck( + Scope $scope, + Expr $var, + string $unknownClassErrorPattern, + callable $unionTypeCriteriaCallback, + ): FoundTypeResult + { + if ($this->checkThisOnly && !$this->isThis($var)) { + return new FoundTypeResult(new ErrorType(), [], [], null); + } + $type = $scope->getType($var); + + return $this->findTypeToCheckImplementation($scope, $var, $type, $unknownClassErrorPattern, $unionTypeCriteriaCallback, true); + } + + /** @param callable(Type $type): bool $unionTypeCriteriaCallback */ + private function findTypeToCheckImplementation( + Scope $scope, + Expr $var, + Type $type, + string $unknownClassErrorPattern, + callable $unionTypeCriteriaCallback, + bool $isTopLevel = false, + ): FoundTypeResult + { + if (!$this->checkNullables && !$type->isNull()->yes()) { + $type = TypeCombinator::removeNull($type); + } + + if ( + ($this->checkExplicitMixed || $this->checkImplicitMixed) + && $type instanceof MixedType + && ($type->isExplicitMixed() ? $this->checkExplicitMixed : $this->checkImplicitMixed) + ) { + return new FoundTypeResult( + $type instanceof TemplateMixedType + ? $type->toStrictMixedType() + : new StrictMixedType(), + [], + [], + null, + ); + } + + if ($type instanceof MixedType || $type instanceof NeverType) { + return new FoundTypeResult(new ErrorType(), [], [], null); + } + + $errors = []; + $hasClassExistsClass = false; + $directClassNames = []; + + if ($isTopLevel) { + $directClassNames = $type->getObjectClassNames(); + foreach ($directClassNames as $referencedClass) { + if ($this->reflectionProvider->hasClass($referencedClass)) { + $classReflection = $this->reflectionProvider->getClass($referencedClass); + if (!$classReflection->isTrait()) { + continue; + } + } + + if ($scope->isInClassExists($referencedClass)) { + $hasClassExistsClass = true; + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf($unknownClassErrorPattern, $referencedClass)) + ->line($var->getStartLine()) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } + } + + if (count($errors) > 0 || $hasClassExistsClass) { + return new FoundTypeResult(new ErrorType(), [], $errors, null); + } + + if (!$this->checkUnionTypes && $type->isObject()->yes() && count($type->getObjectClassNames()) === 0) { + return new FoundTypeResult(new ErrorType(), [], [], null); + } + + if ($type instanceof UnionType) { + $shouldFilterUnion = ( + !$this->checkUnionTypes + && !$type instanceof BenevolentUnionType + ) || ( + !$this->checkBenevolentUnionTypes + && $type instanceof BenevolentUnionType + ); + + $newTypes = []; + + foreach ($type->getTypes() as $innerType) { + if ($shouldFilterUnion && !$unionTypeCriteriaCallback($innerType)) { + continue; + } + + $newTypes[] = $this->findTypeToCheckImplementation( + $scope, + $var, + $innerType, + $unknownClassErrorPattern, + $unionTypeCriteriaCallback, + )->getType(); + } + + if (count($newTypes) > 0) { + $newUnion = TypeCombinator::union(...$newTypes); + if ( + !$this->checkBenevolentUnionTypes + && $type instanceof BenevolentUnionType + ) { + $newUnion = TypeUtils::toBenevolentUnion($newUnion); + } + + return new FoundTypeResult($newUnion, $directClassNames, [], null); + } + } + + if ($type instanceof IntersectionType) { + $newTypes = []; + + $changed = false; + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof TemplateMixedType) { + $changed = true; + $newTypes[] = $this->findTypeToCheckImplementation( + $scope, + $var, + $innerType->toStrictMixedType(), + $unknownClassErrorPattern, + $unionTypeCriteriaCallback, + )->getType(); + continue; + } + $newTypes[] = $innerType; + } + + if ($changed) { + return new FoundTypeResult(TypeCombinator::intersect(...$newTypes), $directClassNames, [], null); + } + } + + $tip = null; + if ( + $type instanceof UnionType + && count($type->getTypes()) === 2 + && $type->getTypes()[0] instanceof ObjectType + && $type->getTypes()[1] instanceof ObjectType + && $type->getTypes()[0]->getClassName() === 'PhpParser\\Node\\Arg' + && $type->getTypes()[1]->getClassName() === 'PhpParser\\Node\\VariadicPlaceholder' + && !$unionTypeCriteriaCallback($type) + ) { + $tip = 'Use ->getArgs() instead of ->args.'; + } + + return new FoundTypeResult($type, $directClassNames, [], $tip); + } + +} diff --git a/src/Rules/RuleLevelHelperAcceptsResult.php b/src/Rules/RuleLevelHelperAcceptsResult.php new file mode 100644 index 00000000..4d70b875 --- /dev/null +++ b/src/Rules/RuleLevelHelperAcceptsResult.php @@ -0,0 +1,45 @@ + $reasons + */ + public function __construct( + public readonly bool $result, + public readonly array $reasons, + ) + { + } + + public function and(self $other): self + { + return new self( + $this->result && $other->result, + array_merge($this->reasons, $other->reasons), + ); + } + + /** + * @param callable(string): string $cb + */ + public function decorateReasons(callable $cb): self + { + $reasons = []; + foreach ($this->reasons as $reason) { + $reasons[] = $cb($reason); + } + + return new self($this->result, $reasons); + } + +} diff --git a/src/Rules/TipRuleError.php b/src/Rules/TipRuleError.php new file mode 100644 index 00000000..b84d9b56 --- /dev/null +++ b/src/Rules/TipRuleError.php @@ -0,0 +1,12 @@ + + */ +final class TooWideArrowFunctionReturnTypehintRule implements Rule +{ + + public function getNodeType(): string + { + return InArrowFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $arrowFunction = $node->getOriginalNode(); + if ($arrowFunction->returnType === null) { + return []; + } + + $expr = $arrowFunction->expr; + if ($expr instanceof Node\Expr\YieldFrom || $expr instanceof Node\Expr\Yield_) { + return []; + } + + $functionReturnType = $scope->getFunctionType($arrowFunction->returnType, false, false); + if (!$functionReturnType instanceof UnionType) { + return []; + } + + $returnType = $scope->getType($expr); + if ($returnType->isNull()->yes()) { + return []; + } + $messages = []; + foreach ($functionReturnType->getTypes() as $type) { + if (!$type->isSuperTypeOf($returnType)->no()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Anonymous function never returns %s so it can be removed from the return type.', + $type->describe(VerbosityLevel::getRecommendedLevelByType($type)), + ))->identifier('return.unusedType')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideClosureReturnTypehintRule.php b/src/Rules/TooWideTypehints/TooWideClosureReturnTypehintRule.php new file mode 100644 index 00000000..375f55a6 --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideClosureReturnTypehintRule.php @@ -0,0 +1,84 @@ + + */ +final class TooWideClosureReturnTypehintRule implements Rule +{ + + public function getNodeType(): string + { + return ClosureReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $closureExpr = $node->getClosureExpr(); + if ($closureExpr->returnType === null) { + return []; + } + + $statementResult = $node->getStatementResult(); + if ($statementResult->hasYield()) { + return []; + } + + $returnStatements = $node->getReturnStatements(); + if (count($returnStatements) === 0) { + return []; + } + + $closureReturnType = $scope->getFunctionType($closureExpr->returnType, false, false); + if (!$closureReturnType instanceof UnionType) { + return []; + } + + $returnTypes = []; + foreach ($returnStatements as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ($returnNode->expr === null) { + continue; + } + + $returnTypes[] = $returnStatement->getScope()->getType($returnNode->expr); + } + + if (count($returnTypes) === 0) { + return []; + } + + $returnType = TypeCombinator::union(...$returnTypes); + if ($returnType->isNull()->yes()) { + return []; + } + + $messages = []; + foreach ($closureReturnType->getTypes() as $type) { + if (!$type->isSuperTypeOf($returnType)->no()) { + continue; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Anonymous function never returns %s so it can be removed from the return type.', + $type->describe(VerbosityLevel::getRecommendedLevelByType($type)), + ))->identifier('return.unusedType')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideFunctionParameterOutTypeRule.php b/src/Rules/TooWideTypehints/TooWideFunctionParameterOutTypeRule.php new file mode 100644 index 00000000..9917d615 --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideFunctionParameterOutTypeRule.php @@ -0,0 +1,41 @@ + + */ +final class TooWideFunctionParameterOutTypeRule implements Rule +{ + + public function __construct( + private TooWideParameterOutTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $inFunction = $node->getFunctionReflection(); + + return $this->check->check( + $node->getExecutionEnds(), + $node->getReturnStatements(), + $inFunction->getParameters(), + sprintf('Function %s()', $inFunction->getName()), + ); + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideFunctionReturnTypehintRule.php b/src/Rules/TooWideTypehints/TooWideFunctionReturnTypehintRule.php new file mode 100644 index 00000000..a4179e3f --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideFunctionReturnTypehintRule.php @@ -0,0 +1,92 @@ + + */ +final class TooWideFunctionReturnTypehintRule implements Rule +{ + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $function = $node->getFunctionReflection(); + + $functionReturnType = $function->getReturnType(); + $functionReturnType = TypeUtils::resolveLateResolvableTypes($functionReturnType); + if (!$functionReturnType instanceof UnionType) { + return []; + } + $statementResult = $node->getStatementResult(); + if ($statementResult->hasYield()) { + return []; + } + + $returnStatements = $node->getReturnStatements(); + if (count($returnStatements) === 0) { + return []; + } + + $returnTypes = []; + foreach ($returnStatements as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ($returnNode->expr === null) { + $returnTypes[] = new VoidType(); + continue; + } + + $returnTypes[] = $returnStatement->getScope()->getType($returnNode->expr); + } + + if (!$statementResult->isAlwaysTerminating()) { + $returnTypes[] = new VoidType(); + } + + $returnType = TypeCombinator::union(...$returnTypes); + + $messages = []; + foreach ($functionReturnType->getTypes() as $type) { + if (!$type->isSuperTypeOf($returnType)->no()) { + continue; + } + + if ($type->isNull()->yes() && !$node->hasNativeReturnTypehint()) { + foreach ($node->getExecutionEnds() as $executionEnd) { + if ($executionEnd->getStatementResult()->isAlwaysTerminating()) { + continue; + } + + continue 2; + } + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() never returns %s so it can be removed from the return type.', + $function->getName(), + $type->describe(VerbosityLevel::getRecommendedLevelByType($type)), + ))->identifier('return.unusedType')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideMethodParameterOutTypeRule.php b/src/Rules/TooWideTypehints/TooWideMethodParameterOutTypeRule.php new file mode 100644 index 00000000..5a6f846a --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideMethodParameterOutTypeRule.php @@ -0,0 +1,41 @@ + + */ +final class TooWideMethodParameterOutTypeRule implements Rule +{ + + public function __construct( + private TooWideParameterOutTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $inMethod = $node->getMethodReflection(); + + return $this->check->check( + $node->getExecutionEnds(), + $node->getReturnStatements(), + $inMethod->getParameters(), + sprintf('Method %s::%s()', $inMethod->getDeclaringClass()->getDisplayName(), $inMethod->getName()), + ); + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideMethodReturnTypehintRule.php b/src/Rules/TooWideTypehints/TooWideMethodReturnTypehintRule.php new file mode 100644 index 00000000..a1e13d35 --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideMethodReturnTypehintRule.php @@ -0,0 +1,120 @@ + + */ +final class TooWideMethodReturnTypehintRule implements Rule +{ + + public function __construct(private bool $checkProtectedAndPublicMethods) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($scope->isInTrait()) { + return []; + } + $method = $node->getMethodReflection(); + $isFirstDeclaration = $method->getPrototype()->getDeclaringClass() === $method->getDeclaringClass(); + if (!$method->isPrivate()) { + if (!$method->getDeclaringClass()->isFinal() && !$method->isFinal()->yes()) { + if (!$this->checkProtectedAndPublicMethods) { + return []; + } + + if ($isFirstDeclaration) { + return []; + } + } + } + + $methodReturnType = $method->getReturnType(); + $methodReturnType = TypeUtils::resolveLateResolvableTypes($methodReturnType); + if (!$methodReturnType instanceof UnionType) { + return []; + } + $statementResult = $node->getStatementResult(); + if ($statementResult->hasYield()) { + return []; + } + + $returnStatements = $node->getReturnStatements(); + if (count($returnStatements) === 0) { + return []; + } + + $returnTypes = []; + foreach ($returnStatements as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ($returnNode->expr === null) { + $returnTypes[] = new VoidType(); + continue; + } + + $returnTypes[] = $returnStatement->getScope()->getType($returnNode->expr); + } + + if (!$statementResult->isAlwaysTerminating()) { + $returnTypes[] = new VoidType(); + } + + $returnType = TypeCombinator::union(...$returnTypes); + if ( + !$method->isPrivate() + && ($returnType->isNull()->yes() || $returnType instanceof ConstantBooleanType) + && !$isFirstDeclaration + ) { + return []; + } + + $messages = []; + foreach ($methodReturnType->getTypes() as $type) { + if (!$type->isSuperTypeOf($returnType)->no()) { + continue; + } + + if ($type->isNull()->yes() && !$node->hasNativeReturnTypehint()) { + foreach ($node->getExecutionEnds() as $executionEnd) { + if ($executionEnd->getStatementResult()->isAlwaysTerminating()) { + continue; + } + + continue 2; + } + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() never returns %s so it can be removed from the return type.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $type->describe(VerbosityLevel::getRecommendedLevelByType($type)), + ))->identifier('return.unusedType')->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/TooWideTypehints/TooWideParameterOutTypeCheck.php b/src/Rules/TooWideTypehints/TooWideParameterOutTypeCheck.php new file mode 100644 index 00000000..54662835 --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWideParameterOutTypeCheck.php @@ -0,0 +1,119 @@ + $executionEnds + * @param list $returnStatements + * @param ExtendedParameterReflection[] $parameters + * @return list + */ + public function check( + array $executionEnds, + array $returnStatements, + array $parameters, + string $functionDescription, + ): array + { + $finalScope = null; + foreach ($executionEnds as $executionEnd) { + $endScope = $executionEnd->getStatementResult()->getScope(); + if ($finalScope === null) { + $finalScope = $endScope; + continue; + } + + $finalScope = $finalScope->mergeWith($endScope); + } + + foreach ($returnStatements as $statement) { + if ($finalScope === null) { + $finalScope = $statement->getScope(); + continue; + } + + $finalScope = $finalScope->mergeWith($statement->getScope()); + } + + if ($finalScope === null) { + return []; + } + + $errors = []; + foreach ($parameters as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + foreach ($this->processSingleParameter($finalScope, $functionDescription, $parameter) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleParameter( + Scope $scope, + string $functionDescription, + ExtendedParameterReflection $parameter, + ): array + { + $isParamOutType = true; + $outType = $parameter->getOutType(); + if ($outType === null) { + $isParamOutType = false; + $outType = $parameter->getType(); + } + + $outType = TypeUtils::resolveLateResolvableTypes($outType); + if (!$outType instanceof UnionType) { + return []; + } + + $variableExpr = new Variable($parameter->getName()); + $variableType = $scope->getType($variableExpr); + + $messages = []; + foreach ($outType->getTypes() as $type) { + if (!$type->isSuperTypeOf($variableType)->no()) { + continue; + } + + $errorBuilder = RuleErrorBuilder::message(sprintf( + '%s never assigns %s to &$%s so it can be removed from the %s.', + $functionDescription, + $type->describe(VerbosityLevel::getRecommendedLevelByType($type)), + $parameter->getName(), + $isParamOutType ? '@param-out type' : 'by-ref type', + ))->identifier(sprintf('%s.unusedType', $isParamOutType ? 'paramOut' : 'parameterByRef')); + if (!$isParamOutType) { + $errorBuilder->tip('You can narrow the parameter out type with @param-out PHPDoc tag.'); + } + + $messages[] = $errorBuilder->build(); + } + + return $messages; + } + +} diff --git a/src/Rules/TooWideTypehints/TooWidePropertyTypeRule.php b/src/Rules/TooWideTypehints/TooWidePropertyTypeRule.php new file mode 100644 index 00000000..e9f15727 --- /dev/null +++ b/src/Rules/TooWideTypehints/TooWidePropertyTypeRule.php @@ -0,0 +1,136 @@ + + */ +final class TooWidePropertyTypeRule implements Rule +{ + + public function __construct( + private ReadWritePropertiesExtensionProvider $extensionProvider, + private PropertyReflectionFinder $propertyReflectionFinder, + ) + { + } + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $classReflection = $node->getClassReflection(); + + foreach ($node->getProperties() as $property) { + if (!$property->isPrivate()) { + continue; + } + if ($property->isDeclaredInTrait()) { + continue; + } + if ($property->isPromoted()) { + continue; + } + $propertyName = $property->getName(); + if (!$classReflection->hasNativeProperty($propertyName)) { + continue; + } + + $propertyReflection = $classReflection->getNativeProperty($propertyName); + $propertyType = $propertyReflection->getWritableType(); + if (!$propertyType instanceof UnionType) { + continue; + } + foreach ($this->extensionProvider->getExtensions() as $extension) { + if ($extension->isAlwaysRead($propertyReflection, $propertyName)) { + continue 2; + } + if ($extension->isAlwaysWritten($propertyReflection, $propertyName)) { + continue 2; + } + if ($extension->isInitialized($propertyReflection, $propertyName)) { + continue 2; + } + } + + $assignedTypes = []; + foreach ($node->getPropertyAssigns() as $assign) { + $assignNode = $assign->getAssign(); + $assignPropertyReflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($assignNode->getPropertyFetch(), $assign->getScope()); + foreach ($assignPropertyReflections as $assignPropertyReflection) { + if ($propertyName !== $assignPropertyReflection->getName()) { + continue; + } + if ($propertyReflection->getDeclaringClass()->getName() !== $assignPropertyReflection->getDeclaringClass()->getName()) { + continue; + } + + $assignedTypes[] = $assignPropertyReflection->getScope()->getType($assignNode->getAssignedExpr()); + } + } + + if ($property->getDefault() !== null) { + $assignedTypes[] = $scope->getType($property->getDefault()); + } + + if (count($assignedTypes) === 0) { + continue; + } + + $assignedType = TypeCombinator::union(...$assignedTypes); + $propertyDescription = $this->describePropertyByName($propertyReflection, $propertyName); + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($propertyType, $assignedType); + foreach ($propertyType->getTypes() as $type) { + if (!$type->isSuperTypeOf($assignedType)->no()) { + continue; + } + + if ($property->getNativeType() === null && (new NullType())->isSuperTypeOf($type)->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s (%s) is never assigned %s so it can be removed from the property type.', + $propertyDescription, + $propertyType->describe($verbosityLevel), + $type->describe($verbosityLevel), + )) + ->identifier('property.unusedType') + ->line($property->getStartLine()) + ->build(); + } + + } + return $errors; + } + + private function describePropertyByName(PropertyReflection $property, string $propertyName): string + { + if (!$property->isStatic()) { + return sprintf('Property %s::$%s', $property->getDeclaringClass()->getDisplayName(), $propertyName); + } + + return sprintf('Static property %s::$%s', $property->getDeclaringClass()->getDisplayName(), $propertyName); + } + +} diff --git a/src/Rules/Traits/ConflictingTraitConstantsRule.php b/src/Rules/Traits/ConflictingTraitConstantsRule.php new file mode 100644 index 00000000..f07cd1cb --- /dev/null +++ b/src/Rules/Traits/ConflictingTraitConstantsRule.php @@ -0,0 +1,253 @@ + + */ +final class ConflictingTraitConstantsRule implements Rule +{ + + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$scope->isInClass()) { + return []; + } + + $classReflection = $scope->getClassReflection(); + $traitConstants = []; + foreach ($classReflection->getTraits(true) as $trait) { + foreach ($trait->getNativeReflection()->getReflectionConstants() as $constant) { + $traitConstants[] = $constant; + } + } + + $errors = []; + foreach ($node->consts as $const) { + foreach ($traitConstants as $traitConstant) { + if ($traitConstant->getName() !== $const->name->toString()) { + continue; + } + + foreach ($this->processSingleConstant($classReflection, $traitConstant, $node, $const->value) as $error) { + $errors[] = $error; + } + } + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleConstant(ClassReflection $classReflection, ReflectionClassConstant $traitConstant, Node\Stmt\ClassConst $classConst, Node\Expr $valueExpr): array + { + $errors = []; + if ($traitConstant->isPublic()) { + if ($classConst->isProtected()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Protected constant %s::%s overriding public constant %s::%s should also be public.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } elseif ($classConst->isPrivate()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Private constant %s::%s overriding public constant %s::%s should also be public.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } + } elseif ($traitConstant->isProtected()) { + if ($classConst->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Public constant %s::%s overriding protected constant %s::%s should also be protected.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } elseif ($classConst->isPrivate()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Private constant %s::%s overriding protected constant %s::%s should also be protected.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } + } elseif ($traitConstant->isPrivate()) { + if ($classConst->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Public constant %s::%s overriding private constant %s::%s should also be private.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } elseif ($classConst->isProtected()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Protected constant %s::%s overriding private constant %s::%s should also be private.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.visibility') + ->build(); + } + } + + if ($traitConstant->isFinal()) { + if (!$classConst->isFinal()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Non-final constant %s::%s overriding final constant %s::%s should also be final.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.nonFinal') + ->build(); + } + } elseif ($classConst->isFinal()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Final constant %s::%s overriding non-final constant %s::%s should also be non-final.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.final') + ->build(); + } + + $traitNativeType = $traitConstant->getType(); + $constantNativeType = $classConst->type; + $traitDeclaringClass = $traitConstant->getDeclaringClass(); + if ($traitNativeType === null) { + if ($constantNativeType !== null) { + $constantNativeTypeType = ParserNodeTypeToPHPStanType::resolve($constantNativeType, $classReflection); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s (%s) overriding constant %s::%s should not have a native type.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $constantNativeTypeType->describe(VerbosityLevel::typeOnly()), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + )) + ->nonIgnorable() + ->identifier('classConstant.nativeType') + ->build(); + } + } elseif ($constantNativeType === null) { + $traitNativeTypeType = TypehintHelper::decideTypeFromReflection($traitNativeType, null, $this->reflectionProvider->getClass($traitDeclaringClass->getName())); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s overriding constant %s::%s (%s) should also have native type %s.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + $traitNativeTypeType->describe(VerbosityLevel::typeOnly()), + $traitNativeTypeType->describe(VerbosityLevel::typeOnly()), + )) + ->nonIgnorable() + ->identifier('classConstant.missingNativeType') + ->build(); + } else { + $traitNativeTypeType = TypehintHelper::decideTypeFromReflection($traitNativeType, null, $this->reflectionProvider->getClass($traitDeclaringClass->getName())); + $constantNativeTypeType = ParserNodeTypeToPHPStanType::resolve($constantNativeType, $classReflection); + if (!$traitNativeTypeType->equals($constantNativeTypeType)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s (%s) overriding constant %s::%s (%s) should have the same native type %s.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $constantNativeTypeType->describe(VerbosityLevel::typeOnly()), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + $traitNativeTypeType->describe(VerbosityLevel::typeOnly()), + $traitNativeTypeType->describe(VerbosityLevel::typeOnly()), + )) + ->nonIgnorable() + ->identifier('classConstant.nativeType') + ->build(); + } + } + + $classConstantValueType = $this->initializerExprTypeResolver->getType($valueExpr, InitializerExprContext::fromClassReflection($classReflection)); + $traitConstantValueType = $this->initializerExprTypeResolver->getType( + $traitConstant->getValueExpression(), + InitializerExprContext::fromClass( + $traitDeclaringClass->getName(), + $traitDeclaringClass->getFileName() !== false ? $traitDeclaringClass->getFileName() : null, + ), + ); + if (!$classConstantValueType->equals($traitConstantValueType)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s with value %s overriding constant %s::%s with different value %s should have the same value.', + $classReflection->getDisplayName(), + $traitConstant->getName(), + $classConstantValueType->describe(VerbosityLevel::value()), + $traitConstant->getDeclaringClass()->getName(), + $traitConstant->getName(), + $traitConstantValueType->describe(VerbosityLevel::value()), + )) + ->nonIgnorable() + ->identifier('classConstant.value') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Traits/ConstantsInTraitsRule.php b/src/Rules/Traits/ConstantsInTraitsRule.php new file mode 100644 index 00000000..ddeb12ca --- /dev/null +++ b/src/Rules/Traits/ConstantsInTraitsRule.php @@ -0,0 +1,47 @@ + + */ +final class ConstantsInTraitsRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassConst::class; + } + + /** + * @param Node\Stmt\ClassConst $node + */ + public function processNode(Node $node, Scope $scope): array + { + if ($this->phpVersion->supportsConstantsInTraits()) { + return []; + } + + if (!$scope->isInTrait()) { + return []; + } + + return [ + RuleErrorBuilder::message( + 'Constant is declared inside a trait but is only supported on PHP 8.2 and later.', + )->identifier('classConstant.inTrait')->nonIgnorable()->build(), + ]; + } + +} diff --git a/src/Rules/Traits/NotAnalysedTraitRule.php b/src/Rules/Traits/NotAnalysedTraitRule.php new file mode 100644 index 00000000..0ecf325e --- /dev/null +++ b/src/Rules/Traits/NotAnalysedTraitRule.php @@ -0,0 +1,65 @@ + + */ +final class NotAnalysedTraitRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->isOnlyFilesAnalysis()) { + return []; + } + + $traitDeclarationData = $node->get(TraitDeclarationCollector::class); + $traitUseData = $node->get(TraitUseCollector::class); + + $declaredTraits = []; + foreach ($traitDeclarationData as $file => $declaration) { + foreach ($declaration as [$name, $line]) { + $declaredTraits[strtolower($name)] = [$file, $name, $line]; + } + } + + foreach ($traitUseData as $usedNamesData) { + foreach ($usedNamesData as $usedNames) { + foreach ($usedNames as $usedName) { + unset($declaredTraits[strtolower($usedName)]); + } + } + } + + $errors = []; + foreach ($declaredTraits as [$file, $name, $line]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Trait %s is used zero times and is not analysed.', + $name, + )) + ->file($file) + ->line($line) + ->identifier('trait.unused') + ->tip('See: https://phpstan.org/blog/how-phpstan-analyses-traits') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Traits/TraitAttributesRule.php b/src/Rules/Traits/TraitAttributesRule.php new file mode 100644 index 00000000..5479fa2b --- /dev/null +++ b/src/Rules/Traits/TraitAttributesRule.php @@ -0,0 +1,52 @@ + + */ +final class TraitAttributesRule implements Rule +{ + + public function __construct( + private AttributesCheck $attributesCheck, + ) + { + } + + public function getNodeType(): string + { + return InTraitNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $originalNode = $node->getOriginalNode(); + $errors = $this->attributesCheck->check( + $scope, + $originalNode->attrGroups, + Attribute::TARGET_CLASS, + 'class', + ); + + if (count($node->getTraitReflection()->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) { + $errors[] = RuleErrorBuilder::message('Attribute class AllowDynamicProperties cannot be used with trait.') + ->identifier('trait.allowDynamicProperties') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Traits/TraitDeclarationCollector.php b/src/Rules/Traits/TraitDeclarationCollector.php new file mode 100644 index 00000000..c0999188 --- /dev/null +++ b/src/Rules/Traits/TraitDeclarationCollector.php @@ -0,0 +1,30 @@ + + */ +final class TraitDeclarationCollector implements Collector +{ + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope) + { + if ($node->namespacedName === null) { + return null; + } + + return [$node->namespacedName->toString(), $node->getStartLine()]; + } + +} diff --git a/src/Rules/Traits/TraitUseCollector.php b/src/Rules/Traits/TraitUseCollector.php new file mode 100644 index 00000000..c22b82f7 --- /dev/null +++ b/src/Rules/Traits/TraitUseCollector.php @@ -0,0 +1,31 @@ +> + */ +final class TraitUseCollector implements Collector +{ + + public function getNodeType(): string + { + return Node\Stmt\TraitUse::class; + } + + /** + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + return array_values(array_map(static fn (Node\Name $traitName) => $traitName->toString(), $node->traits)); + } + +} diff --git a/src/Rules/Types/InvalidTypesInUnionRule.php b/src/Rules/Types/InvalidTypesInUnionRule.php new file mode 100644 index 00000000..78d62241 --- /dev/null +++ b/src/Rules/Types/InvalidTypesInUnionRule.php @@ -0,0 +1,126 @@ + + */ +final class InvalidTypesInUnionRule implements Rule +{ + + private const ONLY_STANDALONE_TYPES = [ + 'mixed', + 'never', + 'void', + ]; + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof Node\FunctionLike && !$node instanceof ClassPropertyNode) { + return []; + } + + if ($node instanceof Node\FunctionLike) { + return $this->processFunctionLikeNode($node); + } + + return $this->processClassPropertyNode($node); + } + + /** + * @return list + */ + private function processFunctionLikeNode(Node\FunctionLike $functionLike): array + { + $errors = []; + + foreach ($functionLike->getParams() as $param) { + if (!$param->type instanceof Node\ComplexType) { + continue; + } + + $errors = array_merge($errors, $this->processComplexType($param->type)); + } + + if ($functionLike->getReturnType() instanceof Node\ComplexType) { + $errors = array_merge($errors, $this->processComplexType($functionLike->getReturnType())); + } + + return $errors; + } + + /** + * @return list + */ + private function processClassPropertyNode(ClassPropertyNode $classPropertyNode): array + { + if (!$classPropertyNode->getNativeTypeNode() instanceof Node\ComplexType) { + return []; + } + + return $this->processComplexType($classPropertyNode->getNativeTypeNode()); + } + + /** + * @return list + */ + private function processComplexType(Node\ComplexType $complexType): array + { + if (!$complexType instanceof Node\UnionType && !$complexType instanceof Node\NullableType) { + return []; + } + + if ($complexType instanceof Node\UnionType) { + foreach ($complexType->types as $type) { + if (!$type instanceof Node\Identifier) { + continue; + } + + $typeString = $type->toLowerString(); + if (in_array($typeString, self::ONLY_STANDALONE_TYPES, true)) { + return [ + RuleErrorBuilder::message(sprintf('Type %s cannot be part of a union type declaration.', $type->toString())) + ->line($complexType->getStartLine()) + ->identifier(sprintf('unionType.%s', $typeString)) + ->nonIgnorable() + ->build(), + ]; + } + } + + return []; + } + + if ($complexType->type instanceof Node\Identifier) { + $complexTypeString = $complexType->type->toLowerString(); + if (in_array($complexTypeString, self::ONLY_STANDALONE_TYPES, true)) { + return [ + RuleErrorBuilder::message(sprintf('Type %s cannot be part of a nullable type declaration.', $complexType->type->toString())) + ->line($complexType->getStartLine()) + ->identifier(sprintf('nullableType.%s', $complexTypeString)) + ->nonIgnorable() + ->build(), + ]; + } + } + + return []; + } + +} diff --git a/src/Rules/UnusedFunctionParametersCheck.php b/src/Rules/UnusedFunctionParametersCheck.php new file mode 100644 index 00000000..39146406 --- /dev/null +++ b/src/Rules/UnusedFunctionParametersCheck.php @@ -0,0 +1,119 @@ + + */ + public function getUnusedParameters( + Scope $scope, + array $parameterVars, + array $statements, + string $unusedParameterMessage, + string $identifier, + ): array + { + $parameterNames = array_map(static function (Variable $variable): string { + if (!is_string($variable->name)) { + throw new ShouldNotHappenException(); + } + return $variable->name; + }, $parameterVars); + $unusedParameters = array_combine($parameterNames, $parameterVars); + foreach ($this->getUsedVariables($scope, $statements) as $variableName) { + if (!isset($unusedParameters[$variableName])) { + continue; + } + + unset($unusedParameters[$variableName]); + } + $errors = []; + foreach ($unusedParameters as $name => $variable) { + $errorBuilder = RuleErrorBuilder::message(sprintf($unusedParameterMessage, $name))->identifier($identifier); + if ($this->reportExactLine) { + $errorBuilder->line($variable->getStartLine()); + } + $errors[] = $errorBuilder->build(); + } + + return $errors; + } + + /** + * @param Node[]|Node|scalar|null $node + * @return string[] + */ + private function getUsedVariables(Scope $scope, $node): array + { + $variableNames = []; + if ($node instanceof Node) { + if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) { + $functionName = $this->reflectionProvider->resolveFunctionName($node->name, $scope); + if ($functionName === 'func_get_args' || $functionName === 'get_defined_vars') { + return $scope->getDefinedVariables(); + } + } + if ($node instanceof Variable && is_string($node->name) && $node->name !== 'this') { + return [$node->name]; + } + if ($node instanceof Node\ClosureUse && is_string($node->var->name)) { + return [$node->var->name]; + } + if ( + $node instanceof Node\Expr\FuncCall + && $node->name instanceof Node\Name + && (string) $node->name === 'compact' + ) { + foreach ($node->getArgs() as $arg) { + $argType = $scope->getType($arg->value); + if (!($argType instanceof ConstantStringType)) { + continue; + } + + $variableNames[] = $argType->getValue(); + } + } + foreach ($node->getSubNodeNames() as $subNodeName) { + if ($node instanceof Node\Expr\Closure && $subNodeName !== 'uses') { + continue; + } + $subNode = $node->{$subNodeName}; + $variableNames = array_merge($variableNames, $this->getUsedVariables($scope, $subNode)); + } + } elseif (is_array($node)) { + foreach ($node as $subNode) { + $variableNames = array_merge($variableNames, $this->getUsedVariables($scope, $subNode)); + } + } + + return $variableNames; + } + +} diff --git a/src/Rules/Variables/CompactVariablesRule.php b/src/Rules/Variables/CompactVariablesRule.php new file mode 100644 index 00000000..fec4adf5 --- /dev/null +++ b/src/Rules/Variables/CompactVariablesRule.php @@ -0,0 +1,91 @@ + + */ +final class CompactVariablesRule implements Rule +{ + + public function __construct(private bool $checkMaybeUndefinedVariables) + { + } + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->name instanceof Node\Expr) { + return []; + } + + $functionName = strtolower($node->name->toString()); + + if ($functionName !== 'compact') { + return []; + } + + $functionArguments = $node->getArgs(); + $messages = []; + + foreach ($functionArguments as $argument) { + $argumentType = $scope->getType($argument->value); + $constantStrings = $this->findConstantStrings($argumentType); + foreach ($constantStrings as $constantString) { + $variableName = $constantString->getValue(); + $scopeHasVariable = $scope->hasVariableType($variableName); + + if ($scopeHasVariable->no()) { + $messages[] = RuleErrorBuilder::message( + sprintf('Call to function compact() contains undefined variable $%s.', $variableName), + )->identifier('variable.undefined')->line($argument->getStartLine())->build(); + } elseif ($this->checkMaybeUndefinedVariables && $scopeHasVariable->maybe()) { + $messages[] = RuleErrorBuilder::message( + sprintf('Call to function compact() contains possibly undefined variable $%s.', $variableName), + )->identifier('variable.undefined')->line($argument->getStartLine())->build(); + } + } + } + + return $messages; + } + + /** + * @return array + */ + private function findConstantStrings(Type $type): array + { + if ($type instanceof ConstantStringType) { + return [$type]; + } + + if ($type instanceof ConstantArrayType) { + $result = []; + foreach ($type->getValueTypes() as $valueType) { + $constantStrings = $this->findConstantStrings($valueType); + $result = array_merge($result, $constantStrings); + } + + return $result; + } + + return []; + } + +} diff --git a/src/Rules/Variables/DefinedVariableRule.php b/src/Rules/Variables/DefinedVariableRule.php new file mode 100644 index 00000000..8f19054a --- /dev/null +++ b/src/Rules/Variables/DefinedVariableRule.php @@ -0,0 +1,73 @@ + + */ +final class DefinedVariableRule implements Rule +{ + + public function __construct( + private bool $cliArgumentsVariablesRegistered, + private bool $checkMaybeUndefinedVariables, + ) + { + } + + public function getNodeType(): string + { + return Variable::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!is_string($node->name)) { + return []; + } + + if ($this->cliArgumentsVariablesRegistered && in_array($node->name, [ + 'argc', + 'argv', + ], true)) { + $isInMain = !$scope->isInClass() && !$scope->isInAnonymousFunction() && $scope->getFunction() === null; + if ($isInMain) { + return []; + } + } + + if ($scope->isInExpressionAssign($node) || $scope->isUndefinedExpressionAllowed($node)) { + return []; + } + + if ($scope->hasVariableType($node->name)->no()) { + return [ + RuleErrorBuilder::message(sprintf('Undefined variable: $%s', $node->name)) + ->identifier('variable.undefined') + ->build(), + ]; + } elseif ( + $this->checkMaybeUndefinedVariables + && !$scope->hasVariableType($node->name)->yes() + ) { + return [ + RuleErrorBuilder::message(sprintf('Variable $%s might not be defined.', $node->name)) + ->identifier('variable.undefined') + ->build(), + ]; + } + + return []; + } + +} diff --git a/src/Rules/Variables/EmptyRule.php b/src/Rules/Variables/EmptyRule.php new file mode 100644 index 00000000..0a239f43 --- /dev/null +++ b/src/Rules/Variables/EmptyRule.php @@ -0,0 +1,68 @@ + + */ +final class EmptyRule implements Rule +{ + + public function __construct(private IssetCheck $issetCheck) + { + } + + public function getNodeType(): string + { + return Node\Expr\Empty_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $error = $this->issetCheck->check($node->expr, $scope, 'in empty()', 'empty', static function (Type $type): ?string { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + $isFalsey = $type->toBoolean()->isFalse(); + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + if ($isFalsey->yes()) { + return 'is always falsy'; + } + if ($isFalsey->no()) { + return 'is not falsy'; + } + + return 'is always null'; + } + + if ($isFalsey->yes()) { + return 'is always falsy'; + } + + if ($isFalsey->no()) { + return 'is not falsy'; + } + + return 'is not nullable'; + }); + + if ($error === null) { + return []; + } + + return [$error]; + } + +} diff --git a/src/Rules/Variables/IssetRule.php b/src/Rules/Variables/IssetRule.php new file mode 100644 index 00000000..452d48c8 --- /dev/null +++ b/src/Rules/Variables/IssetRule.php @@ -0,0 +1,52 @@ + + */ +final class IssetRule implements Rule +{ + + public function __construct(private IssetCheck $issetCheck) + { + } + + public function getNodeType(): string + { + return Node\Expr\Isset_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $messages = []; + foreach ($node->vars as $var) { + $error = $this->issetCheck->check($var, $scope, 'in isset()', 'isset', static function (Type $type): ?string { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + if ($isNull->yes()) { + return 'is always null'; + } + + return 'is not nullable'; + }); + if ($error === null) { + continue; + } + $messages[] = $error; + } + + return $messages; + } + +} diff --git a/src/Rules/Variables/NullCoalesceRule.php b/src/Rules/Variables/NullCoalesceRule.php new file mode 100644 index 00000000..f4804775 --- /dev/null +++ b/src/Rules/Variables/NullCoalesceRule.php @@ -0,0 +1,57 @@ + + */ +final class NullCoalesceRule implements Rule +{ + + public function __construct(private IssetCheck $issetCheck) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $typeMessageCallback = static function (Type $type): ?string { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + if ($isNull->yes()) { + return 'is always null'; + } + + return 'is not nullable'; + }; + + if ($node instanceof Node\Expr\BinaryOp\Coalesce) { + $error = $this->issetCheck->check($node->left, $scope, 'on left side of ??', 'nullCoalesce', $typeMessageCallback); + } elseif ($node instanceof Node\Expr\AssignOp\Coalesce) { + $error = $this->issetCheck->check($node->var, $scope, 'on left side of ??=', 'nullCoalesce', $typeMessageCallback); + } else { + return []; + } + + if ($error === null) { + return []; + } + + return [$error]; + } + +} diff --git a/src/Rules/Variables/ParameterOutAssignedTypeRule.php b/src/Rules/Variables/ParameterOutAssignedTypeRule.php new file mode 100644 index 00000000..52dcd8b8 --- /dev/null +++ b/src/Rules/Variables/ParameterOutAssignedTypeRule.php @@ -0,0 +1,121 @@ + + */ +final class ParameterOutAssignedTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return VariableAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $inFunction = $scope->getFunction(); + if ($inFunction === null) { + return []; + } + + if ($scope->isInAnonymousFunction()) { + return []; + } + + $variable = $node->getVariable(); + if (!is_string($variable->name)) { + return []; + } + + $parameters = $inFunction->getParameters(); + $foundParameter = null; + foreach ($parameters as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + if ($parameter->getName() !== $variable->name) { + continue; + } + + $foundParameter = $parameter; + break; + } + + if ($foundParameter === null) { + return []; + } + + $isParamOutType = true; + $outType = $foundParameter->getOutType(); + if ($outType === null) { + $isParamOutType = false; + $outType = $foundParameter->getType(); + } + + $outType = TypeUtils::resolveLateResolvableTypes($outType); + + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->getAssignedExpr(), + '', + static fn (Type $type): bool => $outType->isSuperTypeOf($type)->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $assignedExprType = $scope->getType($node->getAssignedExpr()); + if ($outType->isSuperTypeOf($assignedExprType)->yes()) { + return []; + } + + if ($inFunction instanceof ExtendedMethodReflection) { + $functionDescription = sprintf('method %s::%s()', $inFunction->getDeclaringClass()->getDisplayName(), $inFunction->getName()); + } else { + $functionDescription = sprintf('function %s()', $inFunction->getName()); + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($outType, $assignedExprType); + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Parameter &$%s %s of %s expects %s, %s given.', + $foundParameter->getName(), + $isParamOutType ? '@param-out type' : 'by-ref type', + $functionDescription, + $outType->describe($verbosityLevel), + $assignedExprType->describe($verbosityLevel), + ))->identifier(sprintf('%s.type', $isParamOutType ? 'paramOut' : 'parameterByRef')); + + if (!$isParamOutType) { + $errorBuilder->tip('You can change the parameter out type with @param-out PHPDoc tag.'); + } + + return [ + $errorBuilder->build(), + ]; + } + +} diff --git a/src/Rules/Variables/ParameterOutExecutionEndTypeRule.php b/src/Rules/Variables/ParameterOutExecutionEndTypeRule.php new file mode 100644 index 00000000..e36ec162 --- /dev/null +++ b/src/Rules/Variables/ParameterOutExecutionEndTypeRule.php @@ -0,0 +1,133 @@ + + */ +final class ParameterOutExecutionEndTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return ExecutionEndNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $inFunction = $scope->getFunction(); + if ($inFunction === null) { + return []; + } + + if ($scope->isInAnonymousFunction()) { + return []; + } + + $endNode = $node->getNode(); + if ($endNode instanceof Node\Stmt\Expression) { + $endNodeExpr = $endNode->expr; + $endNodeExprType = $scope->getType($endNodeExpr); + if ($endNodeExprType instanceof NeverType && $endNodeExprType->isExplicit()) { + return []; + } + } + + $parameters = $inFunction->getParameters(); + $errors = []; + foreach ($parameters as $parameter) { + if (!$parameter->passedByReference()->createsNewVariable()) { + continue; + } + + foreach ($this->processSingleParameter($scope, $inFunction, $parameter) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function processSingleParameter( + Scope $scope, + FunctionReflection|ExtendedMethodReflection $inFunction, + ExtendedParameterReflection $parameter, + ): array + { + $outType = $parameter->getOutType(); + if ($outType === null) { + return []; + } + + if ($scope->hasExpressionType(new ParameterVariableOriginalValueExpr($parameter->getName()))->no()) { + return []; + } + + $outType = TypeUtils::resolveLateResolvableTypes($outType); + + $variableExpr = new Node\Expr\Variable($parameter->getName()); + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $variableExpr, + '', + static fn (Type $type): bool => $outType->isSuperTypeOf($type)->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $assignedExprType = $scope->getType($variableExpr); + if ($outType->isSuperTypeOf($assignedExprType)->yes()) { + return []; + } + + if ($inFunction instanceof ExtendedMethodReflection) { + $functionDescription = sprintf('method %s::%s()', $inFunction->getDeclaringClass()->getDisplayName(), $inFunction->getName()); + } else { + $functionDescription = sprintf('function %s()', $inFunction->getName()); + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($outType, $assignedExprType); + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Parameter &$%s @param-out type of %s expects %s, %s given.', + $parameter->getName(), + $functionDescription, + $outType->describe($verbosityLevel), + $assignedExprType->describe($verbosityLevel), + ))->identifier(sprintf('paramOut.type')); + + return [ + $errorBuilder->build(), + ]; + } + +} diff --git a/src/Rules/Variables/UnsetRule.php b/src/Rules/Variables/UnsetRule.php new file mode 100644 index 00000000..87ab3405 --- /dev/null +++ b/src/Rules/Variables/UnsetRule.php @@ -0,0 +1,78 @@ + + */ +final class UnsetRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Unset_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $functionArguments = $node->vars; + $errors = []; + + foreach ($functionArguments as $argument) { + $error = $this->canBeUnset($argument, $scope); + if ($error === null) { + continue; + } + + $errors[] = $error; + } + + return $errors; + } + + private function canBeUnset(Node $node, Scope $scope): ?IdentifierRuleError + { + if ($node instanceof Node\Expr\Variable && is_string($node->name)) { + $hasVariable = $scope->hasVariableType($node->name); + if ($hasVariable->no()) { + return RuleErrorBuilder::message( + sprintf('Call to function unset() contains undefined variable $%s.', $node->name), + ) + ->line($node->getStartLine()) + ->identifier('unset.variable') + ->build(); + } + } elseif ($node instanceof Node\Expr\ArrayDimFetch && $node->dim !== null) { + $type = $scope->getType($node->var); + $dimType = $scope->getType($node->dim); + + if ($type->isOffsetAccessible()->no() || $type->hasOffsetValueType($dimType)->no()) { + return RuleErrorBuilder::message( + sprintf( + 'Cannot unset offset %s on %s.', + $dimType->describe(VerbosityLevel::value()), + $type->describe(VerbosityLevel::value()), + ), + ) + ->line($node->getStartLine()) + ->identifier('unset.offset') + ->build(); + } + + return $this->canBeUnset($node->var, $scope); + } + + return null; + } + +} diff --git a/src/Rules/Variables/VariableCloningRule.php b/src/Rules/Variables/VariableCloningRule.php new file mode 100644 index 00000000..374c5713 --- /dev/null +++ b/src/Rules/Variables/VariableCloningRule.php @@ -0,0 +1,68 @@ + + */ +final class VariableCloningRule implements Rule +{ + + public function __construct(private RuleLevelHelper $ruleLevelHelper) + { + } + + public function getNodeType(): string + { + return Clone_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + 'Cloning object of an unknown class %s.', + static fn (Type $type): bool => $type->isCloneable()->yes(), + ); + $type = $typeResult->getType(); + if ($type instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + if ($type->isCloneable()->yes()) { + return []; + } + + if ($node->expr instanceof Variable && is_string($node->expr->name)) { + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot clone non-object variable $%s of type %s.', + $node->expr->name, + $type->describe(VerbosityLevel::typeOnly()), + ))->identifier('clone.nonObject')->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Cannot clone %s.', + $type->describe(VerbosityLevel::typeOnly()), + ))->identifier('clone.nonObject')->build(), + ]; + } + +} diff --git a/src/Rules/Whitespace/FileWhitespaceRule.php b/src/Rules/Whitespace/FileWhitespaceRule.php new file mode 100644 index 00000000..29d32f3a --- /dev/null +++ b/src/Rules/Whitespace/FileWhitespaceRule.php @@ -0,0 +1,96 @@ + + */ +final class FileWhitespaceRule implements Rule +{ + + public function getNodeType(): string + { + return FileNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $nodes = $node->getNodes(); + if (count($nodes) === 0) { + return []; + } + + $firstNode = $nodes[0]; + $messages = []; + if ($firstNode instanceof Node\Stmt\InlineHTML && $firstNode->value === "\xef\xbb\xbf") { + $messages[] = RuleErrorBuilder::message('File begins with UTF-8 BOM character. This may cause problems when running the code in the web browser.') + ->identifier('whitespace.bom') + ->build(); + } + + $nodeTraverser = new NodeTraverser(); + $visitor = new class () extends NodeVisitorAbstract { + + /** @var Node[] */ + private array $lastNodes = []; + + /** + * @return int|null + */ + public function enterNode(Node $node) + { + if ($node instanceof Node\Stmt\Declare_) { + if ($node->stmts !== null && count($node->stmts) > 0) { + $this->lastNodes[] = $node->stmts[count($node->stmts) - 1]; + } + return null; + } + if ($node instanceof Node\Stmt\Namespace_) { + if (count($node->stmts) > 0) { + $this->lastNodes[] = $node->stmts[count($node->stmts) - 1]; + } + return null; + } + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + /** + * @return Node[] + */ + public function getLastNodes(): array + { + return $this->lastNodes; + } + + }; + $nodeTraverser->addVisitor($visitor); + $nodeTraverser->traverse($nodes); + + $lastNodes = $visitor->getLastNodes(); + $lastNodes[] = $nodes[count($nodes) - 1]; + foreach ($lastNodes as $lastNode) { + if (!$lastNode instanceof Node\Stmt\InlineHTML || Strings::match($lastNode->value, '#^(\s+)$#') === null) { + continue; + } + + $messages[] = RuleErrorBuilder::message('File ends with a trailing whitespace. This may cause problems when running the code in the web browser. Remove the closing ?> mark or remove the whitespace.')->line($lastNode->getStartLine()) + ->identifier('whitespace.fileEnd') + ->build(); + } + + return $messages; + } + +} diff --git a/src/ShouldNotHappenException.php b/src/ShouldNotHappenException.php new file mode 100644 index 00000000..0db43a84 --- /dev/null +++ b/src/ShouldNotHappenException.php @@ -0,0 +1,17 @@ + */ + private array $outputStream = []; + + /** @var array */ + private array $output = []; + + private function getOutputStream(bool $decorated = false, bool $verbose = false): StreamOutput + { + $kind = $decorated ? self::KIND_DECORATED : self::KIND_PLAIN; + $kind .= $verbose ? self::KIND_VERBOSE : self::KIND_NOT_VERBOSE; + + if (!isset($this->outputStream[$kind])) { + $resource = fopen('php://memory', 'w', false); + if ($resource === false) { + throw new ShouldNotHappenException(); + } + $verbosity = $verbose ? StreamOutput::VERBOSITY_VERBOSE : StreamOutput::VERBOSITY_NORMAL; + $this->outputStream[$kind] = new StreamOutput($resource, $verbosity, $decorated); + } + + return $this->outputStream[$kind]; + } + + protected function getOutput(bool $decorated = false, bool $verbose = false): Output + { + $kind = $decorated ? self::KIND_DECORATED : self::KIND_PLAIN; + $kind .= $verbose ? self::KIND_VERBOSE : self::KIND_NOT_VERBOSE; + + if (!isset($this->output[$kind])) { + $outputStream = $this->getOutputStream($decorated, $verbose); + $errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $outputStream); + $this->output[$kind] = new SymfonyOutput($outputStream, new SymfonyStyle($errorConsoleStyle)); + } + + return $this->output[$kind]; + } + + protected function getOutputContent(bool $decorated = false, bool $verbose = false): string + { + rewind($this->getOutputStream($decorated, $verbose)->getStream()); + + $contents = stream_get_contents($this->getOutputStream($decorated, $verbose)->getStream()); + if ($contents === false) { + throw new ShouldNotHappenException(); + } + + return $this->rtrimMultiline($contents); + } + + /** + * @param array{int, int}|int $numFileErrors + */ + protected function getAnalysisResult(array|int $numFileErrors, int $numGenericErrors): AnalysisResult + { + if (is_int($numFileErrors)) { + $offsetFileErrors = 0; + } else { + [$offsetFileErrors, $numFileErrors] = $numFileErrors; + } + + if (!in_array($numFileErrors, range(0, 6), true) || + !in_array($offsetFileErrors, range(0, 6), true) || + !in_array($numGenericErrors, range(0, 2), true) + ) { + throw new ShouldNotHappenException(); + } + + $fileErrors = array_slice([ + new Error('Foo', self::DIRECTORY_PATH . '/folder with unicode 😃/file name with "spaces" and unicode 😃.php', 4), + new Error('Foo', self::DIRECTORY_PATH . '/foo.php', 1), + new Error("Bar\nBar2", self::DIRECTORY_PATH . '/foo.php', 5, true, null, null, 'a tip'), + new Error("Bar\nBar2", self::DIRECTORY_PATH . '/folder with unicode 😃/file name with "spaces" and unicode 😃.php', 2), + new Error("Bar\nBar2", self::DIRECTORY_PATH . '/foo.php', null), + new Error('Foobar\\Buz', self::DIRECTORY_PATH . '/foo.php', 5, true, null, null, 'a tip', null, null, 'foobar.buz'), + ], $offsetFileErrors, $numFileErrors); + + $genericErrors = array_slice([ + 'first generic error', + 'second generic', + ], 0, $numGenericErrors); + + return new AnalysisResult( + $fileErrors, + $genericErrors, + [], + [], + [], + false, + null, + true, + 0, + false, + [], + ); + } + + private function rtrimMultiline(string $output): string + { + $result = array_map(static fn (string $line): string => rtrim($line, " \r\n"), explode("\n", $output)); + + return implode("\n", $result); + } + +} diff --git a/src/Testing/LevelsTestCase.php b/src/Testing/LevelsTestCase.php new file mode 100644 index 00000000..ffde9dca --- /dev/null +++ b/src/Testing/LevelsTestCase.php @@ -0,0 +1,210 @@ +> + */ + abstract public static function dataTopics(): array; + + abstract public function getDataPath(): string; + + abstract public function getPhpStanExecutablePath(): string; + + abstract public function getPhpStanConfigPath(): ?string; + + protected function getResultSuffix(): string + { + return ''; + } + + protected function shouldAutoloadAnalysedFile(): bool + { + return true; + } + + /** + * @dataProvider dataTopics + */ + public function testLevels( + string $topic, + ): void + { + $file = sprintf('%s' . DIRECTORY_SEPARATOR . '%s.php', $this->getDataPath(), $topic); + $command = escapeshellcmd($this->getPhpStanExecutablePath()); + $configPath = $this->getPhpStanConfigPath(); + $fileHelper = new FileHelper(__DIR__ . '/../..'); + + $previousMessages = []; + + $exceptions = []; + + exec(sprintf('%s %s clear-result-cache %s 2>&1', escapeshellarg(PHP_BINARY), $command, $configPath !== null ? '--configuration ' . escapeshellarg($configPath) : ''), $clearResultCacheOutputLines, $clearResultCacheExitCode); + if ($clearResultCacheExitCode !== 0) { + throw new ShouldNotHappenException('Could not clear result cache: ' . implode("\n", $clearResultCacheOutputLines)); + } + + putenv('__PHPSTAN_FORCE_VALIDATE_STUB_FILES=1'); + + foreach (range(0, 10) as $level) { + unset($outputLines); + exec(sprintf('%s %s analyse --no-progress --error-format=prettyJson --level=%d %s %s %s', escapeshellarg(PHP_BINARY), $command, $level, $configPath !== null ? '--configuration ' . escapeshellarg($configPath) : '', $this->shouldAutoloadAnalysedFile() ? sprintf('--autoload-file %s', escapeshellarg($file)) : '', escapeshellarg($file)), $outputLines); + + $output = implode("\n", $outputLines); + + try { + $actualJson = Json::decode($output, Json::FORCE_ARRAY); + } catch (JsonException) { + throw new JsonException(sprintf('Cannot decode: %s', $output)); + } + if (count($actualJson['files']) > 0) { + $normalizedFilePath = $fileHelper->normalizePath($file); + if (!isset($actualJson['files'][$normalizedFilePath])) { + $messagesBeforeDiffing = []; + } else { + $messagesBeforeDiffing = $actualJson['files'][$normalizedFilePath]['messages']; + } + + foreach ($this->getAdditionalAnalysedFiles() as $additionalAnalysedFile) { + $normalizedAdditionalFilePath = $fileHelper->normalizePath($additionalAnalysedFile); + if (!isset($actualJson['files'][$normalizedAdditionalFilePath])) { + continue; + } + + $messagesBeforeDiffing = array_merge($messagesBeforeDiffing, $actualJson['files'][$normalizedAdditionalFilePath]['messages']); + } + } else { + $messagesBeforeDiffing = []; + } + + $messages = []; + foreach ($messagesBeforeDiffing as $message) { + foreach ($previousMessages as $lastMessage) { + if ( + $message['message'] === $lastMessage['message'] + && $message['line'] === $lastMessage['line'] + ) { + continue 2; + } + } + + unset($message['tip']); + unset($message['identifier']); + + $messages[] = $message; + } + + $missingMessages = []; + foreach ($previousMessages as $previousMessage) { + foreach ($messagesBeforeDiffing as $message) { + if ( + $previousMessage['message'] === $message['message'] + && $previousMessage['line'] === $message['line'] + ) { + continue 2; + } + } + + unset($previousMessage['tip']); + + $missingMessages[] = $previousMessage; + } + + $previousMessages = array_merge($previousMessages, $messages); + $expectedJsonFile = sprintf('%s/%s-%d%s.json', $this->getDataPath(), $topic, $level, $this->getResultSuffix()); + + $exception = $this->compareFiles($expectedJsonFile, $messages); + if ($exception !== null) { + $exceptions[] = $exception; + } + + $expectedJsonMissingFile = sprintf('%s/%s-%d-missing%s.json', $this->getDataPath(), $topic, $level, $this->getResultSuffix()); + $exception = $this->compareFiles($expectedJsonMissingFile, $missingMessages); + if ($exception === null) { + continue; + } + + $exceptions[] = $exception; + } + + if (count($exceptions) > 0) { + throw $exceptions[0]; + } + } + + /** + * @return string[] + */ + public function getAdditionalAnalysedFiles(): array + { + return []; + } + + /** + * @param string[] $expectedMessages + */ + private function compareFiles(string $expectedJsonFile, array $expectedMessages): ?AssertionFailedError + { + if (count($expectedMessages) === 0) { + try { + self::ourCustomAssertFileDoesNotExist($expectedJsonFile); + return null; + } catch (AssertionFailedError $e) { + unlink($expectedJsonFile); + return $e; + } + } + + $actualOutput = Json::encode($expectedMessages, Json::PRETTY); + + try { + $this->assertJsonStringEqualsJsonFile( + $expectedJsonFile, + $actualOutput, + ); + } catch (AssertionFailedError $e) { + FileWriter::write($expectedJsonFile, $actualOutput); + return $e; + } + + return null; + } + + public static function ourCustomAssertFileDoesNotExist(string $filename, string $message = ''): void + { + // this method is no longer called assertFileDoesNotExist because this method is final in PHPUnit 10 + if (!method_exists(parent::class, 'assertFileDoesNotExist')) { + parent::assertFileNotExists($filename, $message); + return; + } + + parent::assertFileDoesNotExist($filename, $message); + } + +} diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php new file mode 100644 index 00000000..05da3639 --- /dev/null +++ b/src/Testing/PHPStanTestCase.php @@ -0,0 +1,251 @@ + */ + private static array $containers = []; + + /** @api */ + public static function getContainer(): Container + { + $additionalConfigFiles = static::getAdditionalConfigFiles(); + $additionalConfigFiles[] = __DIR__ . '/TestCase.neon'; + $cacheKey = sha1(implode("\n", $additionalConfigFiles)); + + if (!isset(self::$containers[$cacheKey])) { + $tmpDir = sys_get_temp_dir() . '/phpstan-tests'; + try { + DirectoryCreator::ensureDirectoryExists($tmpDir, 0777); + } catch (DirectoryCreatorException $e) { + self::fail($e->getMessage()); + } + + $rootDir = __DIR__ . '/../..'; + $fileHelper = new FileHelper($rootDir); + $rootDir = $fileHelper->normalizePath($rootDir, '/'); + $containerFactory = new ContainerFactory($rootDir); + $container = $containerFactory->create($tmpDir, array_merge([ + $containerFactory->getConfigDirectory() . '/config.level8.neon', + ], $additionalConfigFiles), []); + self::$containers[$cacheKey] = $container; + + foreach ($container->getParameter('bootstrapFiles') as $bootstrapFile) { + (static function (string $file) use ($container): void { + require_once $file; + })($bootstrapFile); + } + + if (PHP_VERSION_ID >= 80000) { + require_once __DIR__ . '/../../stubs/runtime/Enum/UnitEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/BackedEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnum.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnumUnitCase.php'; + require_once __DIR__ . '/../../stubs/runtime/Enum/ReflectionEnumBackedCase.php'; + } + } else { + ContainerFactory::postInitializeContainer(self::$containers[$cacheKey]); + } + + return self::$containers[$cacheKey]; + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return []; + } + + public static function getParser(): Parser + { + /** @var Parser $parser */ + $parser = self::getContainer()->getService('defaultAnalysisParser'); + return $parser; + } + + /** @api */ + public static function createReflectionProvider(): ReflectionProvider + { + return self::getContainer()->getByType(ReflectionProvider::class); + } + + public static function getReflector(): Reflector + { + return self::getContainer()->getService('betterReflectionReflector'); + } + + public static function getClassReflectionExtensionRegistryProvider(): ClassReflectionExtensionRegistryProvider + { + return self::getContainer()->getByType(ClassReflectionExtensionRegistryProvider::class); + } + + /** + * @param string[] $dynamicConstantNames + */ + public static function createScopeFactory(ReflectionProvider $reflectionProvider, TypeSpecifier $typeSpecifier, array $dynamicConstantNames = []): ScopeFactory + { + $container = self::getContainer(); + + if (count($dynamicConstantNames) === 0) { + $dynamicConstantNames = $container->getParameter('dynamicConstantNames'); + } + + $reflectionProviderProvider = new DirectReflectionProviderProvider($reflectionProvider); + $composerPhpVersionFactory = $container->getByType(ComposerPhpVersionFactory::class); + $constantResolver = new ConstantResolver($reflectionProviderProvider, $dynamicConstantNames, null, $composerPhpVersionFactory); + + $initializerExprTypeResolver = new InitializerExprTypeResolver( + $constantResolver, + $reflectionProviderProvider, + $container->getByType(PhpVersion::class), + $container->getByType(OperatorTypeSpecifyingExtensionRegistryProvider::class), + new OversizedArrayBuilder(), + $container->getParameter('usePathConstantsAsConstantString'), + ); + + return new ScopeFactory( + new DirectInternalScopeFactory( + $reflectionProvider, + $initializerExprTypeResolver, + $container->getByType(DynamicReturnTypeExtensionRegistryProvider::class), + $container->getByType(ExpressionTypeResolverExtensionRegistryProvider::class), + $container->getByType(ExprPrinter::class), + $typeSpecifier, + new PropertyReflectionFinder(), + self::getParser(), + $container->getByType(NodeScopeResolver::class), + new RicherScopeGetTypeHelper($initializerExprTypeResolver), + $container->getByType(PhpVersion::class), + $container->getByType(AttributeReflectionFactory::class), + $container->getParameter('phpVersion'), + $constantResolver, + ), + ); + } + + /** + * @param array $globalTypeAliases + */ + public static function createTypeAliasResolver(array $globalTypeAliases, ReflectionProvider $reflectionProvider): TypeAliasResolver + { + $container = self::getContainer(); + + return new UsefulTypeAliasResolver( + $globalTypeAliases, + $container->getByType(TypeStringResolver::class), + $container->getByType(TypeNodeResolver::class), + $reflectionProvider, + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return true; + } + + public static function getFileHelper(): FileHelper + { + return self::getContainer()->getByType(FileHelper::class); + } + + /** + * Provides a DIRECTORY_SEPARATOR agnostic assertion helper, to compare file paths. + * + */ + protected function assertSamePaths(string $expected, string $actual, string $message = ''): void + { + $expected = $this->getFileHelper()->normalizePath($expected); + $actual = $this->getFileHelper()->normalizePath($actual); + + $this->assertSame($expected, $actual, $message); + } + + /** + * @param Error[]|string[] $errors + */ + protected function assertNoErrors(array $errors): void + { + try { + $this->assertCount(0, $errors); + } catch (ExpectationFailedException $e) { + $messages = []; + foreach ($errors as $error) { + if ($error instanceof Error) { + $messages[] = sprintf("- %s\n in %s on line %d\n", rtrim($error->getMessage(), '.'), $error->getFile(), $error->getLine()); + } else { + $messages[] = $error; + } + } + + $this->fail($e->getMessage() . "\n\nEmitted errors:\n" . implode("\n", $messages)); + } + } + + protected function skipIfNotOnWindows(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + return; + } + + self::markTestSkipped(); + } + + protected function skipIfNotOnUnix(): void + { + if (DIRECTORY_SEPARATOR === '/') { + return; + } + + self::markTestSkipped(); + } + +} diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php new file mode 100644 index 00000000..2dcbaed1 --- /dev/null +++ b/src/Testing/RuleTestCase.php @@ -0,0 +1,227 @@ +> + */ + protected function getCollectors(): array + { + return []; + } + + /** + * @return ReadWritePropertiesExtension[] + */ + protected function getReadWritePropertiesExtensions(): array + { + return []; + } + + protected function getTypeSpecifier(): TypeSpecifier + { + return self::getContainer()->getService('typeSpecifier'); + } + + private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser + { + if ($this->analyser === null) { + $collectorRegistry = new CollectorRegistry($this->getCollectors()); + + $reflectionProvider = $this->createReflectionProvider(); + $typeSpecifier = $this->getTypeSpecifier(); + + $readWritePropertiesExtensions = $this->getReadWritePropertiesExtensions(); + $nodeScopeResolver = new NodeScopeResolver( + $reflectionProvider, + self::getContainer()->getByType(InitializerExprTypeResolver::class), + self::getReflector(), + self::getClassReflectionExtensionRegistryProvider(), + self::getContainer()->getByType(ParameterOutTypeExtensionProvider::class), + $this->getParser(), + self::getContainer()->getByType(FileTypeMapper::class), + self::getContainer()->getByType(StubPhpDocProvider::class), + self::getContainer()->getByType(PhpVersion::class), + self::getContainer()->getByType(SignatureMapProvider::class), + self::getContainer()->getByType(AttributeReflectionFactory::class), + self::getContainer()->getByType(PhpDocInheritanceResolver::class), + self::getContainer()->getByType(FileHelper::class), + $typeSpecifier, + self::getContainer()->getByType(DynamicThrowTypeExtensionProvider::class), + $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), + self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), + self::createScopeFactory($reflectionProvider, $typeSpecifier), + $this->shouldPolluteScopeWithLoopInitialAssignments(), + $this->shouldPolluteScopeWithAlwaysIterableForeach(), + self::getContainer()->getParameter('polluteScopeWithBlock'), + [], + [], + self::getContainer()->getParameter('universalObjectCratesClasses'), + self::getContainer()->getParameter('exceptions')['implicitThrows'], + $this->shouldTreatPhpDocTypesAsCertain(), + ); + $fileAnalyser = new FileAnalyser( + $this->createScopeFactory($reflectionProvider, $typeSpecifier), + $nodeScopeResolver, + $this->getParser(), + self::getContainer()->getByType(DependencyResolver::class), + new RuleErrorTransformer(), + new LocalIgnoresProcessor(), + ); + $this->analyser = new Analyser( + $fileAnalyser, + $ruleRegistry, + $collectorRegistry, + $nodeScopeResolver, + 50, + ); + } + + return $this->analyser; + } + + /** + * @param string[] $files + * @param list $expectedErrors + */ + public function analyse(array $files, array $expectedErrors): void + { + $actualErrors = $this->gatherAnalyserErrors($files); + $strictlyTypedSprintf = static function (int $line, string $message, ?string $tip): string { + $message = sprintf('%02d: %s', $line, $message); + if ($tip !== null) { + $message .= "\n 💡 " . $tip; + } + + return $message; + }; + + $expectedErrors = array_map( + static fn (array $error): string => $strictlyTypedSprintf($error[1], $error[0], $error[2] ?? null), + $expectedErrors, + ); + + $actualErrors = array_map( + static function (Error $error) use ($strictlyTypedSprintf): string { + $line = $error->getLine(); + if ($line === null) { + return $strictlyTypedSprintf(-1, $error->getMessage(), $error->getTip()); + } + return $strictlyTypedSprintf($line, $error->getMessage(), $error->getTip()); + }, + $actualErrors, + ); + + $this->assertSame(implode("\n", $expectedErrors) . "\n", implode("\n", $actualErrors) . "\n"); + } + + /** + * @param string[] $files + * @return list + */ + public function gatherAnalyserErrors(array $files): array + { + $ruleRegistry = new DirectRuleRegistry([ + $this->getRule(), + ]); + $files = array_map([$this->getFileHelper(), 'normalizePath'], $files); + $analyserResult = $this->getAnalyser($ruleRegistry)->analyse( + $files, + null, + null, + true, + ); + if (count($analyserResult->getInternalErrors()) > 0) { + $this->fail(implode("\n", array_map(static fn (InternalError $internalError) => $internalError->getMessage(), $analyserResult->getInternalErrors()))); + } + + if ($this->shouldFailOnPhpErrors() && count($analyserResult->getAllPhpErrors()) > 0) { + $this->fail(implode("\n", array_map( + static fn (Error $error): string => sprintf('%s on %s:%d', $error->getMessage(), $error->getFile(), $error->getLine()), + $analyserResult->getAllPhpErrors(), + ))); + } + + $finalizer = new AnalyserResultFinalizer( + $ruleRegistry, + new RuleErrorTransformer(), + $this->createScopeFactory($this->createReflectionProvider(), $this->getTypeSpecifier()), + new LocalIgnoresProcessor(), + true, + ); + + return $finalizer->finalize($analyserResult, false, true)->getAnalyserResult()->getUnorderedErrors(); + } + + protected function shouldPolluteScopeWithLoopInitialAssignments(): bool + { + return false; + } + + protected function shouldPolluteScopeWithAlwaysIterableForeach(): bool + { + return true; + } + + protected function shouldFailOnPhpErrors(): bool + { + return true; + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../conf/bleedingEdge.neon', + ]; + } + +} diff --git a/src/Testing/TestCaseSourceLocatorFactory.php b/src/Testing/TestCaseSourceLocatorFactory.php new file mode 100644 index 00000000..225139b3 --- /dev/null +++ b/src/Testing/TestCaseSourceLocatorFactory.php @@ -0,0 +1,92 @@ +> */ + private static array $composerSourceLocatorsCache = []; + + /** + * @param string[] $fileExtensions + * @param array{analyse?: array, analyseAndScan?: array}|null $excludePaths + */ + public function __construct( + private ComposerJsonAndInstalledJsonSourceLocatorMaker $composerJsonAndInstalledJsonSourceLocatorMaker, + private Parser $phpParser, + private Parser $php8Parser, + private FileNodesFetcher $fileNodesFetcher, + private PhpStormStubsSourceStubber $phpstormStubsSourceStubber, + private ReflectionSourceStubber $reflectionSourceStubber, + private PhpVersion $phpVersion, + private array $fileExtensions, + private ?array $excludePaths, + ) + { + } + + public function create(): SourceLocator + { + $classLoaders = ClassLoader::getRegisteredLoaders(); + $classLoaderReflection = new ReflectionClass(ClassLoader::class); + $cacheKey = sha1(serialize([ + $this->phpVersion->getVersionId(), + $this->fileExtensions, + $this->excludePaths, + ])); + if ($classLoaderReflection->hasProperty('vendorDir') && ! isset(self::$composerSourceLocatorsCache[$cacheKey])) { + $composerLocators = []; + $vendorDirProperty = $classLoaderReflection->getProperty('vendorDir'); + $vendorDirProperty->setAccessible(true); + foreach ($classLoaders as $classLoader) { + $composerProjectPath = dirname($vendorDirProperty->getValue($classLoader)); + if (!is_file($composerProjectPath . '/composer.json')) { + continue; + } + + $composerSourceLocator = $this->composerJsonAndInstalledJsonSourceLocatorMaker->create($composerProjectPath); + if ($composerSourceLocator === null) { + continue; + } + $composerLocators[] = $composerSourceLocator; + } + + self::$composerSourceLocatorsCache[$cacheKey] = $composerLocators; + } + + $locators = self::$composerSourceLocatorsCache[$cacheKey] ?? []; + $astLocator = new Locator($this->phpParser); + $astPhp8Locator = new Locator($this->php8Parser); + + $locators[] = new PhpInternalSourceLocator($astPhp8Locator, $this->phpstormStubsSourceStubber); + $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); + $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + + return new MemoizingSourceLocator(new AggregateSourceLocator($locators)); + } + +} diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php new file mode 100644 index 00000000..bc7dd7b1 --- /dev/null +++ b/src/Testing/TypeInferenceTestCase.php @@ -0,0 +1,368 @@ +getService('typeSpecifier'); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $resolver = new NodeScopeResolver( + $reflectionProvider, + self::getContainer()->getByType(InitializerExprTypeResolver::class), + self::getReflector(), + self::getClassReflectionExtensionRegistryProvider(), + self::getContainer()->getByType(ParameterOutTypeExtensionProvider::class), + self::getParser(), + self::getContainer()->getByType(FileTypeMapper::class), + self::getContainer()->getByType(StubPhpDocProvider::class), + self::getContainer()->getByType(PhpVersion::class), + self::getContainer()->getByType(SignatureMapProvider::class), + self::getContainer()->getByType(AttributeReflectionFactory::class), + self::getContainer()->getByType(PhpDocInheritanceResolver::class), + self::getContainer()->getByType(FileHelper::class), + $typeSpecifier, + self::getContainer()->getByType(DynamicThrowTypeExtensionProvider::class), + self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), + self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), + self::createScopeFactory($reflectionProvider, $typeSpecifier), + self::getContainer()->getParameter('polluteScopeWithLoopInitialAssignments'), + self::getContainer()->getParameter('polluteScopeWithAlwaysIterableForeach'), + self::getContainer()->getParameter('polluteScopeWithBlock'), + static::getEarlyTerminatingMethodCalls(), + static::getEarlyTerminatingFunctionCalls(), + self::getContainer()->getParameter('universalObjectCratesClasses'), + self::getContainer()->getParameter('exceptions')['implicitThrows'], + self::getContainer()->getParameter('treatPhpDocTypesAsCertain'), + ); + $resolver->setAnalysedFiles(array_map(static fn (string $file): string => $fileHelper->normalizePath($file), array_merge([$file], static::getAdditionalAnalysedFiles()))); + + $scopeFactory = self::createScopeFactory($reflectionProvider, $typeSpecifier, $dynamicConstantNames); + $scope = $scopeFactory->create(ScopeContext::create($file)); + + $resolver->processNodes( + self::getParser()->parseFile($file), + $scope, + $callback, + ); + } + + /** + * @api + * @param mixed ...$args + */ + public function assertFileAsserts( + string $assertType, + string $file, + ...$args, + ): void + { + if ($assertType === 'type') { + if ($args[0] instanceof Type) { + // backward compatibility + $expectedType = $args[0]; + $this->assertInstanceOf(ConstantScalarType::class, $expectedType); + $expected = $expectedType->getValue(); + $actualType = $args[1]; + $actual = $actualType->describe(VerbosityLevel::precise()); + } else { + $expected = $args[0]; + $actual = $args[1]; + } + + $this->assertSame( + $expected, + $actual, + sprintf('Expected type %s, got type %s in %s on line %d.', $expected, $actual, $file, $args[2]), + ); + } elseif ($assertType === 'variableCertainty') { + $expectedCertainty = $args[0]; + $actualCertainty = $args[1]; + $variableName = $args[2]; + $this->assertTrue( + $expectedCertainty->equals($actualCertainty), + sprintf('Expected %s, actual certainty of %s is %s in %s on line %d.', $expectedCertainty->describe(), $variableName, $actualCertainty->describe(), $file, $args[3]), + ); + } + } + + /** + * @api + * @return array + */ + public static function gatherAssertTypes(string $file): array + { + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + $relativePathHelper = new SystemAgnosticSimpleRelativePathHelper($fileHelper); + + $file = $fileHelper->normalizePath($file); + + $asserts = []; + self::processFile($file, static function (Node $node, Scope $scope) use (&$asserts, $file, $relativePathHelper): void { + if (!$node instanceof Node\Expr\FuncCall) { + return; + } + + $nameNode = $node->name; + if (!$nameNode instanceof Name) { + return; + } + + $functionName = $nameNode->toString(); + if (in_array(strtolower($functionName), ['asserttype', 'assertnativetype', 'assertvariablecertainty'], true)) { + self::fail(sprintf( + 'Missing use statement for %s() in %s on line %d.', + $functionName, + $relativePathHelper->getRelativePath($file), + $node->getStartLine(), + )); + } elseif ($functionName === 'PHPStan\\Testing\\assertType') { + $expectedType = $scope->getType($node->getArgs()[0]->value); + if (!$expectedType instanceof ConstantScalarType) { + self::fail(sprintf( + 'Expected type must be a literal string, %s given in %s on line %d.', + $expectedType->describe(VerbosityLevel::precise()), + $relativePathHelper->getRelativePath($file), + $node->getStartLine(), + )); + } + $actualType = $scope->getType($node->getArgs()[1]->value); + $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()]; + } elseif ($functionName === 'PHPStan\\Testing\\assertNativeType') { + $expectedType = $scope->getType($node->getArgs()[0]->value); + if (!$expectedType instanceof ConstantScalarType) { + self::fail(sprintf( + 'Expected type must be a literal string, %s given in %s on line %d.', + $expectedType->describe(VerbosityLevel::precise()), + $relativePathHelper->getRelativePath($file), + $node->getStartLine(), + )); + } + + $actualType = $scope->getNativeType($node->getArgs()[1]->value); + $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()]; + } elseif ($functionName === 'PHPStan\\Testing\\assertVariableCertainty') { + $certainty = $node->getArgs()[0]->value; + if (!$certainty instanceof StaticCall) { + self::fail(sprintf('First argument of %s() must be TrinaryLogic call', $functionName)); + } + if (!$certainty->class instanceof Node\Name) { + self::fail(sprintf('ERROR: Invalid TrinaryLogic call.')); + } + + if ($certainty->class->toString() !== 'PHPStan\\TrinaryLogic') { + self::fail(sprintf('ERROR: Invalid TrinaryLogic call.')); + } + + if (!$certainty->name instanceof Node\Identifier) { + self::fail(sprintf('ERROR: Invalid TrinaryLogic call.')); + } + + // @phpstan-ignore staticMethod.dynamicName + $expectedertaintyValue = TrinaryLogic::{$certainty->name->toString()}(); + $variable = $node->getArgs()[1]->value; + if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) { + $actualCertaintyValue = $scope->hasVariableType($variable->name); + $variableDescription = sprintf('variable $%s', $variable->name); + } elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) { + $offset = $scope->getType($variable->dim); + $actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset); + $variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise())); + } else { + self::fail(sprintf('ERROR: Invalid assertVariableCertainty call.')); + } + + $assert = ['variableCertainty', $file, $expectedertaintyValue, $actualCertaintyValue, $variableDescription, $node->getStartLine()]; + } else { + $correctFunction = null; + + $assertFunctions = [ + 'assertType' => 'PHPStan\\Testing\\assertType', + 'assertNativeType' => 'PHPStan\\Testing\\assertNativeType', + 'assertVariableCertainty' => 'PHPStan\\Testing\\assertVariableCertainty', + ]; + foreach ($assertFunctions as $assertFn => $fqFunctionName) { + if (stripos($functionName, $assertFn) === false) { + continue; + } + + $correctFunction = $fqFunctionName; + } + + if ($correctFunction === null) { + return; + } + + self::fail(sprintf( + 'Function %s imported with wrong namespace %s called in %s on line %d.', + $correctFunction, + $functionName, + $relativePathHelper->getRelativePath($file), + $node->getStartLine(), + )); + } + + if (count($node->getArgs()) !== 2) { + self::fail(sprintf( + 'ERROR: Wrong %s() call in %s on line %d.', + $functionName, + $relativePathHelper->getRelativePath($file), + $node->getStartLine(), + )); + } + + $asserts[$file . ':' . $node->getStartLine()] = $assert; + }); + + if (count($asserts) === 0) { + self::fail(sprintf('File %s does not contain any asserts', $file)); + } + + return $asserts; + } + + /** + * @api + * @return array + */ + public static function gatherAssertTypesFromDirectory(string $directory): array + { + $asserts = []; + foreach (self::findTestDataFilesFromDirectory($directory) as $path) { + foreach (self::gatherAssertTypes($path) as $key => $assert) { + $asserts[$key] = $assert; + } + } + + return $asserts; + } + + /** + * @return list + */ + public static function findTestDataFilesFromDirectory(string $directory): array + { + if (!is_dir($directory)) { + self::fail(sprintf('Directory %s does not exist.', $directory)); + } + + $finder = new Finder(); + $finder->followLinks(); + $files = []; + foreach ($finder->files()->name('*.php')->in($directory) as $fileInfo) { + $path = $fileInfo->getPathname(); + if (self::isFileLintSkipped($path)) { + continue; + } + $files[] = $path; + } + + return $files; + } + + /** + * From https://github.com/php-parallel-lint/PHP-Parallel-Lint/blob/0c2706086ac36dce31967cb36062ff8915fe03f7/bin/skip-linting.php + * + * Copyright (c) 2012, Jakub Onderka + */ + private static function isFileLintSkipped(string $file): bool + { + $f = @fopen($file, 'r'); + if ($f !== false) { + $firstLine = fgets($f); + if ($firstLine === false) { + return false; + } + + // ignore shebang line + if (strpos($firstLine, '#!') === 0) { + $firstLine = fgets($f); + if ($firstLine === false) { + return false; + } + } + + @fclose($f); + + if (preg_match('~value === self::YES; + } + + public function maybe(): bool + { + return $this->value === self::MAYBE; + } + + public function no(): bool + { + return $this->value === self::NO; + } + + public function toBooleanType(): BooleanType + { + if ($this->value === self::MAYBE) { + return new BooleanType(); + } + + return new ConstantBooleanType($this->value === self::YES); + } + + public function and(self ...$operands): self + { + $operandValues = array_column($operands, 'value'); + $operandValues[] = $this->value; + return self::create(min($operandValues)); + } + + /** + * @template T + * @param T[] $objects + * @param callable(T): self $callback + */ + public function lazyAnd( + array $objects, + callable $callback, + ): self + { + if ($this->no()) { + return $this; + } + + $results = []; + foreach ($objects as $object) { + $result = $callback($object); + if ($result->no()) { + return $result; + } + + $results[] = $result; + } + + return $this->and(...$results); + } + + public function or(self ...$operands): self + { + $operandValues = array_column($operands, 'value'); + $operandValues[] = $this->value; + return self::create(max($operandValues)); + } + + /** + * @template T + * @param T[] $objects + * @param callable(T): self $callback + */ + public function lazyOr( + array $objects, + callable $callback, + ): self + { + if ($this->yes()) { + return $this; + } + + $results = []; + foreach ($objects as $object) { + $result = $callback($object); + if ($result->yes()) { + return $result; + } + + $results[] = $result; + } + + return $this->or(...$results); + } + + public static function extremeIdentity(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + $operandValues = array_column($operands, 'value'); + $min = min($operandValues); + $max = max($operandValues); + return self::create($min === $max ? $min : self::MAYBE); + } + + /** + * @template T + * @param T[] $objects + * @param callable(T): self $callback + */ + public static function lazyExtremeIdentity( + array $objects, + callable $callback, + ): self + { + if ($objects === []) { + throw new ShouldNotHappenException(); + } + + $lastResult = null; + foreach ($objects as $object) { + $result = $callback($object); + if ($lastResult === null) { + $lastResult = $result; + continue; + } + if ($lastResult->equals($result)) { + continue; + } + + return self::createMaybe(); + } + + return $lastResult; + } + + public static function maxMin(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + $operandValues = array_column($operands, 'value'); + return self::create(max($operandValues) > 0 ? 1 : min($operandValues)); + } + + /** + * @template T + * @param T[] $objects + * @param callable(T): self $callback + */ + public static function lazyMaxMin( + array $objects, + callable $callback, + ): self + { + $results = []; + foreach ($objects as $object) { + $result = $callback($object); + if ($result->yes()) { + return $result; + } + + $results[] = $result; + } + + return self::maxMin(...$results); + } + + public function negate(): self + { + return self::create(-$this->value); + } + + public function equals(self $other): bool + { + return $this === $other; + } + + public function compareTo(self $other): ?self + { + if ($this->value > $other->value) { + return $this; + } elseif ($other->value > $this->value) { + return $other; + } + + return null; + } + + public function describe(): string + { + static $labels = [ + self::NO => 'No', + self::MAYBE => 'Maybe', + self::YES => 'Yes', + ]; + + return $labels[$this->value]; + } + +} diff --git a/src/Type/AcceptsResult.php b/src/Type/AcceptsResult.php new file mode 100644 index 00000000..f83b4056 --- /dev/null +++ b/src/Type/AcceptsResult.php @@ -0,0 +1,131 @@ + $reasons + */ + public function __construct( + public readonly TrinaryLogic $result, + public readonly array $reasons, + ) + { + } + + public function yes(): bool + { + return $this->result->yes(); + } + + public function maybe(): bool + { + return $this->result->maybe(); + } + + public function no(): bool + { + return $this->result->no(); + } + + public static function createYes(): self + { + return new self(TrinaryLogic::createYes(), []); + } + + /** + * @param list $reasons + */ + public static function createNo(array $reasons = []): self + { + return new self(TrinaryLogic::createNo(), $reasons); + } + + public static function createMaybe(): self + { + return new self(TrinaryLogic::createMaybe(), []); + } + + public static function createFromBoolean(bool $value): self + { + return new self(TrinaryLogic::createFromBoolean($value), []); + } + + public function and(self $other): self + { + return new self( + $this->result->and($other->result), + array_values(array_unique(array_merge($this->reasons, $other->reasons))), + ); + } + + public function or(self $other): self + { + return new self( + $this->result->or($other->result), + array_values(array_unique(array_merge($this->reasons, $other->reasons))), + ); + } + + /** + * @param callable(string): string $cb + */ + public function decorateReasons(callable $cb): self + { + $reasons = []; + foreach ($this->reasons as $reason) { + $reasons[] = $cb($reason); + } + + return new self($this->result, $reasons); + } + + public static function extremeIdentity(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + + $result = TrinaryLogic::extremeIdentity(...array_map(static fn (self $result) => $result->result, $operands)); + $reasons = []; + foreach ($operands as $operand) { + foreach ($operand->reasons as $reason) { + $reasons[] = $reason; + } + } + + return new self($result, array_values(array_unique($reasons))); + } + + public static function maxMin(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + + $result = TrinaryLogic::maxMin(...array_map(static fn (self $result) => $result->result, $operands)); + $reasons = []; + foreach ($operands as $operand) { + foreach ($operand->reasons as $reason) { + $reasons[] = $reason; + } + } + + return new self($result, array_values(array_unique($reasons))); + } + +} diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php new file mode 100644 index 00000000..e0c6f265 --- /dev/null +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -0,0 +1,503 @@ +isAcceptedBy($this, $strictTypes); + } + + $isArray = $type->isArray(); + $isList = $type->isList(); + + return new AcceptsResult($isArray->and($isList), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return new IsSuperTypeOfResult($type->isArray()->and($type->isList()), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isArray()->and($otherType->isList()), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'list'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $this->getIterableKeyType()->isSuperTypeOf($offsetType)->result->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new MixedType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($offsetType === null || (new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes()) { + return $this; + } + + return new ErrorType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + if ((new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes()) { + return $this; + } + + return new ErrorType(); + } + + public function unsetOffset(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return $this; + } + + return new ErrorType(); + } + + public function getKeysArray(): Type + { + return $this; + } + + public function getValuesArray(): Type + { + return $this; + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function fillKeysArray(Type $valueType): Type + { + return new MixedType(); + } + + public function flipArray(): Type + { + return new MixedType(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + if ($otherArraysType->isList()->yes()) { + return $this; + } + + return new MixedType(); + } + + public function popArray(): Type + { + return $this; + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + if ($preserveKeys->no()) { + return $this; + } + + return new MixedType(); + } + + public function searchArray(Type $needleType): Type + { + return new MixedType(); + } + + public function shiftArray(): Type + { + return $this; + } + + public function shuffleArray(): Type + { + return $this; + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ($preserveKeys->no()) { + return $this; + } + + if ((new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes()) { + return $this; + } + + return new MixedType(); + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + public function getFirstIterableKeyType(): Type + { + return new ConstantIntegerType(0); + } + + public function getLastIterableKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getIterableValueType(): Type + { + return new MixedType(); + } + + public function getFirstIterableValueType(): Type + { + return new MixedType(); + } + + public function getLastIterableValueType(): Type + { + return new MixedType(); + } + + public function isArray(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isOversizedArray(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isList(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return TypeCombinator::union( + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ); + } + + public function toFloat(): Type + { + return TypeCombinator::union( + new ConstantFloatType(0.0), + new ConstantFloatType(1.0), + ); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return $this; + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('list'); + } + +} diff --git a/src/Type/Accessory/AccessoryLiteralStringType.php b/src/Type/Accessory/AccessoryLiteralStringType.php new file mode 100644 index 00000000..247f0f92 --- /dev/null +++ b/src/Type/Accessory/AccessoryLiteralStringType.php @@ -0,0 +1,375 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isLiteralString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isLiteralString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isLiteralString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'literal-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new StringType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + if ($valueType->isLiteralString()->yes()) { + return $this; + } + + return new StringType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toBoolean(): BooleanType + { + return new BooleanType(); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('literal-string'); + } + +} diff --git a/src/Type/Accessory/AccessoryLowercaseStringType.php b/src/Type/Accessory/AccessoryLowercaseStringType.php new file mode 100644 index 00000000..ba559fe0 --- /dev/null +++ b/src/Type/Accessory/AccessoryLowercaseStringType.php @@ -0,0 +1,380 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isLowercaseString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isLowercaseString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isLowercaseString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'lowercase-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + if ($valueType->isLowercaseString()->yes()) { + return $this; + } + + return new StringType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toBoolean(): BooleanType + { + return new BooleanType(); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ( + $type->isString()->yes() + && $type->isLowercaseString()->no() + && ($type->isNumericString()->no() || $this->isNumericString()->no()) + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('lowercase-string'); + } + +} diff --git a/src/Type/Accessory/AccessoryNonEmptyStringType.php b/src/Type/Accessory/AccessoryNonEmptyStringType.php new file mode 100644 index 00000000..20beeed1 --- /dev/null +++ b/src/Type/Accessory/AccessoryNonEmptyStringType.php @@ -0,0 +1,385 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isNonEmptyString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type->isNonFalsyString()->yes()) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isNonEmptyString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isNonEmptyString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'non-empty-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + if ((new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + return new StringType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isNull()->yes()) { + return new ConstantBooleanType(false); + } + + if ($type->isString()->yes() && $type->isNonEmptyString()->no()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ConstantStringType && $typeToRemove->getValue() === '0') { + return TypeCombinator::intersect($this, new AccessoryNonFalsyStringType()); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('non-empty-string'); + } + +} diff --git a/src/Type/Accessory/AccessoryNonFalsyStringType.php b/src/Type/Accessory/AccessoryNonFalsyStringType.php new file mode 100644 index 00000000..a5d452d2 --- /dev/null +++ b/src/Type/Accessory/AccessoryNonFalsyStringType.php @@ -0,0 +1,374 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isNonFalsyString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isNonFalsyString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + if ($otherType instanceof AccessoryNonEmptyStringType) { + return IsSuperTypeOfResult::createYes(); + } + + return (new IsSuperTypeOfResult($otherType->isNonFalsyString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'non-falsy-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new StringType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + if ($valueType->isNonFalsyString()->yes()) { + return $this; + } + + return new StringType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + $falseyTypes = StaticTypeFactory::falsey(); + if ($falseyTypes->isSuperTypeOf($type)->yes()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('non-falsy-string'); + } + +} diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php new file mode 100644 index 00000000..670b0772 --- /dev/null +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -0,0 +1,387 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isNumericString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isNumericString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isNumericString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + if ($acceptingType->isNonFalsyString()->yes()) { + return AcceptsResult::createMaybe(); + } + + if ($acceptingType->isNonEmptyString()->yes()) { + return AcceptsResult::createYes(); + } + + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'numeric-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new StringType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new UnionType([ + $this->toInteger(), + $this->toFloat(), + ]); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return new IntegerType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isNull()->yes()) { + return new ConstantBooleanType(false); + } + + if ($type->isString()->yes() && $type->isNumericString()->no()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ConstantStringType && $typeToRemove->getValue() === '0') { + return TypeCombinator::intersect($this, new AccessoryNonFalsyStringType()); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('numeric-string'); + } + +} diff --git a/src/Type/Accessory/AccessoryType.php b/src/Type/Accessory/AccessoryType.php new file mode 100644 index 00000000..44789663 --- /dev/null +++ b/src/Type/Accessory/AccessoryType.php @@ -0,0 +1,11 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isUppercaseString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + return new IsSuperTypeOfResult($type->isUppercaseString(), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isUppercaseString(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'uppercase-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new IntersectionType([new StringType(), new AccessoryUppercaseStringType()]); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + if ($valueType->isUppercaseString()->yes()) { + return $this; + } + + return new StringType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toBoolean(): BooleanType + { + return new BooleanType(); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ( + $type->isString()->yes() + && $type->isUppercaseString()->no() + && ($type->isNumericString()->no() || $this->isNumericString()->no()) + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('uppercase-string'); + } + +} diff --git a/src/Type/Accessory/HasMethodType.php b/src/Type/Accessory/HasMethodType.php new file mode 100644 index 00000000..e84b4ecf --- /dev/null +++ b/src/Type/Accessory/HasMethodType.php @@ -0,0 +1,200 @@ +methodName); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createFromBoolean($this->equals($type)); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + return new IsSuperTypeOfResult($type->hasMethod($this->methodName), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + if ($this->isCallable()->yes() && $otherType->isCallable()->yes()) { + return IsSuperTypeOfResult::createYes(); + } + + if ($otherType instanceof self) { + $limit = IsSuperTypeOfResult::createYes(); + } else { + $limit = IsSuperTypeOfResult::createMaybe(); + } + + return $limit->and(new IsSuperTypeOfResult($otherType->hasMethod($this->methodName), [])); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->getCanonicalMethodName() === $type->getCanonicalMethodName(); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('hasMethod(%s)', $this->methodName); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + if ($this->getCanonicalMethodName() === strtolower($methodName)) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $method = new DummyMethodReflection($this->methodName); + return new CallbackUnresolvedMethodPrototypeReflection( + $method, + $method->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function isCallable(): TrinaryLogic + { + if ($this->getCanonicalMethodName() === '__invoke') { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function toString(): Type + { + if ($this->getCanonicalMethodName() === '__tostring') { + return new StringType(); + } + + return new ErrorType(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return [ + new TrivialParametersAcceptor(), + ]; + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode(''); // no PHPDoc representation + } + +} diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php new file mode 100644 index 00000000..5e2fe182 --- /dev/null +++ b/src/Type/Accessory/HasOffsetType.php @@ -0,0 +1,427 @@ +offsetType; + } + + public function getReferencedClasses(): array + { + return []; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isOffsetAccessible()->and($type->hasOffsetValueType($this->offsetType)), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + return new IsSuperTypeOfResult($type->isOffsetAccessible()->and($type->hasOffsetValueType($this->offsetType)), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + $result = new IsSuperTypeOfResult($otherType->isOffsetAccessible()->and($otherType->hasOffsetValueType($this->offsetType)), []); + + return $result + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->offsetType->equals($type->offsetType); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('hasOffset(%s)', $this->offsetType->describe($level)); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($offsetType->isConstantScalarValue()->yes() && $offsetType->equals($this->offsetType)) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new MixedType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + if ($this->offsetType->isSuperTypeOf($offsetType)->yes()) { + return new ErrorType(); + } + return $this; + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return new NonEmptyArrayType(); + } + + public function fillKeysArray(Type $valueType): Type + { + return new NonEmptyArrayType(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + if ($otherArraysType->hasOffsetValueType($this->offsetType)->yes()) { + return $this; + } + + return new MixedType(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + if ($preserveKeys->yes()) { + return $this; + } + + return new NonEmptyArrayType(); + } + + public function shuffleArray(): Type + { + return new NonEmptyArrayType(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ( + $this->offsetType->isSuperTypeOf($offsetType)->yes() + && ($lengthType->isNull()->yes() || IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($lengthType)->yes()) + ) { + return $preserveKeys->yes() + ? TypeCombinator::intersect($this, new NonEmptyArrayType()) + : new NonEmptyArrayType(); + } + + return new MixedType(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isList(): TrinaryLogic + { + if ($this->offsetType->isString()->yes()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getKeysArray(): Type + { + return new NonEmptyArrayType(); + } + + public function getValuesArray(): Type + { + return new NonEmptyArrayType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new MixedType(); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode(''); // no PHPDoc representation + } + +} diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php new file mode 100644 index 00000000..4be71e1b --- /dev/null +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -0,0 +1,487 @@ +offsetType; + } + + public function getValueType(): Type + { + return $this->valueType; + } + + public function getReferencedClasses(): array + { + return []; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult( + $type->isOffsetAccessible() + ->and($type->hasOffsetValueType($this->offsetType)) + ->and($this->valueType->accepts($type->getOffsetValueType($this->offsetType), $strictTypes)->result), + [], + ); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + $result = new IsSuperTypeOfResult($type->isOffsetAccessible()->and($type->hasOffsetValueType($this->offsetType)), []); + + return $result + ->and($this->valueType->isSuperTypeOf($type->getOffsetValueType($this->offsetType))); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + $result = new IsSuperTypeOfResult($otherType->isOffsetAccessible()->and($otherType->hasOffsetValueType($this->offsetType)), []); + + return $result + ->and($otherType->getOffsetValueType($this->offsetType)->isSuperTypeOf($this->valueType)) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->offsetType->equals($type->offsetType) + && $this->valueType->equals($type->valueType); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('hasOffsetValue(%s, %s)', $this->offsetType->describe($level), $this->valueType->describe($level)); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($offsetType->isConstantScalarValue()->yes() && $offsetType->equals($this->offsetType)) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($offsetType->isConstantScalarValue()->yes() && $offsetType->equals($this->offsetType)) { + return $this->valueType; + } + + return new MixedType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($offsetType === null) { + return $this; + } + + if (!$offsetType->equals($this->offsetType)) { + return $this; + } + + if (!$offsetType instanceof ConstantIntegerType && !$offsetType instanceof ConstantStringType) { + throw new ShouldNotHappenException(); + } + + return new self($offsetType, $valueType); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return new self($this->offsetType, $valueType); + } + + public function unsetOffset(Type $offsetType): Type + { + if ($this->offsetType->isSuperTypeOf($offsetType)->yes()) { + return new ErrorType(); + } + return $this; + } + + public function getKeysArray(): Type + { + return new NonEmptyArrayType(); + } + + public function getValuesArray(): Type + { + return new NonEmptyArrayType(); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return new NonEmptyArrayType(); + } + + public function fillKeysArray(Type $valueType): Type + { + return new NonEmptyArrayType(); + } + + public function flipArray(): Type + { + $valueType = $this->valueType->toArrayKey(); + if ($valueType instanceof ConstantIntegerType || $valueType instanceof ConstantStringType) { + return new self($valueType, $this->offsetType); + } + + return new MixedType(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + if ($otherArraysType->hasOffsetValueType($this->offsetType)->yes()) { + return $this; + } + + return new MixedType(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + if ($preserveKeys->yes()) { + return $this; + } + + return new NonEmptyArrayType(); + } + + public function searchArray(Type $needleType): Type + { + if ( + $needleType instanceof ConstantScalarType && $this->valueType instanceof ConstantScalarType + && $needleType->getValue() === $this->valueType->getValue() + ) { + return $this->offsetType; + } + + return new MixedType(); + } + + public function shuffleArray(): Type + { + return new NonEmptyArrayType(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ( + $this->offsetType->isSuperTypeOf($offsetType)->yes() + && ($lengthType->isNull()->yes() || IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($lengthType)->yes()) + ) { + return $preserveKeys->yes() + ? TypeCombinator::intersect($this, new NonEmptyArrayType()) + : new NonEmptyArrayType(); + } + + return new MixedType(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isList(): TrinaryLogic + { + if ($this->offsetType->isString()->yes()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new MixedType(); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + $newValueType = $cb($this->valueType); + if ($newValueType === $this->valueType) { + return $this; + } + + return new self($this->offsetType, $newValueType); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $newValueType = $cb($this->valueType, $right->getOffsetValueType($this->offsetType)); + if ($newValueType === $this->valueType) { + return $this; + } + + return new self($this->offsetType, $newValueType); + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode(''); // no PHPDoc representation + } + +} diff --git a/src/Type/Accessory/HasPropertyType.php b/src/Type/Accessory/HasPropertyType.php new file mode 100644 index 00000000..f1492bc2 --- /dev/null +++ b/src/Type/Accessory/HasPropertyType.php @@ -0,0 +1,159 @@ +propertyName; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createFromBoolean($this->equals($type)); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + return new IsSuperTypeOfResult($type->hasProperty($this->propertyName), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + if ($otherType instanceof self) { + $limit = IsSuperTypeOfResult::createYes(); + } else { + $limit = IsSuperTypeOfResult::createMaybe(); + } + + return $limit->and(new IsSuperTypeOfResult($otherType->hasProperty($this->propertyName), [])); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->propertyName === $type->propertyName; + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('hasProperty(%s)', $this->propertyName); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + if ($this->propertyName === $propertyName) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return [new TrivialParametersAcceptor()]; + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode(''); // no PHPDoc representation + } + +} diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php new file mode 100644 index 00000000..d3fb4af1 --- /dev/null +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -0,0 +1,479 @@ +isAcceptedBy($this, $strictTypes); + } + + $isArray = $type->isArray(); + $isIterableAtLeastOnce = $type->isIterableAtLeastOnce(); + + return new AcceptsResult($isArray->and($isIterableAtLeastOnce), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return new IsSuperTypeOfResult($type->isArray()->and($type->isIterableAtLeastOnce()), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isArray()->and($otherType->isIterableAtLeastOnce()), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'non-empty-array'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new MixedType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function getKeysArray(): Type + { + return $this; + } + + public function getValuesArray(): Type + { + return $this; + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function fillKeysArray(Type $valueType): Type + { + return $this; + } + + public function flipArray(): Type + { + return $this; + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return new MixedType(); + } + + public function popArray(): Type + { + return new MixedType(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function searchArray(Type $needleType): Type + { + return new MixedType(); + } + + public function shiftArray(): Type + { + return new MixedType(); + } + + public function shuffleArray(): Type + { + return $this; + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ( + (new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes() + && ($lengthType->isNull()->yes() || IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($lengthType)->yes()) + ) { + return $this; + } + + return new MixedType(); + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getArraySize(): Type + { + return IntegerRangeType::fromInterval(1, null); + } + + public function getIterableKeyType(): Type + { + return new MixedType(); + } + + public function getFirstIterableKeyType(): Type + { + return new MixedType(); + } + + public function getLastIterableKeyType(): Type + { + return new MixedType(); + } + + public function getIterableValueType(): Type + { + return new MixedType(); + } + + public function getFirstIterableValueType(): Type + { + return new MixedType(); + } + + public function getLastIterableValueType(): Type + { + return new MixedType(); + } + + public function isArray(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isOversizedArray(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isList(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isArray()->yes() && $type->isIterableAtLeastOnce()->no()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ConstantIntegerType(1); + } + + public function toFloat(): Type + { + return new ConstantFloatType(1.0); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return $this; + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('non-empty-array'); + } + +} diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php new file mode 100644 index 00000000..a9a64598 --- /dev/null +++ b/src/Type/Accessory/OversizedArrayType.php @@ -0,0 +1,463 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isArray()->and($type->isIterableAtLeastOnce()), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return new IsSuperTypeOfResult($type->isArray()->and($type->isOversizedArray()), []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isArray()->and($otherType->isOversizedArray()), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'oversized-array'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new MixedType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function getKeysArray(): Type + { + return $this; + } + + public function getValuesArray(): Type + { + return $this; + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function fillKeysArray(Type $valueType): Type + { + return $this; + } + + public function flipArray(): Type + { + return $this; + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this; + } + + public function popArray(): Type + { + return $this; + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function searchArray(Type $needleType): Type + { + return new MixedType(); + } + + public function shiftArray(): Type + { + return $this; + } + + public function shuffleArray(): Type + { + return $this; + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + return new MixedType(); + } + + public function getFirstIterableKeyType(): Type + { + return new MixedType(); + } + + public function getLastIterableKeyType(): Type + { + return new MixedType(); + } + + public function getIterableValueType(): Type + { + return new MixedType(); + } + + public function getFirstIterableValueType(): Type + { + return new MixedType(); + } + + public function getLastIterableValueType(): Type + { + return new MixedType(); + } + + public function isArray(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isOversizedArray(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isList(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ConstantIntegerType(1); + } + + public function toFloat(): Type + { + return new ConstantFloatType(1.0); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new MixedType(); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode(''); // no PHPDoc representation + } + +} diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php new file mode 100644 index 00000000..ebfb15ee --- /dev/null +++ b/src/Type/ArrayType.php @@ -0,0 +1,572 @@ +describe(VerbosityLevel::value()) === '(int|string)') { + $keyType = new MixedType(); + } + if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { + $keyType = new UnionType([new StringType(), new IntegerType()]); + } + + $this->keyType = $keyType; + } + + public function getKeyType(): Type + { + return $this->keyType; + } + + public function getItemType(): Type + { + return $this->itemType; + } + + public function getReferencedClasses(): array + { + return array_merge( + $this->keyType->getReferencedClasses(), + $this->getItemType()->getReferencedClasses(), + ); + } + + public function getConstantArrays(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if ($type instanceof ConstantArrayType) { + $result = AcceptsResult::createYes(); + $thisKeyType = $this->keyType; + $itemType = $this->getItemType(); + foreach ($type->getKeyTypes() as $i => $keyType) { + $valueType = $type->getValueTypes()[$i]; + $acceptsKey = $thisKeyType->accepts($keyType, $strictTypes); + $acceptsValue = $itemType->accepts($valueType, $strictTypes); + $result = $result->and($acceptsKey)->and($acceptsValue); + } + + return $result; + } + + if ($type instanceof ArrayType) { + return $this->getItemType()->accepts($type->getItemType(), $strictTypes) + ->and($this->keyType->accepts($type->keyType, $strictTypes)); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self || $type instanceof ConstantArrayType) { + return $this->getItemType()->isSuperTypeOf($type->getItemType()) + ->and($this->getIterableKeyType()->isSuperTypeOf($type->getIterableKeyType())); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->getItemType()->equals($type->getIterableValueType()) + && $this->keyType->equals($type->keyType); + } + + public function describe(VerbosityLevel $level): string + { + $isMixedKeyType = $this->keyType instanceof MixedType && $this->keyType->describe(VerbosityLevel::precise()) === 'mixed' && !$this->keyType->isExplicitMixed(); + $isMixedItemType = $this->itemType instanceof MixedType && $this->itemType->describe(VerbosityLevel::precise()) === 'mixed' && !$this->itemType->isExplicitMixed(); + + $valueHandler = function () use ($level, $isMixedKeyType, $isMixedItemType): string { + if ($isMixedKeyType || $this->keyType instanceof NeverType) { + if ($isMixedItemType || $this->itemType instanceof NeverType) { + return 'array'; + } + + return sprintf('array<%s>', $this->itemType->describe($level)); + } + + return sprintf('array<%s, %s>', $this->keyType->describe($level), $this->itemType->describe($level)); + }; + + return $level->handle( + $valueHandler, + $valueHandler, + function () use ($level, $isMixedKeyType, $isMixedItemType): string { + if ($isMixedKeyType) { + if ($isMixedItemType) { + return 'array'; + } + + return sprintf('array<%s>', $this->itemType->describe($level)); + } + + return sprintf('array<%s, %s>', $this->keyType->describe($level), $this->itemType->describe($level)); + }, + ); + } + + public function generalizeValues(): self + { + return new self($this->keyType, $this->itemType->generalize(GeneralizePrecision::lessSpecific())); + } + + public function getKeysArray(): Type + { + return TypeCombinator::intersect(new self(new IntegerType(), $this->getIterableKeyType()), new AccessoryArrayListType()); + } + + public function getValuesArray(): Type + { + return TypeCombinator::intersect(new self(new IntegerType(), $this->itemType), new AccessoryArrayListType()); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + $keyType = $this->keyType; + if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { + return new BenevolentUnionType([new IntegerType(), new StringType()]); + } + if ($keyType instanceof StrictMixedType) { + return new BenevolentUnionType([new IntegerType(), new StringType()]); + } + + return $keyType; + } + + public function getFirstIterableKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getLastIterableKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getIterableValueType(): Type + { + return $this->getItemType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->getItemType(); + } + + public function getLastIterableValueType(): Type + { + return $this->getItemType(); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isList(): TrinaryLogic + { + if (IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($this->getKeyType())->no()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isInteger()->yes()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + $offsetType = $offsetType->toArrayKey(); + + if ($this->getKeyType()->isSuperTypeOf($offsetType)->no() + && ($offsetType->isString()->no() || !$offsetType->isConstantScalarValue()->no()) + ) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + $offsetType = $offsetType->toArrayKey(); + if ($this->getKeyType()->isSuperTypeOf($offsetType)->no() + && ($offsetType->isString()->no() || !$offsetType->isConstantScalarValue()->no()) + ) { + return new ErrorType(); + } + + $type = $this->getItemType(); + if ($type instanceof ErrorType) { + return new MixedType(); + } + + return $type; + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($offsetType === null) { + $isKeyTypeInteger = $this->keyType->isInteger(); + if ($isKeyTypeInteger->no()) { + $offsetType = new IntegerType(); + } elseif ($isKeyTypeInteger->yes()) { + $offsetType = $this->keyType; + } else { + $integerTypes = []; + TypeTraverser::map($this->keyType, static function (Type $type, callable $traverse) use (&$integerTypes): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + $isInteger = $type->isInteger(); + if ($isInteger->yes()) { + $integerTypes[] = $type; + } + + return $type; + }); + if (count($integerTypes) === 0) { + $offsetType = $this->keyType; + } else { + $offsetType = TypeCombinator::union(...$integerTypes); + } + } + } else { + $offsetType = $offsetType->toArrayKey(); + } + + if ($offsetType instanceof ConstantStringType || $offsetType instanceof ConstantIntegerType) { + if ($offsetType->isSuperTypeOf($this->keyType)->yes()) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType($offsetType, $valueType); + return $builder->getArray(); + } + + return TypeCombinator::intersect( + new self( + TypeCombinator::union($this->keyType, $offsetType), + TypeCombinator::union($this->itemType, $valueType), + ), + new HasOffsetValueType($offsetType, $valueType), + new NonEmptyArrayType(), + ); + } + + return TypeCombinator::intersect( + new self( + TypeCombinator::union($this->keyType, $offsetType), + $unionValues ? TypeCombinator::union($this->itemType, $valueType) : $valueType, + ), + new NonEmptyArrayType(), + ); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return new self( + $this->keyType, + TypeCombinator::union($this->itemType, $valueType), + ); + } + + public function unsetOffset(Type $offsetType): Type + { + $offsetType = $offsetType->toArrayKey(); + + if ( + ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) + && !$this->keyType->isSuperTypeOf($offsetType)->no() + ) { + $keyType = TypeCombinator::remove($this->keyType, $offsetType); + if ($keyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new self($keyType, $this->itemType); + } + + return $this; + } + + public function fillKeysArray(Type $valueType): Type + { + $itemType = $this->getItemType(); + if ($itemType->isInteger()->no()) { + $stringKeyType = $itemType->toString(); + if ($stringKeyType instanceof ErrorType) { + return $stringKeyType; + } + + return new ArrayType($stringKeyType, $valueType); + } + + return new ArrayType($itemType, $valueType); + } + + public function flipArray(): Type + { + return new self($this->getIterableValueType()->toArrayKey(), $this->getIterableKeyType()); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + $isKeySuperType = $otherArraysType->getIterableKeyType()->isSuperTypeOf($this->getIterableKeyType()); + if ($isKeySuperType->no()) { + return ConstantArrayTypeBuilder::createEmpty()->getArray(); + } + + if ($isKeySuperType->yes()) { + return $this; + } + + return new self($otherArraysType->getIterableKeyType(), $this->getIterableValueType()); + } + + public function popArray(): Type + { + return $this; + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function searchArray(Type $needleType): Type + { + return TypeCombinator::union($this->getIterableKeyType(), new ConstantBooleanType(false)); + } + + public function shiftArray(): Type + { + return $this; + } + + public function shuffleArray(): Type + { + return TypeCombinator::intersect(new self(new IntegerType(), $this->itemType), new AccessoryArrayListType()); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this; + } + + public function isCallable(): TrinaryLogic + { + return TrinaryLogic::createMaybe()->and($this->itemType->isString()); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + if ($this->isCallable()->no()) { + throw new ShouldNotHappenException(); + } + + return [new TrivialParametersAcceptor()]; + } + + public function toInteger(): Type + { + return TypeCombinator::union( + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ); + } + + public function toFloat(): Type + { + return TypeCombinator::union( + new ConstantFloatType(0.0), + new ConstantFloatType(1.0), + ); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if ($receivedType->isArray()->yes()) { + $keyTypeMap = $this->getIterableKeyType()->inferTemplateTypes($receivedType->getIterableKeyType()); + $itemTypeMap = $this->getItemType()->inferTemplateTypes($receivedType->getIterableValueType()); + + return $keyTypeMap->union($itemTypeMap); + } + + return TemplateTypeMap::createEmpty(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + + return array_merge( + $this->getIterableKeyType()->getReferencedTemplateTypes($variance), + $this->getItemType()->getReferencedTemplateTypes($variance), + ); + } + + public function traverse(callable $cb): Type + { + $keyType = $cb($this->keyType); + $itemType = $cb($this->itemType); + + if ($keyType !== $this->keyType || $itemType !== $this->itemType) { + if ($keyType instanceof NeverType && $itemType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new self($keyType, $itemType); + } + + return $this; + } + + public function toPhpDocNode(): TypeNode + { + $isMixedKeyType = $this->keyType instanceof MixedType && $this->keyType->describe(VerbosityLevel::precise()) === 'mixed' && !$this->keyType->isExplicitMixed(); + $isMixedItemType = $this->itemType instanceof MixedType && $this->itemType->describe(VerbosityLevel::precise()) === 'mixed' && !$this->itemType->isExplicitMixed(); + + if ($isMixedKeyType) { + if ($isMixedItemType) { + return new IdentifierTypeNode('array'); + } + + return new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + $this->itemType->toPhpDocNode(), + ], + ); + } + + return new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + $this->keyType->toPhpDocNode(), + $this->itemType->toPhpDocNode(), + ], + ); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $keyType = $cb($this->keyType, $right->getIterableKeyType()); + $itemType = $cb($this->itemType, $right->getIterableValueType()); + + if ($keyType !== $this->keyType || $itemType !== $this->itemType) { + if ($keyType instanceof NeverType && $itemType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new self($keyType, $itemType); + } + + return $this; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove->isConstantArray()->yes() && $typeToRemove->isIterableAtLeastOnce()->no()) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + if ($typeToRemove instanceof NonEmptyArrayType) { + return new ConstantArrayType([], []); + } + + return null; + } + + public function getFiniteTypes(): array + { + return []; + } + +} diff --git a/src/Type/BenevolentUnionType.php b/src/Type/BenevolentUnionType.php new file mode 100644 index 00000000..4771670b --- /dev/null +++ b/src/Type/BenevolentUnionType.php @@ -0,0 +1,182 @@ +getTypes() as $type) { + $result = $getType($type); + if ($result instanceof ErrorType) { + continue; + } + + $resultTypes[] = $result; + } + + if (count($resultTypes) === 0) { + return new ErrorType(); + } + + return TypeUtils::toBenevolentUnion(TypeCombinator::union(...$resultTypes)); + } + + protected function pickFromTypes( + callable $getValues, + callable $criteria, + ): array + { + $values = []; + foreach ($this->getTypes() as $type) { + $innerValues = $getValues($type); + if ($innerValues === [] && $criteria($type)) { + return []; + } + + foreach ($innerValues as $innerType) { + $values[] = $innerType; + } + } + + return $values; + } + + public function getOffsetValueType(Type $offsetType): Type + { + $types = []; + foreach ($this->getTypes() as $innerType) { + $valueType = $innerType->getOffsetValueType($offsetType); + if ($valueType instanceof ErrorType) { + continue; + } + + $types[] = $valueType; + } + + if (count($types) === 0) { + return new ErrorType(); + } + + return TypeUtils::toBenevolentUnion(TypeCombinator::union(...$types)); + } + + protected function unionResults(callable $getResult): TrinaryLogic + { + return TrinaryLogic::createNo()->lazyOr($this->getTypes(), $getResult); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + $result = AcceptsResult::createNo(); + foreach ($this->getTypes() as $innerType) { + $result = $result->or($acceptingType->accepts($innerType, $strictTypes)); + } + + return $result; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + + foreach ($this->getTypes() as $type) { + $types = $types->benevolentUnion($type->inferTemplateTypes($receivedType)); + } + + return $types; + } + + public function inferTemplateTypesOn(Type $templateType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + + foreach ($this->getTypes() as $type) { + $types = $types->benevolentUnion($templateType->inferTemplateTypes($type)); + } + + return $types; + } + + public function traverse(callable $cb): Type + { + $types = []; + $changed = false; + + foreach ($this->getTypes() as $type) { + $newType = $cb($type); + if ($type !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeUtils::toBenevolentUnion(TypeCombinator::union(...$types)); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $types = []; + $changed = false; + + if (!$right instanceof UnionType) { + return $this; + } + + if (count($this->getTypes()) !== count($right->getTypes())) { + return $this; + } + + foreach ($this->getSortedTypes() as $i => $leftType) { + $rightType = $right->getSortedTypes()[$i]; + $newType = $cb($leftType, $rightType); + if ($leftType !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeUtils::toBenevolentUnion(TypeCombinator::union(...$types)); + } + + return $this; + } + +} diff --git a/src/Type/BitwiseFlagHelper.php b/src/Type/BitwiseFlagHelper.php new file mode 100644 index 00000000..8a9da074 --- /dev/null +++ b/src/Type/BitwiseFlagHelper.php @@ -0,0 +1,108 @@ +name) === $constName) { + return TrinaryLogic::createYes(); + } + + $resolveConstantName = $this->reflectionProvider->resolveConstantName($expr->name, $scope); + if ($resolveConstantName !== null) { + if ($resolveConstantName === $constName) { + return TrinaryLogic::createYes(); + } + return TrinaryLogic::createNo(); + } + } + + if ($expr instanceof BitwiseOr) { + return TrinaryLogic::createFromBoolean($this->bitwiseOrContainsConstant($expr->left, $scope, $constName)->yes() || + $this->bitwiseOrContainsConstant($expr->right, $scope, $constName)->yes()); + } + + $fqcn = new FullyQualified($constName); + if ($this->reflectionProvider->hasConstant($fqcn, $scope)) { + $constant = $this->reflectionProvider->getConstant($fqcn, $scope); + + $valueType = $constant->getValueType(); + + if ($valueType instanceof ConstantIntegerType) { + return $this->exprContainsIntFlag($expr, $scope, $valueType->getValue()); + } + } + + return TrinaryLogic::createNo(); + } + + private function exprContainsIntFlag(Expr $expr, Scope $scope, int $flag): TrinaryLogic + { + $exprType = $scope->getType($expr); + + if ($exprType instanceof UnionType) { + $allTypesContainFlag = true; + $someTypesContainFlag = false; + foreach ($exprType->getTypes() as $type) { + $containsFlag = $this->typeContainsIntFlag($type, $flag); + if (!$containsFlag->yes()) { + $allTypesContainFlag = false; + } + + if (!$containsFlag->yes() && !$containsFlag->maybe()) { + continue; + } + + $someTypesContainFlag = true; + } + + if ($allTypesContainFlag) { + return TrinaryLogic::createYes(); + } + if ($someTypesContainFlag) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createNo(); + } + + return $this->typeContainsIntFlag($exprType, $flag); + } + + private function typeContainsIntFlag(Type $type, int $flag): TrinaryLogic + { + if ($type instanceof ConstantIntegerType) { + if (($type->getValue() & $flag) === $flag) { + return TrinaryLogic::createYes(); + } + return TrinaryLogic::createNo(); + } + + if ($type->isInteger()->yes() || $type instanceof MixedType) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); + } + +} diff --git a/src/Type/BooleanType.php b/src/Type/BooleanType.php new file mode 100644 index 00000000..51d61529 --- /dev/null +++ b/src/Type/BooleanType.php @@ -0,0 +1,194 @@ +toInteger(); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toString(): Type + { + return TypeCombinator::union( + new ConstantStringType(''), + new ConstantStringType('1'), + ); + } + + public function toInteger(): Type + { + return TypeCombinator::union( + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ); + } + + public function toFloat(): Type + { + return TypeCombinator::union( + new ConstantFloatType(0.0), + new ConstantFloatType(1.0), + ); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ConstantBooleanType) { + return new ConstantBooleanType(!$typeToRemove->getValue()); + } + + return null; + } + + public function getFiniteTypes(): array + { + return [ + new ConstantBooleanType(true), + new ConstantBooleanType(false), + ]; + } + + public function exponentiate(Type $exponent): Type + { + return ExponentiateHelper::exponentiate($this, $exponent); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('bool'); + } + + public function toTrinaryLogic(): TrinaryLogic + { + if ($this->isTrue()->yes()) { + return TrinaryLogic::createYes(); + } + if ($this->isFalse()->yes()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + +} diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php new file mode 100644 index 00000000..886a422d --- /dev/null +++ b/src/Type/CallableType.php @@ -0,0 +1,682 @@ + */ + private array $parameters; + + private Type $returnType; + + private bool $isCommonCallable; + + private TemplateTypeMap $templateTypeMap; + + private TemplateTypeMap $resolvedTemplateTypeMap; + + private TrinaryLogic $isPure; + + /** + * @api + * @param list|null $parameters + * @param array $templateTags + */ + public function __construct( + ?array $parameters = null, + ?Type $returnType = null, + private bool $variadic = true, + ?TemplateTypeMap $templateTypeMap = null, + ?TemplateTypeMap $resolvedTemplateTypeMap = null, + private array $templateTags = [], + ?TrinaryLogic $isPure = null, + ) + { + $this->parameters = $parameters ?? []; + $this->returnType = $returnType ?? new MixedType(); + $this->isCommonCallable = $parameters === null && $returnType === null; + $this->templateTypeMap = $templateTypeMap ?? TemplateTypeMap::createEmpty(); + $this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); + $this->isPure = $isPure ?? TrinaryLogic::createMaybe(); + } + + /** + * @return array + */ + public function getTemplateTags(): array + { + return $this->templateTags; + } + + public function isPure(): TrinaryLogic + { + return $this->isPure; + } + + public function getReferencedClasses(): array + { + $classes = []; + foreach ($this->parameters as $parameter) { + $classes = array_merge($classes, $parameter->getType()->getReferencedClasses()); + } + + return array_merge($classes, $this->returnType->getReferencedClasses()); + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType && !$type instanceof self) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType && !$type instanceof self) { + return $type->isSubTypeOf($this); + } + + return $this->isSuperTypeOfInternal($type, false); + } + + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult + { + $isCallable = new IsSuperTypeOfResult($type->isCallable(), []); + if ($isCallable->no()) { + return $isCallable; + } + + static $scope; + if ($scope === null) { + $scope = new OutOfClassScope(); + } + + if ($this->isCommonCallable) { + if ($this->isPure()->yes()) { + $typePure = TrinaryLogic::createYes(); + foreach ($type->getCallableParametersAcceptors($scope) as $variant) { + $typePure = $typePure->and($variant->isPure()); + } + + return $isCallable->and(new IsSuperTypeOfResult($typePure, [])); + } + + return $isCallable; + } + + $variantsResult = null; + foreach ($type->getCallableParametersAcceptors($scope) as $variant) { + $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny); + if ($variantsResult === null) { + $variantsResult = $isSuperType; + } else { + $variantsResult = $variantsResult->or($isSuperType); + } + } + + if ($variantsResult === null) { + throw new ShouldNotHappenException(); + } + + return $isCallable->and($variantsResult); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof IntersectionType || $otherType instanceof UnionType) { + return $otherType->isSuperTypeOf($this); + } + + return (new IsSuperTypeOfResult($otherType->isCallable(), [])) + ->and($otherType instanceof self ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createMaybe()); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + return $this->describe(VerbosityLevel::precise()) === $type->describe(VerbosityLevel::precise()); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'callable', + function (): string { + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + $p->isOptional() && !$p->isVariadic(), + PassedByReference::createNo(), + $p->isVariadic(), + $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->templateTags, + $this->isPure, + ); + + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, + ); + } + + public function isCallable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return [$this]; + } + + public function getThrowPoints(): array + { + return [ + SimpleThrowPoint::createImplicit(), + ]; + } + + public function getImpurePoints(): array + { + $pure = $this->isPure(); + if ($pure->yes()) { + return []; + } + + return [ + new SimpleImpurePoint( + 'functionCall', + 'call to a callable', + $pure->no(), + ), + ]; + } + + public function getInvalidateExpressions(): array + { + return []; + } + + public function getUsedVariables(): array + { + return []; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ArrayType(new MixedType(), new MixedType()); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return TypeCombinator::union($this, new StringType(), new ArrayType(new MixedType(true), new MixedType(true)), new ObjectType(Closure::class)); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->resolvedTemplateTypeMap; + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return TemplateTypeVarianceMap::createEmpty(); + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getReturnType(): Type + { + return $this->returnType; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if (! $receivedType->isCallable()->yes()) { + return TemplateTypeMap::createEmpty(); + } + + $parametersAcceptors = $receivedType->getCallableParametersAcceptors(new OutOfClassScope()); + + $typeMap = TemplateTypeMap::createEmpty(); + + foreach ($parametersAcceptors as $parametersAcceptor) { + $typeMap = $typeMap->union($this->inferTemplateTypesOnParametersAcceptor($parametersAcceptor)); + } + + return $typeMap; + } + + private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap + { + $typeMap = TemplateTypeMap::createEmpty(); + $args = $parametersAcceptor->getParameters(); + $returnType = $parametersAcceptor->getReturnType(); + + foreach ($this->getParameters() as $i => $param) { + $paramType = $param->getType(); + if (isset($args[$i])) { + $argType = $args[$i]->getType(); + } elseif ($paramType instanceof TemplateType) { + $argType = TemplateTypeHelper::resolveToBounds($paramType); + } else { + $argType = new NeverType(); + } + + $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)->convertToLowerBoundTypes()); + } + + return $typeMap->union($this->getReturnType()->inferTemplateTypes($returnType)); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $references = $this->getReturnType()->getReferencedTemplateTypes( + $positionVariance->compose(TemplateTypeVariance::createCovariant()), + ); + + $paramVariance = $positionVariance->compose(TemplateTypeVariance::createContravariant()); + + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes($paramVariance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function traverse(callable $cb): Type + { + if ($this->isCommonCallable) { + return $this; + } + + $parameters = array_map(static function (ParameterReflection $param) use ($cb): NativeParameterReflection { + $defaultValue = $param->getDefaultValue(); + return new NativeParameterReflection( + $param->getName(), + $param->isOptional(), + $cb($param->getType()), + $param->passedByReference(), + $param->isVariadic(), + $defaultValue !== null ? $cb($defaultValue) : null, + ); + }, $this->getParameters()); + + return new self( + $parameters, + $cb($this->getReturnType()), + $this->isVariadic(), + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->templateTags, + $this->isPure, + ); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->isCommonCallable) { + return $this; + } + + if (!$right->isCallable()->yes()) { + return $this; + } + + $rightAcceptors = $right->getCallableParametersAcceptors(new OutOfClassScope()); + if (count($rightAcceptors) !== 1) { + return $this; + } + + $rightParameters = $rightAcceptors[0]->getParameters(); + if (count($this->getParameters()) !== count($rightParameters)) { + return $this; + } + + $parameters = []; + foreach ($this->getParameters() as $i => $leftParam) { + $rightParam = $rightParameters[$i]; + $leftDefaultValue = $leftParam->getDefaultValue(); + $rightDefaultValue = $rightParam->getDefaultValue(); + $defaultValue = $leftDefaultValue; + if ($leftDefaultValue !== null && $rightDefaultValue !== null) { + $defaultValue = $cb($leftDefaultValue, $rightDefaultValue); + } + $parameters[] = new NativeParameterReflection( + $leftParam->getName(), + $leftParam->isOptional(), + $cb($leftParam->getType(), $rightParam->getType()), + $leftParam->passedByReference(), + $leftParam->isVariadic(), + $defaultValue, + ); + } + + return new self( + $parameters, + $cb($this->getReturnType(), $rightAcceptors[0]->getReturnType()), + $this->isVariadic(), + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->templateTags, + $this->isPure, + ); + } + + public function isOversizedArray(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getEnumCases(): array + { + return []; + } + + public function isCommonCallable(): bool + { + return $this->isCommonCallable; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + if ($this->isCommonCallable) { + return new IdentifierTypeNode($this->isPure()->yes() ? 'pure-callable' : 'callable'); + } + + $parameters = []; + foreach ($this->parameters as $parameter) { + $parameters[] = new CallableTypeParameterNode( + $parameter->getType()->toPhpDocNode(), + !$parameter->passedByReference()->no(), + $parameter->isVariadic(), + $parameter->getName() === '' ? '' : '$' . $parameter->getName(), + $parameter->isOptional(), + ); + } + + $templateTags = []; + foreach ($this->templateTags as $templateName => $templateTag) { + $templateTags[] = new TemplateTagValueNode( + $templateName, + $templateTag->getBound()->toPhpDocNode(), + '', + ); + } + + return new CallableTypeNode( + new IdentifierTypeNode($this->isPure->yes() ? 'pure-callable' : 'callable'), + $parameters, + $this->returnType->toPhpDocNode(), + $templateTags, + ); + } + +} diff --git a/src/Type/CallableTypeHelper.php b/src/Type/CallableTypeHelper.php new file mode 100644 index 00000000..1011e504 --- /dev/null +++ b/src/Type/CallableTypeHelper.php @@ -0,0 +1,119 @@ +getParameters(); + $ourParameters = $ours->getParameters(); + + $lastParameter = null; + foreach ($theirParameters as $theirParameter) { + $lastParameter = $theirParameter; + } + $theirParameterCount = count($theirParameters); + $ourParameterCount = count($ourParameters); + if ( + $lastParameter !== null + && $lastParameter->isVariadic() + && $theirParameterCount < $ourParameterCount + ) { + foreach ($ourParameters as $i => $ourParameter) { + if (array_key_exists($i, $theirParameters)) { + continue; + } + $theirParameters[] = $lastParameter; + } + } + + $result = IsSuperTypeOfResult::createYes(); + foreach ($theirParameters as $i => $theirParameter) { + $parameterDescription = $theirParameter->getName() === '' ? sprintf('#%d', $i + 1) : sprintf('#%d $%s', $i + 1, $theirParameter->getName()); + if (!isset($ourParameters[$i])) { + if ($theirParameter->isOptional()) { + continue; + } + + $accepts = new IsSuperTypeOfResult(TrinaryLogic::createNo(), [ + sprintf( + 'Parameter %s of passed callable is required but accepting callable does not have that parameter. It will be called without it.', + $parameterDescription, + ), + ]); + $result = $result->and($accepts); + continue; + } + + $ourParameter = $ourParameters[$i]; + $ourParameterType = $ourParameter->getType(); + + if ($ourParameter->isOptional() && !$theirParameter->isOptional()) { + $accepts = new IsSuperTypeOfResult(TrinaryLogic::createNo(), [ + sprintf( + 'Parameter %s of passed callable is required but the parameter of accepting callable is optional. It might be called without it.', + $parameterDescription, + ), + ]); + $result = $result->and($accepts); + } + + if ($treatMixedAsAny) { + $isSuperType = $theirParameter->getType()->accepts($ourParameterType, true); + $isSuperType = new IsSuperTypeOfResult($isSuperType->result, $isSuperType->reasons); + } else { + $isSuperType = $theirParameter->getType()->isSuperTypeOf($ourParameterType); + } + + if ($isSuperType->maybe()) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($theirParameter->getType(), $ourParameterType); + $isSuperType = new IsSuperTypeOfResult($isSuperType->result, array_merge($isSuperType->reasons, [ + sprintf( + 'Type %s of parameter %s of passed callable needs to be same or wider than parameter type %s of accepting callable.', + $theirParameter->getType()->describe($verbosity), + $parameterDescription, + $ourParameterType->describe($verbosity), + ), + ])); + } + + $result = $result->and($isSuperType); + } + + if (!$treatMixedAsAny && $theirParameterCount < $ourParameterCount) { + $result = $result->and(IsSuperTypeOfResult::createMaybe()); + } + + $theirReturnType = $theirs->getReturnType(); + if ($treatMixedAsAny) { + $isReturnTypeSuperType = $ours->getReturnType()->accepts($theirReturnType, true); + $isReturnTypeSuperType = new IsSuperTypeOfResult($isReturnTypeSuperType->result, $isReturnTypeSuperType->reasons); + } else { + $isReturnTypeSuperType = $ours->getReturnType()->isSuperTypeOf($theirReturnType); + } + + $pure = $ours->isPure(); + if ($pure->yes()) { + $result = $result->and(new IsSuperTypeOfResult($theirs->isPure(), [])); + } elseif ($pure->no()) { + $result = $result->and(new IsSuperTypeOfResult($theirs->isPure()->negate(), [])); + } + + return $result->and($isReturnTypeSuperType); + } + +} diff --git a/src/Type/CircularTypeAliasDefinitionException.php b/src/Type/CircularTypeAliasDefinitionException.php new file mode 100644 index 00000000..e0a6ebcb --- /dev/null +++ b/src/Type/CircularTypeAliasDefinitionException.php @@ -0,0 +1,11 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isClassString(), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return new IsSuperTypeOfResult($type->isClassString(), []); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('class-string'); + } + +} diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php new file mode 100644 index 00000000..cad98f44 --- /dev/null +++ b/src/Type/ClosureType.php @@ -0,0 +1,805 @@ + */ + private array $parameters; + + private Type $returnType; + + private bool $isCommonCallable; + + private ObjectType $objectType; + + private TemplateTypeMap $templateTypeMap; + + private TemplateTypeMap $resolvedTemplateTypeMap; + + private TemplateTypeVarianceMap $callSiteVarianceMap; + + /** @var SimpleImpurePoint[] */ + private array $impurePoints; + + private TrinaryLogic $acceptsNamedArguments; + + /** + * @api + * @param list|null $parameters + * @param array $templateTags + * @param SimpleThrowPoint[] $throwPoints + * @param ?SimpleImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + * @param string[] $usedVariables + */ + public function __construct( + ?array $parameters = null, + ?Type $returnType = null, + private bool $variadic = true, + ?TemplateTypeMap $templateTypeMap = null, + ?TemplateTypeMap $resolvedTemplateTypeMap = null, + ?TemplateTypeVarianceMap $callSiteVarianceMap = null, + private array $templateTags = [], + private array $throwPoints = [], + ?array $impurePoints = null, + private array $invalidateExpressions = [], + private array $usedVariables = [], + ?TrinaryLogic $acceptsNamedArguments = null, + ) + { + if ($acceptsNamedArguments === null) { + $acceptsNamedArguments = TrinaryLogic::createYes(); + } + $this->acceptsNamedArguments = $acceptsNamedArguments; + + $this->parameters = $parameters ?? []; + $this->returnType = $returnType ?? new MixedType(); + $this->isCommonCallable = $parameters === null && $returnType === null; + $this->objectType = new ObjectType(Closure::class); + $this->templateTypeMap = $templateTypeMap ?? TemplateTypeMap::createEmpty(); + $this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); + $this->callSiteVarianceMap = $callSiteVarianceMap ?? TemplateTypeVarianceMap::createEmpty(); + $this->impurePoints = $impurePoints ?? [new SimpleImpurePoint('functionCall', 'call to an unknown Closure', false)]; + } + + /** + * @return array + */ + public function getTemplateTags(): array + { + return $this->templateTags; + } + + public static function createPure(): self + { + return new self(null, null, true, null, null, null, [], [], []); + } + + public function isPure(): TrinaryLogic + { + $impurePoints = $this->getImpurePoints(); + if (count($impurePoints) === 0) { + return TrinaryLogic::createYes(); + } + + $certainCount = 0; + foreach ($impurePoints as $impurePoint) { + if (!$impurePoint->isCertain()) { + continue; + } + + $certainCount++; + } + + return $certainCount > 0 ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + } + + public function getClassName(): string + { + return $this->objectType->getClassName(); + } + + public function getClassReflection(): ?ClassReflection + { + return $this->objectType->getClassReflection(); + } + + public function getAncestorWithClassName(string $className): ?TypeWithClassName + { + return $this->objectType->getAncestorWithClassName($className); + } + + public function getReferencedClasses(): array + { + $classes = $this->objectType->getReferencedClasses(); + foreach ($this->parameters as $parameter) { + $classes = array_merge($classes, $parameter->getType()->getReferencedClasses()); + } + + return array_merge($classes, $this->returnType->getReferencedClasses()); + } + + public function getObjectClassNames(): array + { + return $this->objectType->getObjectClassNames(); + } + + public function getObjectClassReflections(): array + { + return $this->objectType->getObjectClassReflections(); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if (!$type instanceof ClosureType) { + return $this->objectType->accepts($type, $strictTypes); + } + + return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return $this->isSuperTypeOfInternal($type, false); + } + + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult + { + if ($type instanceof self) { + return CallableTypeHelper::isParametersAcceptorSuperTypeOf( + $this, + $type, + $treatMixedAsAny, + ); + } + + if ($type->getObjectClassNames() === [Closure::class]) { + return IsSuperTypeOfResult::createMaybe(); + } + + return $this->objectType->isSuperTypeOf($type); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + return $this->describe(VerbosityLevel::precise()) === $type->describe(VerbosityLevel::precise()); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'Closure', + function (): string { + if ($this->isCommonCallable) { + return $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + } + + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + $p->isOptional() && !$p->isVariadic(), + PassedByReference::createNo(), + $p->isVariadic(), + $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + ); + + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, + ); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isObject(): TrinaryLogic + { + return $this->objectType->isObject(); + } + + public function isEnum(): TrinaryLogic + { + return $this->objectType->isEnum(); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->objectType->getTemplateType($ancestorClassName, $templateTypeName); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->objectType->canAccessProperties(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->objectType->hasProperty($propertyName); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->objectType->getProperty($propertyName, $scope); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + return $this->objectType->getUnresolvedPropertyPrototype($propertyName, $scope); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->objectType->canCallMethods(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->objectType->hasMethod($methodName); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + if ($methodName === 'call') { + return new ClosureCallUnresolvedMethodPrototypeReflection( + $this->objectType->getUnresolvedMethodPrototype($methodName, $scope), + $this, + ); + } + + return $this->objectType->getUnresolvedMethodPrototype($methodName, $scope); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->objectType->canAccessConstants(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->objectType->hasConstant($constantName); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->objectType->getConstant($constantName); + } + + public function getConstantStrings(): array + { + return []; + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isCallable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getEnumCases(): array + { + return []; + } + + public function isCommonCallable(): bool + { + return $this->isCommonCallable; + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return [$this]; + } + + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + + public function getUsedVariables(): array + { + return $this->usedVariables; + } + + public function acceptsNamedArguments(): TrinaryLogic + { + return $this->acceptsNamedArguments; + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function toBoolean(): BooleanType + { + return new ConstantBooleanType(true); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return TypeCombinator::union($this, new CallableType()); + } + + public function getTemplateTypeMap(): TemplateTypeMap + { + return $this->templateTypeMap; + } + + public function getResolvedTemplateTypeMap(): TemplateTypeMap + { + return $this->resolvedTemplateTypeMap; + } + + public function getCallSiteVarianceMap(): TemplateTypeVarianceMap + { + return $this->callSiteVarianceMap; + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function isVariadic(): bool + { + return $this->variadic; + } + + public function getReturnType(): Type + { + return $this->returnType; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if ($receivedType->isCallable()->no() || ! $receivedType instanceof self) { + return TemplateTypeMap::createEmpty(); + } + + $parametersAcceptors = $receivedType->getCallableParametersAcceptors(new OutOfClassScope()); + + $typeMap = TemplateTypeMap::createEmpty(); + + foreach ($parametersAcceptors as $parametersAcceptor) { + $typeMap = $typeMap->union($this->inferTemplateTypesOnParametersAcceptor($parametersAcceptor)); + } + + return $typeMap; + } + + private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap + { + $typeMap = TemplateTypeMap::createEmpty(); + $args = $parametersAcceptor->getParameters(); + $returnType = $parametersAcceptor->getReturnType(); + + foreach ($this->getParameters() as $i => $param) { + $paramType = $param->getType(); + if (isset($args[$i])) { + $argType = $args[$i]->getType(); + } elseif ($paramType instanceof TemplateType) { + $argType = TemplateTypeHelper::resolveToBounds($paramType); + } else { + $argType = new NeverType(); + } + + $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)->convertToLowerBoundTypes()); + } + + return $typeMap->union($this->getReturnType()->inferTemplateTypes($returnType)); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $references = $this->getReturnType()->getReferencedTemplateTypes( + $positionVariance->compose(TemplateTypeVariance::createCovariant()), + ); + + $paramVariance = $positionVariance->compose(TemplateTypeVariance::createContravariant()); + + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes($paramVariance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function traverse(callable $cb): Type + { + if ($this->isCommonCallable) { + return $this; + } + + return new self( + array_map(static function (ParameterReflection $param) use ($cb): NativeParameterReflection { + $defaultValue = $param->getDefaultValue(); + return new NativeParameterReflection( + $param->getName(), + $param->isOptional(), + $cb($param->getType()), + $param->passedByReference(), + $param->isVariadic(), + $defaultValue !== null ? $cb($defaultValue) : null, + ); + }, $this->getParameters()), + $cb($this->getReturnType()), + $this->isVariadic(), + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + ); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->isCommonCallable) { + return $this; + } + + if (!$right instanceof self) { + return $this; + } + + $rightParameters = $right->getParameters(); + if (count($this->getParameters()) !== count($rightParameters)) { + return $this; + } + + $parameters = []; + foreach ($this->getParameters() as $i => $leftParam) { + $rightParam = $rightParameters[$i]; + $leftDefaultValue = $leftParam->getDefaultValue(); + $rightDefaultValue = $rightParam->getDefaultValue(); + $defaultValue = $leftDefaultValue; + if ($leftDefaultValue !== null && $rightDefaultValue !== null) { + $defaultValue = $cb($leftDefaultValue, $rightDefaultValue); + } + $parameters[] = new NativeParameterReflection( + $leftParam->getName(), + $leftParam->isOptional(), + $cb($leftParam->getType(), $rightParam->getType()), + $leftParam->passedByReference(), + $leftParam->isVariadic(), + $defaultValue, + ); + } + + return new self( + $parameters, + $cb($this->getReturnType(), $right->getReturnType()), + $this->isVariadic(), + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + ); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this; + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + if ($this->isCommonCallable) { + return new IdentifierTypeNode($this->isPure()->yes() ? 'pure-Closure' : 'Closure'); + } + + $parameters = []; + foreach ($this->parameters as $parameter) { + $parameters[] = new CallableTypeParameterNode( + $parameter->getType()->toPhpDocNode(), + !$parameter->passedByReference()->no(), + $parameter->isVariadic(), + $parameter->getName() === '' ? '' : '$' . $parameter->getName(), + $parameter->isOptional(), + ); + } + + $templateTags = []; + foreach ($this->templateTags as $templateName => $templateTag) { + $templateTags[] = new TemplateTagValueNode( + $templateName, + $templateTag->getBound()->toPhpDocNode(), + '', + ); + } + + return new CallableTypeNode( + new IdentifierTypeNode('Closure'), + $parameters, + $this->returnType->toPhpDocNode(), + $templateTags, + ); + } + +} diff --git a/src/Type/ClosureTypeFactory.php b/src/Type/ClosureTypeFactory.php new file mode 100644 index 00000000..e48ced17 --- /dev/null +++ b/src/Type/ClosureTypeFactory.php @@ -0,0 +1,120 @@ +reflectionSourceStubber->generateFunctionStubFromReflection(new ReflectionFunction($closure)); + if ($stubData === null) { + throw new ShouldNotHappenException('Closure reflection not found.'); + } + $source = $stubData->getStub(); + $source = str_replace('{closure}', 'foo', $source); + $locatedSource = new LocatedSource($source, '{closure}', $stubData->getFileName()); + $find = new FindReflectionsInTree(new NodeToReflection()); + $ast = $this->parser->parse($locatedSource->getSource()); + if ($ast === null) { + throw new ShouldNotHappenException('Closure reflection not found.'); + } + + /** @var list<\PHPStan\BetterReflection\Reflection\ReflectionFunction> $reflections */ + $reflections = $find($this->reflector, $ast, new IdentifierType(IdentifierType::IDENTIFIER_FUNCTION), $locatedSource); + if (count($reflections) !== 1) { + throw new ShouldNotHappenException('Closure reflection not found.'); + } + + $betterReflectionFunction = $reflections[0]; + + $parameters = array_map(fn (BetterReflectionParameter $parameter) => new class($parameter, $this->initializerExprTypeResolver) implements ParameterReflection { + + public function __construct(private BetterReflectionParameter $reflection, private InitializerExprTypeResolver $initializerExprTypeResolver) + { + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function isOptional(): bool + { + return $this->reflection->isOptional(); + } + + public function getType(): Type + { + return TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($this->reflection->getType()), null, null, $this->reflection->isVariadic()); + } + + public function passedByReference(): PassedByReference + { + return $this->reflection->isPassedByReference() + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(); + } + + public function isVariadic(): bool + { + return $this->reflection->isVariadic(); + } + + public function getDefaultValue(): ?Type + { + if (! $this->reflection->isDefaultValueAvailable()) { + return null; + } + + $defaultExpr = $this->reflection->getDefaultValueExpression(); + if ($defaultExpr === null) { + return null; + } + + return $this->initializerExprTypeResolver->getType($defaultExpr, InitializerExprContext::fromReflectionParameter(new ReflectionParameter($this->reflection))); + } + + }, $betterReflectionFunction->getParameters()); + + return new ClosureType($parameters, TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($betterReflectionFunction->getReturnType())), $betterReflectionFunction->isVariadic()); + } + +} diff --git a/src/Type/CompoundType.php b/src/Type/CompoundType.php new file mode 100644 index 00000000..a71964ba --- /dev/null +++ b/src/Type/CompoundType.php @@ -0,0 +1,21 @@ +subject; + } + + public function getTarget(): Type + { + return $this->target; + } + + public function getIf(): Type + { + return $this->if; + } + + public function getElse(): Type + { + return $this->else; + } + + public function isNegated(): bool + { + return $this->negated; + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return $this->if->isSuperTypeOf($type->if) + ->and($this->else->isSuperTypeOf($type->else)); + } + + return $this->isSuperTypeOfDefault($type); + } + + public function getReferencedClasses(): array + { + return array_merge( + $this->subject->getReferencedClasses(), + $this->target->getReferencedClasses(), + $this->if->getReferencedClasses(), + $this->else->getReferencedClasses(), + ); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return array_merge( + $this->subject->getReferencedTemplateTypes($positionVariance), + $this->target->getReferencedTemplateTypes($positionVariance), + $this->if->getReferencedTemplateTypes($positionVariance), + $this->else->getReferencedTemplateTypes($positionVariance), + ); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->subject->equals($type->subject) + && $this->target->equals($type->target) + && $this->if->equals($type->if) + && $this->else->equals($type->else); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf( + '(%s %s %s ? %s : %s)', + $this->subject->describe($level), + $this->negated ? 'is not' : 'is', + $this->target->describe($level), + $this->if->describe($level), + $this->else->describe($level), + ); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->subject) && !TypeUtils::containsTemplateType($this->target); + } + + protected function getResult(): Type + { + $isSuperType = $this->target->isSuperTypeOf($this->subject); + + if ($isSuperType->yes()) { + return !$this->negated ? $this->getNormalizedIf() : $this->getNormalizedElse(); + } + + if ($isSuperType->no()) { + return !$this->negated ? $this->getNormalizedElse() : $this->getNormalizedIf(); + } + + return TypeCombinator::union( + $this->getNormalizedIf(), + $this->getNormalizedElse(), + ); + } + + public function traverse(callable $cb): Type + { + $subject = $cb($this->subject); + $target = $cb($this->target); + $if = $cb($this->getNormalizedIf()); + $else = $cb($this->getNormalizedElse()); + + if ( + $this->subject === $subject + && $this->target === $target + && $this->getNormalizedIf() === $if + && $this->getNormalizedElse() === $else + ) { + return $this; + } + + return new self($subject, $target, $if, $else, $this->negated); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $subject = $cb($this->subject, $right->subject); + $target = $cb($this->target, $right->target); + $if = $cb($this->getNormalizedIf(), $right->getNormalizedIf()); + $else = $cb($this->getNormalizedElse(), $right->getNormalizedElse()); + + if ( + $this->subject === $subject + && $this->target === $target + && $this->getNormalizedIf() === $if + && $this->getNormalizedElse() === $else + ) { + return $this; + } + + return new self($subject, $target, $if, $else, $this->negated); + } + + public function toPhpDocNode(): TypeNode + { + return new ConditionalTypeNode( + $this->subject->toPhpDocNode(), + $this->target->toPhpDocNode(), + $this->if->toPhpDocNode(), + $this->else->toPhpDocNode(), + $this->negated, + ); + } + + private function getNormalizedIf(): Type + { + return $this->normalizedIf ??= TypeTraverser::map( + $this->if, + fn (Type $type, callable $traverse) => $type === $this->subject + ? (!$this->negated ? $this->getSubjectWithTargetIntersectedType() : $this->getSubjectWithTargetRemovedType()) + : $traverse($type), + ); + } + + private function getNormalizedElse(): Type + { + return $this->normalizedElse ??= TypeTraverser::map( + $this->else, + fn (Type $type, callable $traverse) => $type === $this->subject + ? (!$this->negated ? $this->getSubjectWithTargetRemovedType() : $this->getSubjectWithTargetIntersectedType()) + : $traverse($type), + ); + } + + private function getSubjectWithTargetIntersectedType(): Type + { + return $this->subjectWithTargetIntersectedType ??= TypeCombinator::intersect($this->subject, $this->target); + } + + private function getSubjectWithTargetRemovedType(): Type + { + return $this->subjectWithTargetRemovedType ??= TypeCombinator::remove($this->subject, $this->target); + } + +} diff --git a/src/Type/ConditionalTypeForParameter.php b/src/Type/ConditionalTypeForParameter.php new file mode 100644 index 00000000..b508aaa8 --- /dev/null +++ b/src/Type/ConditionalTypeForParameter.php @@ -0,0 +1,178 @@ +parameterName; + } + + public function getTarget(): Type + { + return $this->target; + } + + public function getIf(): Type + { + return $this->if; + } + + public function getElse(): Type + { + return $this->else; + } + + public function isNegated(): bool + { + return $this->negated; + } + + public function changeParameterName(string $parameterName): self + { + return new self( + $parameterName, + $this->target, + $this->if, + $this->else, + $this->negated, + ); + } + + public function toConditional(Type $subject): Type + { + return new ConditionalType( + $subject, + $this->target, + $this->if, + $this->else, + $this->negated, + ); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return $this->if->isSuperTypeOf($type->if) + ->and($this->else->isSuperTypeOf($type->else)); + } + + return $this->isSuperTypeOfDefault($type); + } + + public function getReferencedClasses(): array + { + return array_merge( + $this->target->getReferencedClasses(), + $this->if->getReferencedClasses(), + $this->else->getReferencedClasses(), + ); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return array_merge( + $this->target->getReferencedTemplateTypes($positionVariance), + $this->if->getReferencedTemplateTypes($positionVariance), + $this->else->getReferencedTemplateTypes($positionVariance), + ); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->parameterName === $type->parameterName + && $this->target->equals($type->target) + && $this->if->equals($type->if) + && $this->else->equals($type->else); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf( + '(%s %s %s ? %s : %s)', + $this->parameterName, + $this->negated ? 'is not' : 'is', + $this->target->describe($level), + $this->if->describe($level), + $this->else->describe($level), + ); + } + + public function isResolvable(): bool + { + return false; + } + + protected function getResult(): Type + { + return TypeCombinator::union($this->if, $this->else); + } + + public function traverse(callable $cb): Type + { + $target = $cb($this->target); + $if = $cb($this->if); + $else = $cb($this->else); + + if ($this->target === $target && $this->if === $if && $this->else === $else) { + return $this; + } + + return new self($this->parameterName, $target, $if, $else, $this->negated); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $target = $cb($this->target, $right->target); + $if = $cb($this->if, $right->if); + $else = $cb($this->else, $right->else); + + if ($this->target === $target && $this->if === $if && $this->else === $else) { + return $this; + } + + return new self($this->parameterName, $target, $if, $else, $this->negated); + } + + public function toPhpDocNode(): TypeNode + { + return new ConditionalTypeForParameterNode( + $this->parameterName, + $this->target->toPhpDocNode(), + $this->if->toPhpDocNode(), + $this->else->toPhpDocNode(), + $this->negated, + ); + } + +} diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php new file mode 100644 index 00000000..2ef696bc --- /dev/null +++ b/src/Type/Constant/ConstantArrayType.php @@ -0,0 +1,1692 @@ + $keyTypes + * @param array $valueTypes + * @param non-empty-list $nextAutoIndexes + * @param int[] $optionalKeys + */ + public function __construct( + private array $keyTypes, + private array $valueTypes, + private array $nextAutoIndexes = [0], + private array $optionalKeys = [], + ?TrinaryLogic $isList = null, + ) + { + assert(count($keyTypes) === count($valueTypes)); + + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount === 0) { + $isList = TrinaryLogic::createYes(); + } + + if ($isList === null) { + $isList = TrinaryLogic::createNo(); + } + $this->isList = $isList; + } + + public function getConstantArrays(): array + { + return [$this]; + } + + public function getReferencedClasses(): array + { + $referencedClasses = []; + foreach ($this->getKeyTypes() as $keyType) { + foreach ($keyType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + } + + foreach ($this->getValueTypes() as $valueType) { + foreach ($valueType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + } + + return $referencedClasses; + } + + public function getIterableKeyType(): Type + { + if ($this->iterableKeyType !== null) { + return $this->iterableKeyType; + } + + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount === 0) { + $keyType = new NeverType(true); + } elseif ($keyTypesCount === 1) { + $keyType = $this->keyTypes[0]; + } else { + $keyType = new UnionType($this->keyTypes); + } + + return $this->iterableKeyType = $keyType; + } + + public function getIterableValueType(): Type + { + if ($this->iterableValueType !== null) { + return $this->iterableValueType; + } + + return $this->iterableValueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + } + + public function getKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getItemType(): Type + { + return $this->getIterableValueType(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + /** + * @return non-empty-list + */ + public function getNextAutoIndexes(): array + { + return $this->nextAutoIndexes; + } + + /** + * @return int[] + */ + public function getOptionalKeys(): array + { + return $this->optionalKeys; + } + + /** + * @return self[] + */ + public function getAllArrays(): array + { + if ($this->allArrays !== null) { + return $this->allArrays; + } + + if (count($this->optionalKeys) <= 10) { + $optionalKeysCombinations = $this->powerSet($this->optionalKeys); + } else { + $optionalKeysCombinations = [ + [], + $this->optionalKeys, + ]; + } + + $requiredKeys = []; + foreach (array_keys($this->keyTypes) as $i) { + if (in_array($i, $this->optionalKeys, true)) { + continue; + } + $requiredKeys[] = $i; + } + + $arrays = []; + foreach ($optionalKeysCombinations as $combination) { + $keys = array_merge($requiredKeys, $combination); + sort($keys); + + if ($this->isList->yes() && array_keys($keys) !== $keys) { + continue; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keys as $i) { + $builder->setOffsetValueType($this->keyTypes[$i], $this->valueTypes[$i]); + } + + $array = $builder->getArray(); + if (!$array instanceof self) { + throw new ShouldNotHappenException(); + } + + $arrays[] = $array; + } + + return $this->allArrays = $arrays; + } + + /** + * @template T + * @param T[] $in + * @return T[][] + */ + private function powerSet(array $in): array + { + $count = count($in); + $members = pow(2, $count); + $return = []; + for ($i = 0; $i < $members; $i++) { + $b = sprintf('%0' . $count . 'b', $i); + $out = []; + for ($j = 0; $j < $count; $j++) { + if ($b[$j] !== '1') { + continue; + } + + $out[] = $in[$j]; + } + $return[] = $out; + } + + return $return; + } + + /** + * @return array + */ + public function getKeyTypes(): array + { + return $this->keyTypes; + } + + /** + * @return array + */ + public function getValueTypes(): array + { + return $this->valueTypes; + } + + public function isOptionalKey(int $i): bool + { + return in_array($i, $this->optionalKeys, true); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType && !$type instanceof IntersectionType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if ($type instanceof self && count($this->keyTypes) === 0) { + return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + } + + $result = AcceptsResult::createYes(); + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + $hasOffsetValueType = $type->hasOffsetValueType($keyType); + $hasOffset = new AcceptsResult( + $hasOffsetValueType, + $hasOffsetValueType->yes() || !$type->isConstantArray()->yes() ? [] : [sprintf('Array %s have offset %s.', $hasOffsetValueType->no() ? 'does not' : 'might not', $keyType->describe(VerbosityLevel::value()))], + ); + if ($hasOffset->no()) { + if ($this->isOptionalKey($i)) { + continue; + } + return $hasOffset; + } + if ($hasOffset->maybe() && $this->isOptionalKey($i)) { + $hasOffset = AcceptsResult::createYes(); + } + + $result = $result->and($hasOffset); + $otherValueType = $type->getOffsetValueType($keyType); + $verbosity = VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType); + $acceptsValue = $valueType->accepts($otherValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Offset %s (%s) does not accept type %s: %s', + $keyType->describe(VerbosityLevel::precise()), + $valueType->describe($verbosity), + $otherValueType->describe($verbosity), + $reason, + ), + ); + if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0 && $type->isConstantArray()->yes()) { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Offset %s (%s) does not accept type %s.', + $keyType->describe(VerbosityLevel::precise()), + $valueType->describe($verbosity), + $otherValueType->describe($verbosity), + ), + ]); + } + if ($acceptsValue->no()) { + return $acceptsValue; + } + $result = $result->and($acceptsValue); + } + + $result = $result->and(new AcceptsResult($type->isArray(), [])); + if ($type->isOversizedArray()->yes()) { + if (!$result->no()) { + return AcceptsResult::createYes(); + } + } + + return $result; + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + if (count($this->keyTypes) === 0) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + + $results = []; + foreach ($this->keyTypes as $i => $keyType) { + $hasOffset = $type->hasOffsetValueType($keyType); + if ($hasOffset->no()) { + if (!$this->isOptionalKey($i)) { + return IsSuperTypeOfResult::createNo(); + } + + $results[] = IsSuperTypeOfResult::createYes(); + continue; + } elseif ($hasOffset->maybe() && !$this->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + } + + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($type->getOffsetValueType($keyType)); + if ($isValueSuperType->no()) { + return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); + } + $results[] = $isValueSuperType; + } + + return IsSuperTypeOfResult::createYes()->and(...$results); + } + + if ($type instanceof ArrayType) { + $result = IsSuperTypeOfResult::createMaybe(); + if (count($this->keyTypes) === 0) { + return $result; + } + + $isKeySuperType = $this->getKeyType()->isSuperTypeOf($type->getKeyType()); + if ($isKeySuperType->no()) { + return $isKeySuperType; + } + + return $result->and($isKeySuperType, $this->getItemType()->isSuperTypeOf($type->getItemType())); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isInteger()->yes()) { + return new ConstantBooleanType(false); + } + + if ($this->isIterableAtLeastOnce()->no()) { + if ($type->isIterableAtLeastOnce()->yes()) { + return new ConstantBooleanType(false); + } + + $constantScalarValues = $type->getConstantScalarValues(); + if (count($constantScalarValues) > 0) { + $results = []; + foreach ($constantScalarValues as $constantScalarValue) { + // @phpstan-ignore equal.invalid, equal.notAllowed + $results[] = TrinaryLogic::createFromBoolean($constantScalarValue == []); // phpcs:ignore + } + + return TrinaryLogic::extremeIdentity(...$results)->toBooleanType(); + } + } + + return new BooleanType(); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if (count($this->keyTypes) !== count($type->keyTypes)) { + return false; + } + + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + if (!$valueType->equals($type->valueTypes[$i])) { + return false; + } + if (!$keyType->equals($type->keyTypes[$i])) { + return false; + } + } + + if ($this->optionalKeys !== $type->optionalKeys) { + return false; + } + + return true; + } + + public function isCallable(): TrinaryLogic + { + $typeAndMethods = $this->findTypeAndMethodNames(); + if ($typeAndMethods === []) { + return TrinaryLogic::createNo(); + } + + $results = array_map( + static fn (ConstantArrayTypeAndMethod $typeAndMethod): TrinaryLogic => $typeAndMethod->getCertainty(), + $typeAndMethods, + ); + + return TrinaryLogic::createYes()->and(...$results); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + $typeAndMethodNames = $this->findTypeAndMethodNames(); + if ($typeAndMethodNames === []) { + throw new ShouldNotHappenException(); + } + + $acceptors = []; + foreach ($typeAndMethodNames as $typeAndMethodName) { + if ($typeAndMethodName->isUnknown() || !$typeAndMethodName->getCertainty()->yes()) { + $acceptors[] = new TrivialParametersAcceptor(); + continue; + } + + $method = $typeAndMethodName->getType() + ->getMethod($typeAndMethodName->getMethod(), $scope); + + if (!$scope->canCallMethod($method)) { + $acceptors[] = new InaccessibleMethod($method); + continue; + } + + array_push($acceptors, ...FunctionCallableVariant::createFromVariants($method, $method->getVariants())); + } + + return $acceptors; + } + + /** @return ConstantArrayTypeAndMethod[] */ + public function findTypeAndMethodNames(): array + { + if (count($this->keyTypes) !== 2) { + return []; + } + + $classOrObject = null; + $method = null; + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->isSuperTypeOf(new ConstantIntegerType(0))->yes()) { + $classOrObject = $this->valueTypes[$i]; + continue; + } + + if (!$keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) { + continue; + } + + $method = $this->valueTypes[$i]; + } + + if ($classOrObject === null || $method === null) { + return []; + } + + $callableArray = [$classOrObject, $method]; + + [$classOrObject, $methods] = $callableArray; + if (count($methods->getConstantStrings()) === 0) { + return [ConstantArrayTypeAndMethod::createUnknown()]; + } + + $type = $classOrObject->getObjectTypeOrClassStringObjectType(); + if (!$type->isObject()->yes()) { + return [ConstantArrayTypeAndMethod::createUnknown()]; + } + + $typeAndMethods = []; + $phpVersion = PhpVersionStaticAccessor::getInstance(); + foreach ($methods->getConstantStrings() as $methodName) { + $has = $type->hasMethod($methodName->getValue()); + if ($has->no()) { + continue; + } + + if ( + $has->yes() + && !$phpVersion->supportsCallableInstanceMethods() + ) { + $methodReflection = $type->getMethod($methodName->getValue(), new OutOfClassScope()); + if ($classOrObject->isString()->yes() && !$methodReflection->isStatic()) { + continue; + } + } + + if ($this->isOptionalKey(0) || $this->isOptionalKey(1)) { + $has = $has->and(TrinaryLogic::createMaybe()); + } + + $typeAndMethods[] = ConstantArrayTypeAndMethod::createConcrete($type, $methodName->getValue(), $has); + } + + return $typeAndMethods; + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + $offsetType = $offsetType->toArrayKey(); + if ($offsetType instanceof UnionType) { + $results = []; + foreach ($offsetType->getTypes() as $innerType) { + $results[] = $this->hasOffsetValueType($innerType); + } + + return TrinaryLogic::extremeIdentity(...$results); + } + if ($offsetType instanceof IntegerRangeType) { + $finiteTypes = $offsetType->getFiniteTypes(); + if ($finiteTypes !== []) { + $results = []; + foreach ($finiteTypes as $innerType) { + $results[] = $this->hasOffsetValueType($innerType); + } + + return TrinaryLogic::extremeIdentity(...$results); + } + } + + $result = TrinaryLogic::createNo(); + foreach ($this->keyTypes as $i => $keyType) { + if ( + $keyType instanceof ConstantIntegerType + && !$offsetType->isString()->no() + && $offsetType->isConstantScalarValue()->no() + ) { + return TrinaryLogic::createMaybe(); + } + + $has = $keyType->isSuperTypeOf($offsetType); + if ($has->yes()) { + if ($this->isOptionalKey($i)) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createYes(); + } + if (!$has->maybe()) { + continue; + } + + $result = TrinaryLogic::createMaybe(); + } + + return $result; + } + + public function getOffsetValueType(Type $offsetType): Type + { + if (count($this->keyTypes) === 0) { + return new ErrorType(); + } + + $offsetType = $offsetType->toArrayKey(); + $matchingValueTypes = []; + $all = true; + $maybeAll = true; + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->isSuperTypeOf($offsetType)->no()) { + $all = false; + + if ( + $keyType instanceof ConstantIntegerType + && !$offsetType->isString()->no() + && $offsetType->isConstantScalarValue()->no() + ) { + continue; + } + $maybeAll = false; + continue; + } + + $matchingValueTypes[] = $this->valueTypes[$i]; + } + + if ($all) { + return $this->getIterableValueType(); + } + + if (count($matchingValueTypes) > 0) { + $type = TypeCombinator::union(...$matchingValueTypes); + if ($type instanceof ErrorType) { + return new MixedType(); + } + + return $type; + } + + if ($maybeAll) { + return $this->getIterableValueType(); + } + + return new ErrorType(); // undefined offset + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); + $builder->setOffsetValueType($offsetType, $valueType); + + return $builder->getArray(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + $offsetType = $offsetType->toArrayKey(); + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); + foreach ($this->keyTypes as $keyType) { + if ($offsetType->isSuperTypeOf($keyType)->no()) { + continue; + } + + $builder->setOffsetValueType($keyType, $valueType); + } + + return $builder->getArray(); + } + + public function unsetOffset(Type $offsetType): Type + { + $offsetType = $offsetType->toArrayKey(); + if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) { + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $offsetType->getValue()) { + continue; + } + + $keyTypes = $this->keyTypes; + unset($keyTypes[$i]); + $valueTypes = $this->valueTypes; + unset($valueTypes[$i]); + + $newKeyTypes = []; + $newValueTypes = []; + $newOptionalKeys = []; + + $k = 0; + foreach ($keyTypes as $j => $newKeyType) { + $newKeyTypes[] = $newKeyType; + $newValueTypes[] = $valueTypes[$j]; + if (in_array($j, $this->optionalKeys, true)) { + $newOptionalKeys[] = $k; + } + $k++; + } + + return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, TrinaryLogic::createNo()); + } + + return $this; + } + + $constantScalars = $offsetType->getConstantScalarTypes(); + if (count($constantScalars) > 0) { + $optionalKeys = $this->optionalKeys; + + foreach ($constantScalars as $constantScalar) { + $constantScalar = $constantScalar->toArrayKey(); + if (!$constantScalar instanceof ConstantIntegerType && !$constantScalar instanceof ConstantStringType) { + continue; + } + + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $constantScalar->getValue()) { + continue; + } + + if (in_array($i, $optionalKeys, true)) { + continue 2; + } + + $optionalKeys[] = $i; + } + } + + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, TrinaryLogic::createNo()); + } + + $optionalKeys = $this->optionalKeys; + $isList = $this->isList; + foreach ($this->keyTypes as $i => $keyType) { + if (!$offsetType->isSuperTypeOf($keyType)->yes()) { + continue; + } + $optionalKeys[] = $i; + $isList = TrinaryLogic::createNo(); + } + $optionalKeys = array_values(array_unique($optionalKeys)); + + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $isList); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + $biggerOne = IntegerRangeType::fromInterval(1, null); + $finiteTypes = $lengthType->getFiniteTypes(); + if ($biggerOne->isSuperTypeOf($lengthType)->yes() && count($finiteTypes) < self::CHUNK_FINITE_TYPES_LIMIT) { + $results = []; + foreach ($finiteTypes as $finiteType) { + if (!$finiteType instanceof ConstantIntegerType || $finiteType->getValue() < 1) { + return $this->traitChunkArray($lengthType, $preserveKeys); + } + + $length = $finiteType->getValue(); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $keyTypesCount = count($this->keyTypes); + for ($i = 0; $i < $keyTypesCount; $i += $length) { + $chunk = $this->sliceArray(new ConstantIntegerType($i), new ConstantIntegerType($length), TrinaryLogic::createYes()); + $builder->setOffsetValueType(null, $preserveKeys->yes() ? $chunk : $chunk->getValuesArray()); + } + + $results[] = $builder->getArray(); + } + + return TypeCombinator::union(...$results); + } + + return $this->traitChunkArray($lengthType, $preserveKeys); + } + + public function fillKeysArray(Type $valueType): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($this->valueTypes as $i => $keyType) { + if ($keyType->isInteger()->no()) { + $stringKeyType = $keyType->toString(); + if ($stringKeyType instanceof ErrorType) { + return $stringKeyType; + } + + $builder->setOffsetValueType($stringKeyType, $valueType, $this->isOptionalKey($i)); + } else { + $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i)); + } + } + + return $builder->getArray(); + } + + public function flipArray(): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + $builder->setOffsetValueType( + $valueType->toArrayKey(), + $keyType, + $this->isOptionalKey($i), + ); + } + + return $builder->getArray(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + $has = $otherArraysType->hasOffsetValueType($keyType); + if ($has->no()) { + continue; + } + $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || !$has->yes()); + } + + return $builder->getArray(); + } + + public function popArray(): Type + { + return $this->removeLastElements(1); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) { + $offsetType = $preserveKeys->yes() || $this->keyTypes[$i]->isInteger()->no() + ? $this->keyTypes[$i] + : null; + $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $this->isOptionalKey($i)); + } + + return $builder->getArray(); + } + + public function searchArray(Type $needleType): Type + { + $matches = []; + $hasIdenticalValue = false; + + foreach ($this->valueTypes as $index => $valueType) { + $isNeedleSuperType = $valueType->isSuperTypeOf($needleType); + if ($isNeedleSuperType->no()) { + continue; + } + + if ($needleType instanceof ConstantScalarType && $valueType instanceof ConstantScalarType + && $needleType->getValue() === $valueType->getValue() + && !$this->isOptionalKey($index) + ) { + $hasIdenticalValue = true; + } + + $matches[] = $this->keyTypes[$index]; + } + + if (count($matches) > 0) { + if ($hasIdenticalValue) { + return TypeCombinator::union(...$matches); + } + + return TypeCombinator::union(new ConstantBooleanType(false), ...$matches); + } + + return new ConstantBooleanType(false); + } + + public function shiftArray(): Type + { + return $this->removeFirstElements(1); + } + + public function shuffleArray(): Type + { + $valuesArray = $this->getValuesArray(); + + $isIterableAtLeastOnce = $valuesArray->isIterableAtLeastOnce(); + if ($isIterableAtLeastOnce->no()) { + return $valuesArray; + } + + $generalizedArray = new ArrayType($valuesArray->getIterableKeyType(), $valuesArray->getIterableValueType()); + + if ($isIterableAtLeastOnce->yes()) { + $generalizedArray = TypeCombinator::intersect($generalizedArray, new NonEmptyArrayType()); + } + if ($valuesArray->isList->yes()) { + $generalizedArray = TypeCombinator::intersect($generalizedArray, new AccessoryArrayListType()); + } + + return $generalizedArray; + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount === 0) { + return $this; + } + + $offset = $offsetType instanceof ConstantIntegerType ? $offsetType->getValue() : 0; + $length = $lengthType instanceof ConstantIntegerType ? $lengthType->getValue() : $keyTypesCount; + + if ($length < 0) { + // Negative lengths prevent access to the most right n elements + return $this->removeLastElements($length * -1) + ->sliceArray($offsetType, new NullType(), $preserveKeys); + } + + if ($keyTypesCount + $offset <= 0) { + // A negative offset cannot reach left outside the array + $offset = 0; + } + + if ($offset < 0) { + /* + * Transforms the problem with the negative offset in one with a positive offset using array reversion. + * The reason is belows handling of optional keys which works only from left to right. + * + * e.g. + * array{a: 0, b: 1, c: 2, d: 3, e: 4} + * with offset -4 and length 2 (which would be sliced to array{b: 1, c: 2}) + * + * is transformed via reversion to + * + * array{e: 4, d: 3, c: 2, b: 1, a: 0} + * with offset 2 and length 2 (which will be sliced to array{c: 2, b: 1} and then reversed again) + */ + $offset *= -1; + $reversedLength = min($length, $offset); + $reversedOffset = $offset - $reversedLength; + return $this->reverseArray(TrinaryLogic::createYes()) + ->sliceArray(new ConstantIntegerType($reversedOffset), new ConstantIntegerType($reversedLength), $preserveKeys) + ->reverseArray(TrinaryLogic::createYes()); + } + + if ($offset > 0) { + return $this->removeFirstElements($offset, false) + ->sliceArray(new ConstantIntegerType(0), $lengthType, $preserveKeys); + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $nonOptionalElementsCount = 0; + $hasOptional = false; + for ($i = 0; $nonOptionalElementsCount < $length && $i < $keyTypesCount; $i++) { + $isOptional = $this->isOptionalKey($i); + if (!$isOptional) { + $nonOptionalElementsCount++; + } else { + $hasOptional = true; + } + + $isLastElement = $nonOptionalElementsCount >= $length || $i + 1 >= $keyTypesCount; + if ($isLastElement && $length < $keyTypesCount && $hasOptional) { + // If the slice is not full yet, but has at least one optional key + // the last non-optional element is going to be optional. + // Otherwise, it would not fit into the slice if previous non-optional keys are there. + $isOptional = true; + } + + $offsetType = $preserveKeys->yes() || $this->keyTypes[$i]->isInteger()->no() + ? $this->keyTypes[$i] + : null; + + $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $isOptional); + } + + return $builder->getArray(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + $keysCount = count($this->keyTypes); + if ($keysCount === 0) { + return TrinaryLogic::createNo(); + } + + $optionalKeysCount = count($this->optionalKeys); + if ($optionalKeysCount < $keysCount) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + $optionalKeysCount = count($this->optionalKeys); + $totalKeysCount = count($this->getKeyTypes()); + if ($optionalKeysCount === 0) { + return new ConstantIntegerType($totalKeysCount); + } + + return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $totalKeysCount); + } + + public function getFirstIterableKeyType(): Type + { + $keyTypes = []; + foreach ($this->keyTypes as $i => $keyType) { + $keyTypes[] = $keyType; + if (!$this->isOptionalKey($i)) { + break; + } + } + + return TypeCombinator::union(...$keyTypes); + } + + public function getLastIterableKeyType(): Type + { + $keyTypes = []; + for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) { + $keyTypes[] = $this->keyTypes[$i]; + if (!$this->isOptionalKey($i)) { + break; + } + } + + return TypeCombinator::union(...$keyTypes); + } + + public function getFirstIterableValueType(): Type + { + $valueTypes = []; + foreach ($this->valueTypes as $i => $valueType) { + $valueTypes[] = $valueType; + if (!$this->isOptionalKey($i)) { + break; + } + } + + return TypeCombinator::union(...$valueTypes); + } + + public function getLastIterableValueType(): Type + { + $valueTypes = []; + for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) { + $valueTypes[] = $this->valueTypes[$i]; + if (!$this->isOptionalKey($i)) { + break; + } + } + + return TypeCombinator::union(...$valueTypes); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isList(): TrinaryLogic + { + return $this->isList; + } + + /** @param positive-int $length */ + private function removeLastElements(int $length): self + { + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount === 0) { + return $this; + } + + $keyTypes = $this->keyTypes; + $valueTypes = $this->valueTypes; + $optionalKeys = $this->optionalKeys; + $nextAutoindexes = $this->nextAutoIndexes; + + $optionalKeysRemoved = 0; + $newLength = $keyTypesCount - $length; + for ($i = $keyTypesCount - 1; $i >= 0; $i--) { + $isOptional = $this->isOptionalKey($i); + + if ($i >= $newLength) { + if ($isOptional) { + $optionalKeysRemoved++; + foreach ($optionalKeys as $key => $value) { + if ($value === $i) { + unset($optionalKeys[$key]); + break; + } + } + } + + $removedKeyType = array_pop($keyTypes); + array_pop($valueTypes); + $nextAutoindexes = $removedKeyType instanceof ConstantIntegerType + ? [$removedKeyType->getValue()] + : $this->nextAutoIndexes; + continue; + } + + if ($isOptional || $optionalKeysRemoved <= 0) { + continue; + } + + $optionalKeys[] = $i; + $optionalKeysRemoved--; + } + + return new self( + $keyTypes, + $valueTypes, + $nextAutoindexes, + array_values($optionalKeys), + $this->isList, + ); + } + + /** @param positive-int $length */ + private function removeFirstElements(int $length, bool $reindex = true): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $optionalKeysIgnored = 0; + foreach ($this->keyTypes as $i => $keyType) { + $isOptional = $this->isOptionalKey($i); + if ($i <= $length - 1) { + if ($isOptional) { + $optionalKeysIgnored++; + } + continue; + } + + if (!$isOptional && $optionalKeysIgnored > 0) { + $isOptional = true; + $optionalKeysIgnored--; + } + + $valueType = $this->valueTypes[$i]; + if ($reindex && $keyType instanceof ConstantIntegerType) { + $keyType = null; + } + + $builder->setOffsetValueType($keyType, $valueType, $isOptional); + } + + return $builder->getArray(); + } + + public function toBoolean(): BooleanType + { + return $this->getArraySize()->toBoolean(); + } + + public function toInteger(): Type + { + return $this->toBoolean()->toInteger(); + } + + public function toFloat(): Type + { + return $this->toBoolean()->toFloat(); + } + + public function generalize(GeneralizePrecision $precision): Type + { + if (count($this->keyTypes) === 0) { + return $this; + } + + if ($precision->isTemplateArgument()) { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } + + $arrayType = new ArrayType( + $this->getIterableKeyType()->generalize($precision), + $this->getIterableValueType()->generalize($precision), + ); + + $keyTypesCount = count($this->keyTypes); + $optionalKeysCount = count($this->optionalKeys); + + $accessoryTypes = []; + if ($precision->isMoreSpecific() && ($keyTypesCount - $optionalKeysCount) < 32) { + foreach ($this->keyTypes as $i => $keyType) { + if ($this->isOptionalKey($i)) { + continue; + } + + $accessoryTypes[] = new HasOffsetValueType($keyType, $this->valueTypes[$i]->generalize($precision)); + } + } elseif ($keyTypesCount > $optionalKeysCount) { + $accessoryTypes[] = new NonEmptyArrayType(); + } + + if ($this->isList()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + if (count($accessoryTypes) > 0) { + return TypeCombinator::intersect($arrayType, ...$accessoryTypes); + } + + return $arrayType; + } + + public function generalizeValues(): self + { + $valueTypes = []; + foreach ($this->valueTypes as $valueType) { + $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); + } + + return new self($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + } + + public function getKeysArray(): self + { + return $this->getKeysOrValuesArray($this->keyTypes); + } + + public function getValuesArray(): self + { + return $this->getKeysOrValuesArray($this->valueTypes); + } + + /** + * @param array $types + */ + private function getKeysOrValuesArray(array $types): self + { + $count = count($types); + $autoIndexes = range($count - count($this->optionalKeys), $count); + assert($autoIndexes !== []); + + if ($this->isList->yes()) { + // Optimized version for lists: Assume that if a later key exists, then earlier keys also exist. + $keyTypes = array_map( + static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i), + array_keys($types), + ); + return new self($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + } + + $keyTypes = []; + $valueTypes = []; + $optionalKeys = []; + $maxIndex = 0; + + foreach ($types as $i => $type) { + $keyTypes[] = new ConstantIntegerType($i); + + if ($this->isOptionalKey($maxIndex)) { + // move $maxIndex to next non-optional key + do { + $maxIndex++; + } while ($maxIndex < $count && $this->isOptionalKey($maxIndex)); + } + + if ($i === $maxIndex) { + $valueTypes[] = $type; + } else { + $valueTypes[] = TypeCombinator::union(...array_slice($types, $i, $maxIndex - $i + 1)); + if ($maxIndex >= $count) { + $optionalKeys[] = $i; + } + } + $maxIndex++; + } + + return new self($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes()); + } + + public function describe(VerbosityLevel $level): string + { + $describeValue = function (bool $truncate) use ($level): string { + $items = []; + $values = []; + $exportValuesOnly = true; + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + if ($keyType->getValue() !== $i) { + $exportValuesOnly = false; + } + + $isOptional = $this->isOptionalKey($i); + if ($isOptional) { + $exportValuesOnly = false; + } + + $keyDescription = $keyType->getValue(); + if (is_string($keyDescription)) { + if (str_contains($keyDescription, '"')) { + $keyDescription = sprintf('\'%s\'', $keyDescription); + } elseif (str_contains($keyDescription, '\'')) { + $keyDescription = sprintf('"%s"', $keyDescription); + } + } + + $valueTypeDescription = $valueType->describe($level); + $items[] = sprintf('%s%s: %s', $keyDescription, $isOptional ? '?' : '', $valueTypeDescription); + $values[] = $valueTypeDescription; + } + + $append = ''; + if ($truncate && count($items) > self::DESCRIBE_LIMIT) { + $items = array_slice($items, 0, self::DESCRIBE_LIMIT); + $values = array_slice($values, 0, self::DESCRIBE_LIMIT); + $append = ', ...'; + } + + return sprintf( + 'array{%s%s}', + implode(', ', $exportValuesOnly ? $values : $items), + $append, + ); + }; + return $level->handle( + fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)), + static fn (): string => $describeValue(true), + static fn (): string => $describeValue(false), + ); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if ($receivedType instanceof self) { + $typeMap = TemplateTypeMap::createEmpty(); + foreach ($this->keyTypes as $i => $keyType) { + $valueType = $this->valueTypes[$i]; + if ($receivedType->hasOffsetValueType($keyType)->no()) { + continue; + } + $receivedValueType = $receivedType->getOffsetValueType($keyType); + $typeMap = $typeMap->union($valueType->inferTemplateTypes($receivedValueType)); + } + + return $typeMap; + } + + if ($receivedType->isArray()->yes()) { + $keyTypeMap = $this->getIterableKeyType()->inferTemplateTypes($receivedType->getIterableKeyType()); + $itemTypeMap = $this->getIterableValueType()->inferTemplateTypes($receivedType->getIterableValueType()); + + return $keyTypeMap->union($itemTypeMap); + } + + return TemplateTypeMap::createEmpty(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + $references = []; + + foreach ($this->keyTypes as $type) { + foreach ($type->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + + foreach ($this->valueTypes as $type) { + foreach ($type->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove->isConstantArray()->yes() && $typeToRemove->isIterableAtLeastOnce()->no()) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + if ($typeToRemove instanceof NonEmptyArrayType) { + return new ConstantArrayType([], []); + } + + if ($typeToRemove instanceof HasOffsetType) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + + if ($typeToRemove instanceof HasOffsetValueType) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + + return null; + } + + public function traverse(callable $cb): Type + { + $valueTypes = []; + + $stillOriginal = true; + foreach ($this->valueTypes as $valueType) { + $transformedValueType = $cb($valueType); + if ($transformedValueType !== $valueType) { + $stillOriginal = false; + } + + $valueTypes[] = $transformedValueType; + } + + if ($stillOriginal) { + return $this; + } + + return new self($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right->isArray()->yes()) { + return $this; + } + + $valueTypes = []; + + $stillOriginal = true; + foreach ($this->valueTypes as $i => $valueType) { + $keyType = $this->keyTypes[$i]; + $transformedValueType = $cb($valueType, $right->getOffsetValueType($keyType)); + if ($transformedValueType !== $valueType) { + $stillOriginal = false; + } + + $valueTypes[] = $transformedValueType; + } + + if ($stillOriginal) { + return $this; + } + + return new self($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + } + + public function isKeysSupersetOf(self $otherArray): bool + { + $keyTypesCount = count($this->keyTypes); + $otherKeyTypesCount = count($otherArray->keyTypes); + + if ($keyTypesCount < $otherKeyTypesCount) { + return false; + } + + if ($otherKeyTypesCount === 0) { + return $keyTypesCount === 0; + } + + $failOnDifferentValueType = $keyTypesCount !== $otherKeyTypesCount || $keyTypesCount < 2; + + $keyTypes = $this->keyTypes; + + foreach ($otherArray->keyTypes as $j => $keyType) { + $i = self::findKeyIndex($keyType, $keyTypes); + if ($i === null) { + return false; + } + + unset($keyTypes[$i]); + + $valueType = $this->valueTypes[$i]; + $otherValueType = $otherArray->valueTypes[$j]; + if (!$otherValueType->isSuperTypeOf($valueType)->no()) { + continue; + } + + if ($failOnDifferentValueType) { + return false; + } + $failOnDifferentValueType = true; + } + + $requiredKeyCount = 0; + foreach (array_keys($keyTypes) as $i) { + if ($this->isOptionalKey($i)) { + continue; + } + + $requiredKeyCount++; + if ($requiredKeyCount > 1) { + return false; + } + } + + return true; + } + + public function mergeWith(self $otherArray): self + { + // only call this after verifying isKeysSupersetOf, or if losing tagged unions is not an issue + $valueTypes = $this->valueTypes; + $optionalKeys = $this->optionalKeys; + foreach ($this->keyTypes as $i => $keyType) { + $otherIndex = $otherArray->getKeyIndex($keyType); + if ($otherIndex === null) { + $optionalKeys[] = $i; + continue; + } + if ($otherArray->isOptionalKey($otherIndex)) { + $optionalKeys[] = $i; + } + $otherValueType = $otherArray->valueTypes[$otherIndex]; + $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $otherValueType); + } + + $optionalKeys = array_values(array_unique($optionalKeys)); + + $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); + sort($nextAutoIndexes); + + return new self($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList)); + } + + /** + * @param ConstantIntegerType|ConstantStringType $otherKeyType + */ + private function getKeyIndex($otherKeyType): ?int + { + return self::findKeyIndex($otherKeyType, $this->keyTypes); + } + + /** + * @param ConstantIntegerType|ConstantStringType $otherKeyType + * @param array $keyTypes + */ + private static function findKeyIndex($otherKeyType, array $keyTypes): ?int + { + foreach ($keyTypes as $i => $keyType) { + if ($keyType->equals($otherKeyType)) { + return $i; + } + } + + return null; + } + + public function makeOffsetRequired(Type $offsetType): self + { + $offsetType = $offsetType->toArrayKey(); + $optionalKeys = $this->optionalKeys; + foreach ($this->keyTypes as $i => $keyType) { + if (!$keyType->equals($offsetType)) { + continue; + } + + foreach ($optionalKeys as $j => $key) { + if ($i === $key) { + unset($optionalKeys[$j]); + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); + } + } + + break; + } + + return $this; + } + + public function toPhpDocNode(): TypeNode + { + $items = []; + $values = []; + $exportValuesOnly = true; + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $i) { + $exportValuesOnly = false; + } + $keyPhpDocNode = $keyType->toPhpDocNode(); + if (!$keyPhpDocNode instanceof ConstTypeNode) { + continue; + } + $valueType = $this->valueTypes[$i]; + + /** @var ConstExprStringNode|ConstExprIntegerNode $keyNode */ + $keyNode = $keyPhpDocNode->constExpr; + if ($keyNode instanceof ConstExprStringNode) { + $value = $keyNode->value; + if (self::isValidIdentifier($value)) { + $keyNode = new IdentifierTypeNode($value); + } + } + + $isOptional = $this->isOptionalKey($i); + if ($isOptional) { + $exportValuesOnly = false; + } + $items[] = new ArrayShapeItemNode( + $keyNode, + $isOptional, + $valueType->toPhpDocNode(), + ); + $values[] = new ArrayShapeItemNode( + null, + $isOptional, + $valueType->toPhpDocNode(), + ); + } + + return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items); + } + + public static function isValidIdentifier(string $value): bool + { + $result = Strings::match($value, '~^(?:[\\\\]?+[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF-]*+)++$~si'); + + return $result !== null; + } + + public function getFiniteTypes(): array + { + $arraysArraysForCombinations = []; + $count = 0; + foreach ($this->getAllArrays() as $array) { + $values = $array->getValueTypes(); + $arraysForCombinations = []; + $combinationCount = 1; + foreach ($values as $valueType) { + $finiteTypes = $valueType->getFiniteTypes(); + if ($finiteTypes === []) { + return []; + } + $arraysForCombinations[] = $finiteTypes; + $combinationCount *= count($finiteTypes); + } + $arraysArraysForCombinations[] = $arraysForCombinations; + $count += $combinationCount; + } + + if ($count > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return []; + } + + $finiteTypes = []; + foreach ($arraysArraysForCombinations as $arraysForCombinations) { + $combinations = CombinationsHelper::combinations($arraysForCombinations); + foreach ($combinations as $combination) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($combination as $i => $v) { + $builder->setOffsetValueType($this->keyTypes[$i], $v); + } + $finiteTypes[] = $builder->getArray(); + } + } + + return $finiteTypes; + } + +} diff --git a/src/Type/Constant/ConstantArrayTypeAndMethod.php b/src/Type/Constant/ConstantArrayTypeAndMethod.php new file mode 100644 index 00000000..56b30dcd --- /dev/null +++ b/src/Type/Constant/ConstantArrayTypeAndMethod.php @@ -0,0 +1,69 @@ +no()) { + throw new ShouldNotHappenException(); + } + return new self($type, $method, $certainty); + } + + public static function createUnknown(): self + { + return new self(null, null, TrinaryLogic::createMaybe()); + } + + public function isUnknown(): bool + { + return $this->type === null; + } + + public function getType(): Type + { + if ($this->type === null) { + throw new ShouldNotHappenException(); + } + + return $this->type; + } + + public function getMethod(): string + { + if ($this->method === null) { + throw new ShouldNotHappenException(); + } + + return $this->method; + } + + public function getCertainty(): TrinaryLogic + { + return $this->certainty; + } + +} diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php new file mode 100644 index 00000000..9c87ff6e --- /dev/null +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -0,0 +1,322 @@ + $keyTypes + * @param array $valueTypes + * @param non-empty-list $nextAutoIndexes + * @param array $optionalKeys + */ + private function __construct( + private array $keyTypes, + private array $valueTypes, + private array $nextAutoIndexes, + private array $optionalKeys, + private TrinaryLogic $isList, + ) + { + } + + public static function createEmpty(): self + { + return new self([], [], [0], [], TrinaryLogic::createYes()); + } + + public static function createFromConstantArray(ConstantArrayType $startArrayType): self + { + $builder = new self( + $startArrayType->getKeyTypes(), + $startArrayType->getValueTypes(), + $startArrayType->getNextAutoIndexes(), + $startArrayType->getOptionalKeys(), + $startArrayType->isList(), + ); + + if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { + $builder->degradeToGeneralArray(true); + } + + return $builder; + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $optional = false): void + { + if ($offsetType !== null) { + $offsetType = $offsetType->toArrayKey(); + } + + if (!$this->degradeToGeneralArray) { + if ($offsetType === null) { + $newAutoIndexes = $optional ? $this->nextAutoIndexes : []; + $hasOptional = false; + foreach ($this->keyTypes as $i => $keyType) { + if (!$keyType instanceof ConstantIntegerType) { + continue; + } + + if (!in_array($keyType->getValue(), $this->nextAutoIndexes, true)) { + continue; + } + + $this->valueTypes[$i] = TypeCombinator::union($this->valueTypes[$i], $valueType); + + if (!$hasOptional && !$optional) { + $this->optionalKeys = array_values(array_filter($this->optionalKeys, static fn (int $index): bool => $index !== $i)); + } + + /** @var int|float $newAutoIndex */ + $newAutoIndex = $keyType->getValue() + 1; + if (is_float($newAutoIndex)) { + $newAutoIndex = $keyType->getValue(); + } + + $newAutoIndexes[] = $newAutoIndex; + $hasOptional = true; + } + + $max = max($this->nextAutoIndexes); + + $this->keyTypes[] = new ConstantIntegerType($max); + $this->valueTypes[] = $valueType; + + /** @var int|float $newAutoIndex */ + $newAutoIndex = $max + 1; + if (is_float($newAutoIndex)) { + $newAutoIndex = $max; + } + + $newAutoIndexes[] = $newAutoIndex; + $this->nextAutoIndexes = array_values(array_unique($newAutoIndexes)); + + if ($optional || $hasOptional) { + $this->optionalKeys[] = count($this->keyTypes) - 1; + } + + if (count($this->keyTypes) > self::ARRAY_COUNT_LIMIT) { + $this->degradeToGeneralArray = true; + $this->oversized = true; + } + + return; + } + + if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) { + /** @var ConstantIntegerType|ConstantStringType $keyType */ + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $offsetType->getValue()) { + continue; + } + + if ($optional) { + $valueType = TypeCombinator::union($valueType, $this->valueTypes[$i]); + } + + $this->valueTypes[$i] = $valueType; + + if (!$optional) { + $this->optionalKeys = array_values(array_filter($this->optionalKeys, static fn (int $index): bool => $index !== $i)); + if ($keyType instanceof ConstantIntegerType) { + $nextAutoIndexes = array_values(array_filter($this->nextAutoIndexes, static fn (int $index) => $index > $keyType->getValue())); + if (count($nextAutoIndexes) === 0) { + throw new ShouldNotHappenException(); + } + $this->nextAutoIndexes = $nextAutoIndexes; + } + } + return; + } + + $this->keyTypes[] = $offsetType; + $this->valueTypes[] = $valueType; + + if ($offsetType instanceof ConstantIntegerType) { + $min = min($this->nextAutoIndexes); + $max = max($this->nextAutoIndexes); + if ($offsetType->getValue() > $min) { + if ($offsetType->getValue() <= $max) { + $this->isList = $this->isList->and(TrinaryLogic::createMaybe()); + } else { + $this->isList = TrinaryLogic::createNo(); + } + } + if ($offsetType->getValue() >= $max) { + /** @var int|float $newAutoIndex */ + $newAutoIndex = $offsetType->getValue() + 1; + if (is_float($newAutoIndex)) { + $newAutoIndex = $max; + } + if (!$optional) { + $this->nextAutoIndexes = [$newAutoIndex]; + } else { + $this->nextAutoIndexes[] = $newAutoIndex; + } + } + } else { + $this->isList = TrinaryLogic::createNo(); + } + + if ($optional) { + $this->optionalKeys[] = count($this->keyTypes) - 1; + } + + if (count($this->keyTypes) > self::ARRAY_COUNT_LIMIT) { + $this->degradeToGeneralArray = true; + $this->oversized = true; + } + + return; + } + + $scalarTypes = $offsetType->getConstantScalarTypes(); + if (count($scalarTypes) === 0) { + $integerRanges = TypeUtils::getIntegerRanges($offsetType); + if (count($integerRanges) > 0) { + foreach ($integerRanges as $integerRange) { + if ($integerRange->getMin() === null) { + break; + } + if ($integerRange->getMax() === null) { + break; + } + + $rangeLength = $integerRange->getMax() - $integerRange->getMin(); + if ($rangeLength >= self::ARRAY_COUNT_LIMIT) { + $scalarTypes = []; + break; + } + + foreach (range($integerRange->getMin(), $integerRange->getMax()) as $rangeValue) { + $scalarTypes[] = new ConstantIntegerType($rangeValue); + } + } + } + } + if (count($scalarTypes) > 0 && count($scalarTypes) < self::ARRAY_COUNT_LIMIT) { + $match = true; + $valueTypes = $this->valueTypes; + foreach ($scalarTypes as $scalarType) { + $scalarOffsetType = $scalarType->toArrayKey(); + if (!$scalarOffsetType instanceof ConstantIntegerType && !$scalarOffsetType instanceof ConstantStringType) { + throw new ShouldNotHappenException(); + } + $offsetMatch = false; + + /** @var ConstantIntegerType|ConstantStringType $keyType */ + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $scalarOffsetType->getValue()) { + continue; + } + + $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $valueType); + $offsetMatch = true; + } + + if ($offsetMatch) { + continue; + } + + $match = false; + } + + if ($match) { + $this->valueTypes = $valueTypes; + return; + } + } + + $this->isList = TrinaryLogic::createNo(); + } + + if ($offsetType === null) { + $offsetType = TypeCombinator::union(...array_map(static fn (int $index) => new ConstantIntegerType($index), $this->nextAutoIndexes)); + } else { + $this->isList = TrinaryLogic::createNo(); + } + + $this->keyTypes[] = $offsetType; + $this->valueTypes[] = $valueType; + if ($optional) { + $this->optionalKeys[] = count($this->keyTypes) - 1; + } + $this->degradeToGeneralArray = true; + } + + public function degradeToGeneralArray(bool $oversized = false): void + { + $this->degradeToGeneralArray = true; + $this->oversized = $this->oversized || $oversized; + } + + public function getArray(): Type + { + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount === 0) { + return new ConstantArrayType([], []); + } + + if (!$this->degradeToGeneralArray) { + /** @var array $keyTypes */ + $keyTypes = $this->keyTypes; + return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + } + + $array = new ArrayType( + TypeCombinator::union(...$this->keyTypes), + TypeCombinator::union(...$this->valueTypes), + ); + + if (count($this->optionalKeys) < $keyTypesCount) { + $array = TypeCombinator::intersect($array, new NonEmptyArrayType()); + } + + if ($this->oversized) { + $array = TypeCombinator::intersect($array, new OversizedArrayType()); + } + + if ($this->isList->yes()) { + $array = TypeCombinator::intersect($array, new AccessoryArrayListType()); + } + + return $array; + } + + public function isList(): bool + { + return $this->isList->yes(); + } + +} diff --git a/src/Type/Constant/ConstantBooleanType.php b/src/Type/Constant/ConstantBooleanType.php new file mode 100644 index 00000000..40bc7ca7 --- /dev/null +++ b/src/Type/Constant/ConstantBooleanType.php @@ -0,0 +1,145 @@ +value; + } + + public function describe(VerbosityLevel $level): string + { + return $this->value ? 'true' : 'false'; + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + if ($this->value) { + return StaticTypeFactory::falsey(); + } + return new NeverType(); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + if ($this->value) { + return new MixedType(); + } + return StaticTypeFactory::falsey(); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + if ($this->value) { + return new NeverType(); + } + return StaticTypeFactory::truthy(); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + if ($this->value) { + return StaticTypeFactory::truthy(); + } + return new MixedType(); + } + + public function toBoolean(): BooleanType + { + return $this; + } + + public function toNumber(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toString(): Type + { + return new ConstantStringType((string) $this->value); + } + + public function toInteger(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function toFloat(): Type + { + return new ConstantFloatType((float) $this->value); + } + + public function toArrayKey(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->value === true); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->value === false); + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new BooleanType(); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode($this->value ? 'true' : 'false'); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isObject()->yes()) { + return $this; + } + + return $this->scalarLooseCompare($type, $phpVersion); + } + +} diff --git a/src/Type/Constant/ConstantFloatType.php b/src/Type/Constant/ConstantFloatType.php new file mode 100644 index 00000000..0cf66ce5 --- /dev/null +++ b/src/Type/Constant/ConstantFloatType.php @@ -0,0 +1,104 @@ +value; + } + + public function equals(Type $type): bool + { + return $type instanceof self && ($this->value === $type->value || is_nan($this->value) && is_nan($type->value)); + } + + private function castFloatToString(float $value): string + { + $precisionBackup = ini_get('precision'); + ini_set('precision', '-1'); + try { + $valueStr = (string) $value; + if (is_finite($value) && !str_contains($valueStr, '.')) { + $valueStr .= '.0'; + } + + return $valueStr; + } finally { + ini_set('precision', $precisionBackup); + } + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'float', + fn (): string => $this->castFloatToString($this->value), + ); + } + + public function toString(): Type + { + return new ConstantStringType((string) $this->value); + } + + public function toInteger(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function toAbsoluteNumber(): Type + { + return new self(abs($this->value)); + } + + public function toArrayKey(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new FloatType(); + } + + /** + * @return ConstTypeNode + */ + public function toPhpDocNode(): TypeNode + { + return new ConstTypeNode(new ConstExprFloatNode($this->castFloatToString($this->value))); + } + +} diff --git a/src/Type/Constant/ConstantIntegerType.php b/src/Type/Constant/ConstantIntegerType.php new file mode 100644 index 00000000..7d37bdbd --- /dev/null +++ b/src/Type/Constant/ConstantIntegerType.php @@ -0,0 +1,109 @@ +value; + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return $this->value === $type->value ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createNo(); + } + + if ($type instanceof IntegerRangeType) { + $min = $type->getMin(); + $max = $type->getMax(); + if (($min === null || $min <= $this->value) && ($max === null || $this->value <= $max)) { + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createNo(); + } + + if ($type instanceof parent) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'int', + fn (): string => sprintf('%s', $this->value), + ); + } + + public function toFloat(): Type + { + return new ConstantFloatType($this->value); + } + + public function toAbsoluteNumber(): Type + { + return new self(abs($this->value)); + } + + public function toString(): Type + { + return new ConstantStringType((string) $this->value); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new IntegerType(); + } + + /** + * @return ConstTypeNode + */ + public function toPhpDocNode(): TypeNode + { + return new ConstTypeNode(new ConstExprIntegerNode((string) $this->value)); + } + +} diff --git a/src/Type/Constant/ConstantScalarToBooleanTrait.php b/src/Type/Constant/ConstantScalarToBooleanTrait.php new file mode 100644 index 00000000..a1d3a12b --- /dev/null +++ b/src/Type/Constant/ConstantScalarToBooleanTrait.php @@ -0,0 +1,16 @@ +value); + } + +} diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php new file mode 100644 index 00000000..54468587 --- /dev/null +++ b/src/Type/Constant/ConstantStringType.php @@ -0,0 +1,572 @@ +value; + } + + public function getConstantStrings(): array + { + return [$this]; + } + + public function isClassString(): TrinaryLogic + { + if ($this->isClassString) { + return TrinaryLogic::createYes(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + + return TrinaryLogic::createFromBoolean($reflectionProvider->hasClass($this->value)); + } + + public function getClassStringObjectType(): Type + { + if ($this->isClassString()->yes()) { + return new ObjectType($this->value); + } + + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->getClassStringObjectType(); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'string', + function (): string { + $value = $this->value; + + if (!$this->isClassString) { + try { + $value = Strings::truncate($value, self::DESCRIBE_LIMIT); + } catch (RegexpException) { + $value = substr($value, 0, self::DESCRIBE_LIMIT) . "\u{2026}"; + } + } + + return self::export($value); + }, + fn (): string => self::export($this->value), + ); + } + + private function export(string $value): string + { + $escapedValue = addcslashes($value, "\0..\37"); + if ($escapedValue !== $value) { + return '"' . addcslashes($value, "\0..\37\\\"") . '"'; + } + + return "'" . addcslashes($value, '\\\'') . "'"; + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof GenericClassStringType) { + $genericType = $type->getGenericType(); + if ($genericType instanceof MixedType) { + return IsSuperTypeOfResult::createMaybe(); + } + if ($genericType instanceof StaticType) { + $genericType = $genericType->getStaticObjectType(); + } + + // We are transforming constant class-string to ObjectType. But we need to filter out + // an uncertainty originating in possible ObjectType's class subtypes. + $objectType = $this->getObjectType(); + + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType + // uncertainty into account. + if ($genericType instanceof TemplateType) { + $isSuperType = $genericType->getBound()->isSuperTypeOf($objectType); + } else { + $isSuperType = $genericType->isSuperTypeOf($objectType); + } + + // Explicitly handle the uncertainty for Yes & Maybe. + if ($isSuperType->yes()) { + return IsSuperTypeOfResult::createMaybe(); + } + return IsSuperTypeOfResult::createNo(); + } + if ($type instanceof ClassStringType) { + return $this->isClassString()->yes() ? IsSuperTypeOfResult::createMaybe() : IsSuperTypeOfResult::createNo(); + } + + if ($type instanceof self) { + return $this->value === $type->value ? IsSuperTypeOfResult::createYes() : IsSuperTypeOfResult::createNo(); + } + + if ($type instanceof parent) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function isCallable(): TrinaryLogic + { + if ($this->value === '') { + return TrinaryLogic::createNo(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + + // 'my_function' + if ($reflectionProvider->hasFunction(new Name($this->value), null)) { + return TrinaryLogic::createYes(); + } + + // 'MyClass::myStaticFunction' + $matches = Strings::match($this->value, '#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\z#'); + if ($matches !== null) { + if (!$reflectionProvider->hasClass($matches[1])) { + return TrinaryLogic::createMaybe(); + } + + $phpVersion = PhpVersionStaticAccessor::getInstance(); + $classRef = $reflectionProvider->getClass($matches[1]); + if ($classRef->hasMethod($matches[2])) { + $method = $classRef->getMethod($matches[2], new OutOfClassScope()); + if ( + !$phpVersion->supportsCallableInstanceMethods() + && !$method->isStatic() + ) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createYes(); + } + + if (!$classRef->getNativeReflection()->isFinal()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createNo(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + if ($this->value === '') { + return []; + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + + // 'my_function' + $functionName = new Name($this->value); + if ($reflectionProvider->hasFunction($functionName, null)) { + $function = $reflectionProvider->getFunction($functionName, null); + return FunctionCallableVariant::createFromVariants($function, $function->getVariants()); + } + + // 'MyClass::myStaticFunction' + $matches = Strings::match($this->value, '#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\z#'); + if ($matches !== null) { + if (!$reflectionProvider->hasClass($matches[1])) { + return [new TrivialParametersAcceptor()]; + } + + $classReflection = $reflectionProvider->getClass($matches[1]); + if ($classReflection->hasMethod($matches[2])) { + $method = $classReflection->getMethod($matches[2], $scope); + if (!$scope->canCallMethod($method)) { + return [new InaccessibleMethod($method)]; + } + + return FunctionCallableVariant::createFromVariants($method, $method->getVariants()); + } + + if (!$classReflection->getNativeReflection()->isFinal()) { + return [new TrivialParametersAcceptor()]; + } + } + + throw new ShouldNotHappenException(); + } + + public function toNumber(): Type + { + if (is_numeric($this->value)) { + $value = $this->value; + $value = +$value; + if (is_float($value)) { + return new ConstantFloatType($value); + } + + return new ConstantIntegerType($value); + } + + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toInteger(): Type + { + return new ConstantIntegerType((int) $this->value); + } + + public function toFloat(): Type + { + return new ConstantFloatType((float) $this->value); + } + + public function toArrayKey(): Type + { + if ($this->arrayKeyType !== null) { + return $this->arrayKeyType; + } + + /** @var int|string $offsetValue */ + $offsetValue = key([$this->value => null]); + return $this->arrayKeyType = is_int($offsetValue) ? new ConstantIntegerType($offsetValue) : new ConstantStringType($offsetValue); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean(is_numeric($this->getValue())); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getValue() !== ''); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean(!in_array($this->getValue(), ['', '0'], true)); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean(strtolower($this->value) === $this->value); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean(strtoupper($this->value) === $this->value); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($offsetType->isInteger()->yes()) { + $strlen = strlen($this->value); + $strLenType = IntegerRangeType::fromInterval(-$strlen, $strlen - 1); + return $strLenType->isSuperTypeOf($offsetType)->result; + } + + return parent::hasOffsetValueType($offsetType); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($offsetType->isInteger()->yes()) { + $strlen = strlen($this->value); + $strLenType = IntegerRangeType::fromInterval(-$strlen, $strlen - 1); + + if ($offsetType instanceof ConstantIntegerType) { + if ($strLenType->isSuperTypeOf($offsetType)->yes()) { + return new self($this->value[$offsetType->getValue()]); + } + + return new ErrorType(); + } + + $intersected = TypeCombinator::intersect($strLenType, $offsetType); + if ($intersected instanceof IntegerRangeType) { + $finiteTypes = $intersected->getFiniteTypes(); + if ($finiteTypes === []) { + return parent::getOffsetValueType($offsetType); + } + + $chars = []; + foreach ($finiteTypes as $constantInteger) { + $chars[] = new self($this->value[$constantInteger->getValue()]); + } + if (!$strLenType->isSuperTypeOf($offsetType)->yes()) { + $chars[] = new self(''); + } + + return TypeCombinator::union(...$chars); + } + } + + return parent::getOffsetValueType($offsetType); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $valueStringType = $valueType->toString(); + if ($valueStringType instanceof ErrorType) { + return new ErrorType(); + } + if ( + $offsetType instanceof ConstantIntegerType + && $valueStringType instanceof ConstantStringType + ) { + $value = $this->value; + $offsetValue = $offsetType->getValue(); + if ($offsetValue < 0) { + return new ErrorType(); + } + $stringValue = $valueStringType->getValue(); + if (strlen($stringValue) !== 1) { + return new ErrorType(); + } + $value[$offsetValue] = $stringValue; + + return new self($value); + } + + return parent::setOffsetValueType($offsetType, $valueType); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return parent::setOffsetValueType($offsetType, $valueType); + } + + public function append(self $otherString): self + { + return new self($this->getValue() . $otherString->getValue()); + } + + public function generalize(GeneralizePrecision $precision): Type + { + if ($this->isClassString) { + if ($precision->isMoreSpecific()) { + return new ClassStringType(); + } + + return new StringType(); + } + + if ($this->getValue() !== '' && $precision->isMoreSpecific()) { + $accessories = [ + new StringType(), + new AccessoryLiteralStringType(), + ]; + + if (is_numeric($this->getValue())) { + $accessories[] = new AccessoryNumericStringType(); + } + + if ($this->getValue() !== '0') { + $accessories[] = new AccessoryNonFalsyStringType(); + } else { + $accessories[] = new AccessoryNonEmptyStringType(); + } + + if (strtolower($this->getValue()) === $this->getValue()) { + $accessories[] = new AccessoryLowercaseStringType(); + } + + if (strtoupper($this->getValue()) === $this->getValue()) { + $accessories[] = new AccessoryUppercaseStringType(); + } + + return new IntersectionType($accessories); + } + + if ($precision->isMoreSpecific()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); + } + + return new StringType(); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + new ConstantBooleanType(true), + IntegerRangeType::createAllGreaterThanOrEqualTo((float) $this->value), + ]; + + if ($this->value === '') { + $subtractedTypes[] = new NullType(); + $subtractedTypes[] = new StringType(); + } + + if (!(bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(false); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + IntegerRangeType::createAllGreaterThan((float) $this->value), + ]; + + if (!(bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(true); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + new ConstantBooleanType(false), + IntegerRangeType::createAllSmallerThanOrEqualTo((float) $this->value), + ]; + + if ((bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(true); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + IntegerRangeType::createAllSmallerThan((float) $this->value), + ]; + + if ((bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(false); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->isClassString(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->getObjectType()->hasConstant($constantName); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->getObjectType()->getConstant($constantName); + } + + private function getObjectType(): ObjectType + { + return $this->objectType ??= new ObjectType($this->value); + } + + public function toPhpDocNode(): TypeNode + { + if (substr_count($this->value, "\n") > 0) { + return $this->generalize(GeneralizePrecision::moreSpecific())->toPhpDocNode(); + } + + return new ConstTypeNode(new ConstExprStringNode($this->value, ConstExprStringNode::SINGLE_QUOTED)); + } + +} diff --git a/src/Type/Constant/OversizedArrayBuilder.php b/src/Type/Constant/OversizedArrayBuilder.php new file mode 100644 index 00000000..23e0c60f --- /dev/null +++ b/src/Type/Constant/OversizedArrayBuilder.php @@ -0,0 +1,102 @@ +items; + for ($i = 0; $i < count($items); $i++) { + $item = $items[$i]; + if (!$item->unpack) { + continue; + } + + $valueType = $getTypeCallback($item->value); + if ($valueType instanceof ConstantArrayType) { + array_splice($items, $i, 1); + foreach ($valueType->getKeyTypes() as $j => $innerKeyType) { + $innerValueType = $valueType->getValueTypes()[$j]; + if ($innerKeyType->isString()->no()) { + $keyExpr = null; + } else { + $keyExpr = new TypeExpr($innerKeyType); + } + array_splice($items, $i++, 0, [new ArrayItem( + new TypeExpr($innerValueType), + $keyExpr, + )]); + } + } else { + array_splice($items, $i, 1, [new ArrayItem( + new TypeExpr($valueType->getIterableValueType()), + new TypeExpr($valueType->getIterableKeyType()), + )]); + } + } + foreach ($items as $item) { + if ($item->unpack) { + throw new ShouldNotHappenException(); + } + if ($item->key !== null) { + $itemKeyType = $getTypeCallback($item->key); + if (!$itemKeyType instanceof ConstantIntegerType) { + $isList = false; + } elseif ($itemKeyType->getValue() !== $nextAutoIndex) { + $isList = false; + $nextAutoIndex = $itemKeyType->getValue() + 1; + } else { + $nextAutoIndex++; + } + } else { + $itemKeyType = new ConstantIntegerType($nextAutoIndex); + $nextAutoIndex++; + } + + $generalizedKeyType = $itemKeyType->generalize(GeneralizePrecision::moreSpecific()); + $keyTypes[$generalizedKeyType->describe(VerbosityLevel::precise())] = $generalizedKeyType; + + $itemValueType = $getTypeCallback($item->value); + $generalizedValueType = $itemValueType->generalize(GeneralizePrecision::moreSpecific()); + $valueTypes[$generalizedValueType->describe(VerbosityLevel::precise())] = $generalizedValueType; + } + + $keyType = TypeCombinator::union(...array_values($keyTypes)); + $valueType = TypeCombinator::union(...array_values($valueTypes)); + + $arrayType = new ArrayType($keyType, $valueType); + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType(), new OversizedArrayType()); + } + +} diff --git a/src/Type/ConstantScalarType.php b/src/Type/ConstantScalarType.php new file mode 100644 index 00000000..cf311ed0 --- /dev/null +++ b/src/Type/ConstantScalarType.php @@ -0,0 +1,15 @@ + ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + $arrayBuilder->degradeToGeneralArray(true); + } + foreach ($value as $k => $v) { + $arrayBuilder->setOffsetValueType(self::getTypeFromValue($k), self::getTypeFromValue($v)); + } + return $arrayBuilder->getArray(); + } elseif (is_object($value)) { + $class = get_class($value); + /** phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFullyQualifiedName */ + if (function_exists('enum_exists') && \enum_exists($class)) { + /** @var UnitEnum $value */ + return new EnumCaseObjectType($class, $value->name); + } + /** phpcs:enable */ + + return new ObjectType(get_class($value)); + } + + return new MixedType(); + } + +} diff --git a/src/Type/DirectTypeAliasResolverProvider.php b/src/Type/DirectTypeAliasResolverProvider.php new file mode 100644 index 00000000..7e60c904 --- /dev/null +++ b/src/Type/DirectTypeAliasResolverProvider.php @@ -0,0 +1,18 @@ +typeAliasResolver; + } + +} diff --git a/src/Type/DynamicFunctionReturnTypeExtension.php b/src/Type/DynamicFunctionReturnTypeExtension.php new file mode 100644 index 00000000..65dcff8d --- /dev/null +++ b/src/Type/DynamicFunctionReturnTypeExtension.php @@ -0,0 +1,34 @@ +dynamicMethodReturnTypeExtensionsByClass === null) { + $byClass = []; + foreach ($this->dynamicMethodReturnTypeExtensions as $extension) { + $byClass[strtolower($extension->getClass())][] = $extension; + } + + $this->dynamicMethodReturnTypeExtensionsByClass = $byClass; + } + return $this->getDynamicExtensionsForType($this->dynamicMethodReturnTypeExtensionsByClass, $className); + } + + /** + * @return DynamicStaticMethodReturnTypeExtension[] + */ + public function getDynamicStaticMethodReturnTypeExtensionsForClass(string $className): array + { + if ($this->dynamicStaticMethodReturnTypeExtensionsByClass === null) { + $byClass = []; + foreach ($this->dynamicStaticMethodReturnTypeExtensions as $extension) { + $byClass[strtolower($extension->getClass())][] = $extension; + } + + $this->dynamicStaticMethodReturnTypeExtensionsByClass = $byClass; + } + return $this->getDynamicExtensionsForType($this->dynamicStaticMethodReturnTypeExtensionsByClass, $className); + } + + /** + * @param DynamicMethodReturnTypeExtension[][]|DynamicStaticMethodReturnTypeExtension[][] $extensions + * @return mixed[] + */ + private function getDynamicExtensionsForType(array $extensions, string $className): array + { + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $extensionsForClass = [[]]; + $class = $this->reflectionProvider->getClass($className); + foreach (array_merge([$className], $class->getParentClassesNames(), $class->getNativeReflection()->getInterfaceNames()) as $extensionClassName) { + $extensionClassName = strtolower($extensionClassName); + if (!isset($extensions[$extensionClassName])) { + continue; + } + + $extensionsForClass[] = $extensions[$extensionClassName]; + } + + return array_merge(...$extensionsForClass); + } + + /** + * @return DynamicFunctionReturnTypeExtension[] + */ + public function getDynamicFunctionReturnTypeExtensions(): array + { + return $this->dynamicFunctionReturnTypeExtensions; + } + +} diff --git a/src/Type/DynamicStaticMethodReturnTypeExtension.php b/src/Type/DynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 00000000..390a349e --- /dev/null +++ b/src/Type/DynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,37 @@ +enumCaseName; + } + + public function describe(VerbosityLevel $level): string + { + $parent = parent::describe($level); + + return sprintf('%s::%s', $parent, $this->enumCaseName); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + return $this->enumCaseName === $type->enumCaseName && + $this->getClassName() === $type->getClassName(); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return $this->isSuperTypeOf($type)->toAcceptsResult(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createFromBoolean( + $this->enumCaseName === $type->enumCaseName && $this->getClassName() === $type->getClassName(), + ); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ( + $type instanceof SubtractableType + && $type->getSubtractedType() !== null + ) { + $isSuperType = $type->getSubtractedType()->isSuperTypeOf($this); + if ($isSuperType->yes()) { + return IsSuperTypeOfResult::createNo(); + } + } + + $parent = new parent($this->getClassName(), $this->getSubtractedType(), $this->getClassReflection()); + + return $parent->isSuperTypeOf($type)->and(IsSuperTypeOfResult::createMaybe()); + } + + public function subtract(Type $type): Type + { + return $this; + } + + public function getTypeWithoutSubtractedType(): Type + { + return $this; + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + return $this; + } + + public function getSubtractedType(): ?Type + { + return null; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($this->isSuperTypeOf($typeToRemove)->yes()) { + return $this->subtract($typeToRemove); + } + + return null; + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return parent::getUnresolvedPropertyPrototype($propertyName, $scope); + + } + if ($propertyName === 'name') { + return new EnumUnresolvedPropertyPrototypeReflection( + new EnumPropertyReflection($classReflection, new ConstantStringType($this->enumCaseName)), + ); + } + + if ($classReflection->isBackedEnum() && $propertyName === 'value') { + if ($classReflection->hasEnumCase($this->enumCaseName)) { + $enumCase = $classReflection->getEnumCase($this->enumCaseName); + $valueType = $enumCase->getBackingValueType(); + if ($valueType === null) { + throw new ShouldNotHappenException(); + } + + return new EnumUnresolvedPropertyPrototypeReflection( + new EnumPropertyReflection($classReflection, $valueType), + ); + } + } + + return parent::getUnresolvedPropertyPrototype($propertyName, $scope); + } + + public function getBackingValueType(): ?Type + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return null; + } + + if (!$classReflection->isBackedEnum()) { + return null; + } + + if ($classReflection->hasEnumCase($this->enumCaseName)) { + $enumCase = $classReflection->getEnumCase($this->enumCaseName); + + return $enumCase->getBackingValueType(); + } + + return null; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new parent($this->getClassName(), null, $this->getClassReflection()); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getEnumCases(): array + { + return [$this]; + } + + public function toPhpDocNode(): TypeNode + { + return new ConstTypeNode( + new ConstFetchNode( + $this->getClassName(), + $this->getEnumCaseName(), + ), + ); + } + +} diff --git a/src/Type/ErrorType.php b/src/Type/ErrorType.php new file mode 100644 index 00000000..442cb0a1 --- /dev/null +++ b/src/Type/ErrorType.php @@ -0,0 +1,45 @@ +handle( + fn (): string => parent::describe($level), + fn (): string => parent::describe($level), + static fn (): string => '*ERROR*', + ); + } + + public function getIterableKeyType(): Type + { + return new ErrorType(); + } + + public function getIterableValueType(): Type + { + return new ErrorType(); + } + + public function subtract(Type $type): Type + { + return new self(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + +} diff --git a/src/Type/ExponentiateHelper.php b/src/Type/ExponentiateHelper.php new file mode 100644 index 00000000..6a3341df --- /dev/null +++ b/src/Type/ExponentiateHelper.php @@ -0,0 +1,132 @@ +getTypes() as $unionType) { + $results[] = self::exponentiate($base, $unionType); + } + return TypeCombinator::union(...$results); + } + + if ($exponent instanceof NeverType) { + return new NeverType(); + } + + $allowedExponentTypes = new UnionType([ + new IntegerType(), + new FloatType(), + new StringType(), + new BooleanType(), + new NullType(), + ]); + if (!$allowedExponentTypes->isSuperTypeOf($exponent)->yes()) { + return new ErrorType(); + } + + if ($base instanceof ConstantScalarType) { + $result = self::exponentiateConstantScalar($base, $exponent); + if ($result !== null) { + return $result; + } + } + + // exponentiation of a float, stays a float + $isFloatBase = $base->isFloat()->yes(); + + $isLooseZero = (new ConstantIntegerType(0))->isSuperTypeOf($exponent->toNumber()); + if ($isLooseZero->yes()) { + if ($isFloatBase) { + return new ConstantFloatType(1); + } + + return new ConstantIntegerType(1); + } + + $isLooseOne = (new ConstantIntegerType(1))->isSuperTypeOf($exponent->toNumber()); + if ($isLooseOne->yes()) { + $possibleResults = new UnionType([ + new FloatType(), + new IntegerType(), + ]); + + if ($possibleResults->isSuperTypeOf($base)->yes()) { + return $base; + } + } + + if ($isFloatBase) { + return new FloatType(); + } + + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + private static function exponentiateConstantScalar(ConstantScalarType $base, Type $exponent): ?Type + { + if ($exponent instanceof IntegerRangeType) { + $min = null; + $max = null; + if ($exponent->getMin() !== null) { + $min = self::pow($base->getValue(), $exponent->getMin()); + if ($min === null) { + return new ErrorType(); + } + } + if ($exponent->getMax() !== null) { + $max = self::pow($base->getValue(), $exponent->getMax()); + if ($max === null) { + return new ErrorType(); + } + } + + if (!is_float($min) && !is_float($max)) { + return IntegerRangeType::fromInterval($min, $max); + } + } + + if ($exponent instanceof ConstantScalarType) { + $result = self::pow($base->getValue(), $exponent->getValue()); + if ($result === null) { + return new ErrorType(); + } + + if (is_int($result)) { + return new ConstantIntegerType($result); + } + return new ConstantFloatType($result); + } + + return null; + } + + private static function pow(mixed $base, mixed $exp): float|int|null + { + if (is_string($base) && !is_numeric($base)) { + return null; + } + if (is_string($exp) && !is_numeric($exp)) { + return null; + } + return pow($base, $exp); + } + +} diff --git a/src/Type/ExpressionTypeResolverExtension.php b/src/Type/ExpressionTypeResolverExtension.php new file mode 100644 index 00000000..c1ce9547 --- /dev/null +++ b/src/Type/ExpressionTypeResolverExtension.php @@ -0,0 +1,29 @@ + $extensions + */ + public function __construct( + private array $extensions, + ) + { + } + + /** + * @return array + */ + public function getExtensions(): array + { + return $this->extensions; + } + +} diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php new file mode 100644 index 00000000..ddabd6d7 --- /dev/null +++ b/src/Type/FileTypeMapper.php @@ -0,0 +1,833 @@ + */ + private array $resolvedPhpDocBlockCache = []; + + private int $resolvedPhpDocBlockCacheCount = 0; + + public function __construct( + private ReflectionProviderProvider $reflectionProviderProvider, + private Parser $phpParser, + private PhpDocStringResolver $phpDocStringResolver, + private PhpDocNodeResolver $phpDocNodeResolver, + private AnonymousClassNameHelper $anonymousClassNameHelper, + private FileHelper $fileHelper, + ) + { + } + + /** @api */ + public function getResolvedPhpDoc( + ?string $fileName, + ?string $className, + ?string $traitName, + ?string $functionName, + string $docComment, + ): ResolvedPhpDocBlock + { + if ($className === null && $traitName !== null) { + throw new ShouldNotHappenException(); + } + + if ($docComment === '') { + return ResolvedPhpDocBlock::createEmpty(); + } + + if ($fileName !== null) { + $fileName = $this->fileHelper->normalizePath($fileName); + } + + $nameScopeKey = $this->getNameScopeKey($fileName, $className, $traitName, $functionName); + $phpDocKey = md5(sprintf('%s-%s', $nameScopeKey, $docComment)); + if (isset($this->resolvedPhpDocBlockCache[$phpDocKey])) { + return $this->resolvedPhpDocBlockCache[$phpDocKey]; + } + + if ($fileName === null) { + return $this->createResolvedPhpDocBlock($phpDocKey, new NameScope(null, []), $docComment, null); + } + + $nameScopeMap = []; + + if (!isset($this->inProcess[$fileName])) { + $nameScopeMap = $this->getNameScopeMap($fileName); + } + + if (isset($nameScopeMap[$nameScopeKey])) { + return $this->createResolvedPhpDocBlock($phpDocKey, $nameScopeMap[$nameScopeKey], $docComment, $fileName); + } + + if (!isset($this->inProcess[$fileName][$nameScopeKey])) { // wrong $fileName due to traits + return ResolvedPhpDocBlock::createEmpty(); + } + + if ($this->inProcess[$fileName][$nameScopeKey] === true) { // PHPDoc has cyclic dependency + return ResolvedPhpDocBlock::createEmpty(); + } + + if (is_callable($this->inProcess[$fileName][$nameScopeKey])) { + $resolveCallback = $this->inProcess[$fileName][$nameScopeKey]; + $this->inProcess[$fileName][$nameScopeKey] = true; + $this->inProcess[$fileName][$nameScopeKey] = $resolveCallback(); + } + + return $this->createResolvedPhpDocBlock($phpDocKey, $this->inProcess[$fileName][$nameScopeKey], $docComment, $fileName); + } + + private function createResolvedPhpDocBlock(string $phpDocKey, NameScope $nameScope, string $phpDocString, ?string $fileName): ResolvedPhpDocBlock + { + $phpDocNode = $this->phpDocStringResolver->resolve($phpDocString); + if ($this->resolvedPhpDocBlockCacheCount >= 2048) { + $this->resolvedPhpDocBlockCache = array_slice( + $this->resolvedPhpDocBlockCache, + 1, + null, + true, + ); + + $this->resolvedPhpDocBlockCacheCount--; + } + + $templateTypeMap = $nameScope->getTemplateTypeMap(); + $phpDocTemplateTypes = []; + $templateTags = $this->phpDocNodeResolver->resolveTemplateTags($phpDocNode, $nameScope); + foreach (array_keys($templateTags) as $name) { + $templateType = $templateTypeMap->getType($name); + if ($templateType === null) { + continue; + } + $phpDocTemplateTypes[$name] = $templateType; + } + + $this->resolvedPhpDocBlockCache[$phpDocKey] = ResolvedPhpDocBlock::create( + $phpDocNode, + $phpDocString, + $fileName, + $nameScope, + new TemplateTypeMap($phpDocTemplateTypes), + $templateTags, + $this->phpDocNodeResolver, + $this->reflectionProviderProvider->getReflectionProvider(), + ); + $this->resolvedPhpDocBlockCacheCount++; + + return $this->resolvedPhpDocBlockCache[$phpDocKey]; + } + + /** + * @return NameScope[] + */ + private function getNameScopeMap(string $fileName): array + { + if (!isset($this->memoryCache[$fileName])) { + $map = $this->createResolvedPhpDocMap($fileName); + if ($this->memoryCacheCount >= 2048) { + $this->memoryCache = array_slice( + $this->memoryCache, + 1, + null, + true, + ); + $this->memoryCacheCount--; + } + + $this->memoryCache[$fileName] = $map; + $this->memoryCacheCount++; + } + + return $this->memoryCache[$fileName]; + } + + /** + * @return NameScope[] + */ + private function createResolvedPhpDocMap(string $fileName): array + { + $phpDocNodeMap = $this->createPhpDocNodeMap($fileName, null, $fileName, [], $fileName); + $nameScopeMap = $this->createNameScopeMap($fileName, null, null, [], $fileName, $phpDocNodeMap); + $resolvedNameScopeMap = []; + + try { + $this->inProcess[$fileName] = $nameScopeMap; + + foreach ($nameScopeMap as $nameScopeKey => $resolveCallback) { + $this->inProcess[$fileName][$nameScopeKey] = true; + $this->inProcess[$fileName][$nameScopeKey] = $data = $resolveCallback(); + $resolvedNameScopeMap[$nameScopeKey] = $data; + } + + } finally { + unset($this->inProcess[$fileName]); + } + + return $resolvedNameScopeMap; + } + + /** + * @param array $traitMethodAliases + * @return array + */ + private function createPhpDocNodeMap(string $fileName, ?string $lookForTrait, ?string $traitUseClass, array $traitMethodAliases, string $originalClassFileName): array + { + /** @var array $phpDocNodeMap */ + $phpDocNodeMap = []; + + /** @var string[] $classStack */ + $classStack = []; + if ($lookForTrait !== null && $traitUseClass !== null) { + $classStack[] = $traitUseClass; + } + $namespace = null; + + $traitFound = false; + + /** @var array $functionStack */ + $functionStack = []; + $this->processNodes( + $this->phpParser->parseFile($fileName), + function (Node $node) use ($fileName, $lookForTrait, &$traitFound, $traitMethodAliases, $originalClassFileName, &$phpDocNodeMap, &$classStack, &$namespace, &$functionStack): ?int { + if ($node instanceof Node\Stmt\ClassLike) { + if ($traitFound && $fileName === $originalClassFileName) { + return self::SKIP_NODE; + } + + if ($lookForTrait !== null && !$traitFound) { + if (!$node instanceof Node\Stmt\Trait_) { + return self::SKIP_NODE; + } + if ((string) $node->namespacedName !== $lookForTrait) { + return self::SKIP_NODE; + } + + $traitFound = true; + $functionStack[] = null; + } else { + if ($node->name === null) { + if (!$node instanceof Node\Stmt\Class_) { + throw new ShouldNotHappenException(); + } + + $className = $this->anonymousClassNameHelper->getAnonymousClassName($node, $fileName); + } elseif ($node instanceof Node\Stmt\Class_ && $node->isAnonymous()) { + $className = $node->name->name; + } else { + if ($traitFound) { + return self::SKIP_NODE; + } + $className = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } + $classStack[] = $className; + $functionStack[] = null; + } + } elseif ($node instanceof Node\Stmt\ClassMethod) { + if (array_key_exists($node->name->name, $traitMethodAliases)) { + $functionStack[] = $traitMethodAliases[$node->name->name]; + } else { + $functionStack[] = $node->name->name; + } + } elseif ($node instanceof Node\Stmt\Function_) { + $functionStack[] = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + $functionStack[] = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } + } + + $className = $classStack[count($classStack) - 1] ?? null; + $functionName = $functionStack[count($functionStack) - 1] ?? null; + + if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + $docComment = GetLastDocComment::forNode($node); + if ($docComment !== null) { + $nameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, $functionName); + $phpDocNodeMap[$nameScopeKey] = $this->phpDocStringResolver->resolve($docComment); + } + + return null; + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + $docComment = GetLastDocComment::forNode($node); + if ($docComment !== null) { + $nameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, $functionName); + $phpDocNodeMap[$nameScopeKey] = $this->phpDocStringResolver->resolve($docComment); + } + } + + return null; + } + + if ($node instanceof Node\Stmt\Namespace_) { + $namespace = $node->name !== null ? (string) $node->name : null; + } elseif ($node instanceof Node\Stmt\TraitUse) { + $traitMethodAliases = []; + foreach ($node->adaptations as $traitUseAdaptation) { + if (!$traitUseAdaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { + continue; + } + + if ($traitUseAdaptation->newName === null) { + continue; + } + + $methodName = $traitUseAdaptation->method->toString(); + $newTraitName = $traitUseAdaptation->newName->toString(); + + if ($traitUseAdaptation->trait === null) { + foreach ($node->traits as $traitName) { + $traitMethodAliases[$traitName->toString()][$methodName] = $newTraitName; + } + continue; + } + + $traitMethodAliases[$traitUseAdaptation->trait->toString()][$methodName] = $newTraitName; + } + + foreach ($node->traits as $traitName) { + /** @var class-string $traitName */ + $traitName = (string) $traitName; + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + if (!$reflectionProvider->hasClass($traitName)) { + continue; + } + + $traitReflection = $reflectionProvider->getClass($traitName); + if (!$traitReflection->isTrait()) { + continue; + } + if ($traitReflection->getFileName() === null) { + continue; + } + if (!is_file($traitReflection->getFileName())) { + continue; + } + + $className = $classStack[count($classStack) - 1] ?? null; + if ($className === null) { + throw new ShouldNotHappenException(); + } + + $phpDocNodeMap = array_merge($phpDocNodeMap, $this->createPhpDocNodeMap( + $traitReflection->getFileName(), + $traitName, + $className, + $traitMethodAliases[$traitName] ?? [], + $originalClassFileName, + )); + } + } + + return null; + }, + static function (Node $node) use (&$namespace, &$functionStack, &$classStack): void { + if ($node instanceof Node\Stmt\ClassLike) { + if (count($classStack) === 0) { + throw new ShouldNotHappenException(); + } + array_pop($classStack); + + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } elseif ($node instanceof Node\Stmt\Namespace_) { + $namespace = null; + } elseif ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } + } + }, + ); + + return $phpDocNodeMap; + } + + /** + * @param array $traitMethodAliases + * @param array $phpDocNodeMap + * @return (callable(): NameScope)[] + */ + private function createNameScopeMap( + string $fileName, + ?string $lookForTrait, + ?string $traitUseClass, + array $traitMethodAliases, + string $originalClassFileName, + array $phpDocNodeMap, + ): array + { + /** @var (callable(): NameScope)[] $nameScopeMap */ + $nameScopeMap = []; + + /** @var (callable(): TemplateTypeMap)[] $typeMapStack */ + $typeMapStack = []; + + /** @var array> $typeAliasStack */ + $typeAliasStack = []; + + /** @var string[] $classStack */ + $classStack = []; + if ($lookForTrait !== null && $traitUseClass !== null) { + $classStack[] = $traitUseClass; + $typeAliasStack[] = []; + } + $namespace = null; + + $traitFound = false; + + /** @var array $functionStack */ + $functionStack = []; + $uses = []; + $constUses = []; + $this->processNodes( + $this->phpParser->parseFile($fileName), + function (Node $node) use ($fileName, $lookForTrait, $phpDocNodeMap, &$traitFound, $traitMethodAliases, $originalClassFileName, &$nameScopeMap, &$classStack, &$typeAliasStack, &$namespace, &$functionStack, &$uses, &$typeMapStack, &$constUses): ?int { + if ($node instanceof Node\Stmt\ClassLike) { + if ($traitFound && $fileName === $originalClassFileName) { + return self::SKIP_NODE; + } + + if ($lookForTrait !== null && !$traitFound) { + if (!$node instanceof Node\Stmt\Trait_) { + return self::SKIP_NODE; + } + if ((string) $node->namespacedName !== $lookForTrait) { + return self::SKIP_NODE; + } + + $traitFound = true; + $traitNameScopeKey = $this->getNameScopeKey($originalClassFileName, $classStack[count($classStack) - 1] ?? null, $lookForTrait, null); + if (array_key_exists($traitNameScopeKey, $phpDocNodeMap)) { + $typeAliasStack[] = $this->getTypeAliasesMap($phpDocNodeMap[$traitNameScopeKey]); + } else { + $typeAliasStack[] = []; + } + $functionStack[] = null; + } else { + if ($node->name === null) { + if (!$node instanceof Node\Stmt\Class_) { + throw new ShouldNotHappenException(); + } + + $className = $this->anonymousClassNameHelper->getAnonymousClassName($node, $fileName); + } elseif ($node instanceof Node\Stmt\Class_ && $node->isAnonymous()) { + $className = $node->name->name; + } else { + if ($traitFound) { + return self::SKIP_NODE; + } + $className = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } + $classStack[] = $className; + $classNameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, null); + if (array_key_exists($classNameScopeKey, $phpDocNodeMap)) { + $typeAliasStack[] = $this->getTypeAliasesMap($phpDocNodeMap[$classNameScopeKey]); + } else { + $typeAliasStack[] = []; + } + $functionStack[] = null; + } + } elseif ($node instanceof Node\Stmt\ClassMethod) { + if (array_key_exists($node->name->name, $traitMethodAliases)) { + $functionStack[] = $traitMethodAliases[$node->name->name]; + } else { + $functionStack[] = $node->name->name; + } + } elseif ($node instanceof Node\Stmt\Function_) { + $functionStack[] = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + $functionStack[] = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } + } + + $className = $classStack[count($classStack) - 1] ?? null; + $functionName = $functionStack[count($functionStack) - 1] ?? null; + $nameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, $functionName); + + if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + // property hook skipped on purpose, it does not support @template + if (array_key_exists($nameScopeKey, $phpDocNodeMap)) { + $phpDocNode = $phpDocNodeMap[$nameScopeKey]; + $typeMapStack[] = function () use ($namespace, $uses, $className, $lookForTrait, $functionName, $phpDocNode, $typeMapStack, $typeAliasStack, $constUses): TemplateTypeMap { + $typeMapCb = $typeMapStack[count($typeMapStack) - 1] ?? null; + $currentTypeMap = $typeMapCb !== null ? $typeMapCb() : null; + $typeAliasesMap = $typeAliasStack[count($typeAliasStack) - 1] ?? []; + $nameScope = new NameScope($namespace, $uses, $className, $functionName, $currentTypeMap, $typeAliasesMap, false, $constUses, $lookForTrait); + $templateTags = $this->phpDocNodeResolver->resolveTemplateTags($phpDocNode, $nameScope); + $templateTypeScope = $nameScope->getTemplateTypeScope(); + if ($templateTypeScope === null) { + throw new ShouldNotHappenException(); + } + $templateTypeMap = new TemplateTypeMap(array_map(static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), $templateTags)); + $nameScope = $nameScope->withTemplateTypeMap($templateTypeMap); + $templateTags = $this->phpDocNodeResolver->resolveTemplateTags($phpDocNode, $nameScope); + $templateTypeMap = new TemplateTypeMap(array_map(static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), $templateTags)); + + return new TemplateTypeMap(array_merge( + $currentTypeMap !== null ? $currentTypeMap->getTypes() : [], + $templateTypeMap->getTypes(), + )); + }; + } + } + + $typeMapCb = $typeMapStack[count($typeMapStack) - 1] ?? null; + $typeAliasesMap = $typeAliasStack[count($typeAliasStack) - 1] ?? []; + + if ( + ( + $node instanceof Node\PropertyHook + || ( + $node instanceof Node\Stmt + && !$node instanceof Node\Stmt\Namespace_ + && !$node instanceof Node\Stmt\Declare_ + && !$node instanceof Node\Stmt\Use_ + && !$node instanceof Node\Stmt\GroupUse + && !$node instanceof Node\Stmt\TraitUse + && !$node instanceof Node\Stmt\TraitUseAdaptation + && !$node instanceof Node\Stmt\InlineHTML + && !($node instanceof Node\Stmt\Expression && $node->expr instanceof Node\Expr\Include_) + ) + ) && !array_key_exists($nameScopeKey, $nameScopeMap) + ) { + $nameScopeMap[$nameScopeKey] = static fn (): NameScope => new NameScope( + $namespace, + $uses, + $className, + $functionName, + ($typeMapCb !== null ? $typeMapCb() : TemplateTypeMap::createEmpty()), + $typeAliasesMap, + false, + $constUses, + $lookForTrait, + ); + } + + if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + // property hook skipped on purpose, it does not support @template + if (array_key_exists($nameScopeKey, $phpDocNodeMap)) { + return self::POP_TYPE_MAP_STACK; + } + + return null; + } + + if ($node instanceof Node\Stmt\Namespace_) { + $namespace = $node->name !== null ? (string) $node->name : null; + } elseif ($node instanceof Node\Stmt\Use_) { + if ($node->type === Node\Stmt\Use_::TYPE_NORMAL) { + foreach ($node->uses as $use) { + $uses[strtolower($use->getAlias()->name)] = (string) $use->name; + } + } elseif ($node->type === Node\Stmt\Use_::TYPE_CONSTANT) { + foreach ($node->uses as $use) { + $constUses[strtolower($use->getAlias()->name)] = (string) $use->name; + } + } + } elseif ($node instanceof Node\Stmt\GroupUse) { + $prefix = (string) $node->prefix; + foreach ($node->uses as $use) { + if ($node->type === Node\Stmt\Use_::TYPE_NORMAL || $use->type === Node\Stmt\Use_::TYPE_NORMAL) { + $uses[strtolower($use->getAlias()->name)] = sprintf('%s\\%s', $prefix, (string) $use->name); + } elseif ($node->type === Node\Stmt\Use_::TYPE_CONSTANT || $use->type === Node\Stmt\Use_::TYPE_CONSTANT) { + $constUses[strtolower($use->getAlias()->name)] = sprintf('%s\\%s', $prefix, (string) $use->name); + } + } + } elseif ($node instanceof Node\Stmt\TraitUse) { + $traitMethodAliases = []; + foreach ($node->adaptations as $traitUseAdaptation) { + if (!$traitUseAdaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { + continue; + } + + if ($traitUseAdaptation->newName === null) { + continue; + } + + $methodName = $traitUseAdaptation->method->toString(); + $newTraitName = $traitUseAdaptation->newName->toString(); + + if ($traitUseAdaptation->trait === null) { + foreach ($node->traits as $traitName) { + $traitMethodAliases[$traitName->toString()][$methodName] = $newTraitName; + } + continue; + } + + $traitMethodAliases[$traitUseAdaptation->trait->toString()][$methodName] = $newTraitName; + } + + $useDocComment = null; + if ($node->getDocComment() !== null) { + $useDocComment = $node->getDocComment()->getText(); + } + + foreach ($node->traits as $traitName) { + /** @var class-string $traitName */ + $traitName = (string) $traitName; + $reflectionProvider = $this->reflectionProviderProvider->getReflectionProvider(); + if (!$reflectionProvider->hasClass($traitName)) { + continue; + } + + $traitReflection = $reflectionProvider->getClass($traitName); + if (!$traitReflection->isTrait()) { + continue; + } + if ($traitReflection->getFileName() === null) { + continue; + } + if (!is_file($traitReflection->getFileName())) { + continue; + } + + $className = $classStack[count($classStack) - 1] ?? null; + if ($className === null) { + throw new ShouldNotHappenException(); + } + + $traitPhpDocMap = $this->createNameScopeMap( + $traitReflection->getFileName(), + $traitName, + $className, + $traitMethodAliases[$traitName] ?? [], + $originalClassFileName, + $phpDocNodeMap, + ); + $finalTraitPhpDocMap = []; + foreach ($traitPhpDocMap as $nameScopeTraitKey => $callback) { + $finalTraitPhpDocMap[$nameScopeTraitKey] = function () use ($callback, $traitReflection, $fileName, $className, $lookForTrait, $useDocComment): NameScope { + /** @var NameScope $original */ + $original = $callback(); + if (!$traitReflection->isGeneric()) { + return $original; + } + + $traitTemplateTypeMap = $traitReflection->getTemplateTypeMap(); + + $useType = null; + if ($useDocComment !== null) { + $useTags = $this->getResolvedPhpDoc( + $fileName, + $className, + $lookForTrait, + null, + $useDocComment, + )->getUsesTags(); + foreach ($useTags as $useTag) { + $useTagType = $useTag->getType(); + if (!$useTagType instanceof GenericObjectType) { + continue; + } + + if ($useTagType->getClassName() !== $traitReflection->getName()) { + continue; + } + + $useType = $useTagType; + break; + } + } + + if ($useType === null) { + return $original->withTemplateTypeMap($traitTemplateTypeMap->resolveToBounds()); + } + + $transformedTraitTypeMap = $traitReflection->typeMapFromList($useType->getTypes()); + + return $original->withTemplateTypeMap($traitTemplateTypeMap->map(static fn (string $name, Type $type): Type => TemplateTypeHelper::resolveTemplateTypes($type, $transformedTraitTypeMap, TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createStatic()))); + }; + } + $nameScopeMap = array_merge($nameScopeMap, $finalTraitPhpDocMap); + } + } + + return null; + }, + static function (Node $node, $callbackResult) use (&$namespace, &$functionStack, &$classStack, &$typeAliasStack, &$uses, &$typeMapStack, &$constUses): void { + if ($node instanceof Node\Stmt\ClassLike) { + if (count($classStack) === 0) { + throw new ShouldNotHappenException(); + } + array_pop($classStack); + + if (count($typeAliasStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($typeAliasStack); + + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } elseif ($node instanceof Node\Stmt\Namespace_) { + $namespace = null; + $uses = []; + $constUses = []; + } elseif ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute('propertyName'); + if ($propertyName !== null) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } + } + if ($callbackResult !== self::POP_TYPE_MAP_STACK) { + return; + } + + if (count($typeMapStack) === 0) { + throw new ShouldNotHappenException(); + } + array_pop($typeMapStack); + }, + ); + + if (count($typeMapStack) > 0) { + throw new ShouldNotHappenException(); + } + + return $nameScopeMap; + } + + /** + * @return array + */ + private function getTypeAliasesMap(PhpDocNode $phpDocNode): array + { + $nameScope = new NameScope(null, []); + + $aliasesMap = []; + foreach (array_keys($this->phpDocNodeResolver->resolveTypeAliasImportTags($phpDocNode, $nameScope)) as $key) { + $aliasesMap[$key] = true; + } + + foreach (array_keys($this->phpDocNodeResolver->resolveTypeAliasTags($phpDocNode, $nameScope)) as $key) { + $aliasesMap[$key] = true; + } + + return $aliasesMap; + } + + /** + * @param Node[]|Node|scalar|null $node + * @param Closure(Node $node): mixed $nodeCallback + * @param Closure(Node $node, mixed $callbackResult): void $endNodeCallback + */ + private function processNodes($node, Closure $nodeCallback, Closure $endNodeCallback): void + { + if ($node instanceof Node) { + $callbackResult = $nodeCallback($node); + if ($callbackResult === self::SKIP_NODE) { + return; + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->{$subNodeName}; + $this->processNodes($subNode, $nodeCallback, $endNodeCallback); + } + $endNodeCallback($node, $callbackResult); + } elseif (is_array($node)) { + foreach ($node as $subNode) { + $this->processNodes($subNode, $nodeCallback, $endNodeCallback); + } + } + } + + private function getNameScopeKey( + ?string $file, + ?string $class, + ?string $trait, + ?string $function, + ): string + { + if ($class === null && $trait === null && $function === null) { + return md5(sprintf('%s', $file ?? 'no-file')); + } + + if ($class !== null && str_contains($class, 'class@anonymous')) { + throw new ShouldNotHappenException('Wrong anonymous class name, FilTypeMapper should be called with ClassReflection::getName().'); + } + + return md5(sprintf('%s-%s-%s-%s', $file ?? 'no-file', $class, $trait, $function)); + } + +} diff --git a/src/Type/FloatType.php b/src/Type/FloatType.php new file mode 100644 index 00000000..31630d34 --- /dev/null +++ b/src/Type/FloatType.php @@ -0,0 +1,297 @@ +isInteger()->yes()) { + return AcceptsResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return get_class($type) === static::class; + } + + public function describe(VerbosityLevel $level): string + { + return 'float'; + } + + public function toNumber(): Type + { + return $this; + } + + public function toAbsoluteNumber(): Type + { + return $this; + } + + public function toFloat(): Type + { + return $this; + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toString(): Type + { + return new IntersectionType([ + new StringType(), + new AccessoryUppercaseStringType(), + new AccessoryNumericStringType(), + ]); + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return new IntegerType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return ExponentiateHelper::exponentiate($this, $exponent); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('float'); + } + + public function getFiniteTypes(): array + { + return []; + } + +} diff --git a/src/Type/FunctionParameterClosureTypeExtension.php b/src/Type/FunctionParameterClosureTypeExtension.php new file mode 100644 index 00000000..bfc3aac2 --- /dev/null +++ b/src/Type/FunctionParameterClosureTypeExtension.php @@ -0,0 +1,33 @@ +value === self::LESS_SPECIFIC; + } + + public function isMoreSpecific(): bool + { + return $this->value === self::MORE_SPECIFIC; + } + + public function isTemplateArgument(): bool + { + return $this->value === self::TEMPLATE_ARGUMENT; + } + +} diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php new file mode 100644 index 00000000..a9b4b038 --- /dev/null +++ b/src/Type/Generic/GenericClassStringType.php @@ -0,0 +1,226 @@ +type->getReferencedClasses(); + } + + public function getGenericType(): Type + { + return $this->type; + } + + public function getClassStringObjectType(): Type + { + return $this->getGenericType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->getClassStringObjectType(); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('%s<%s>', parent::describe($level), $this->type->describe($level)); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if ($type instanceof ConstantStringType) { + if (!$type->isClassString()->yes()) { + return AcceptsResult::createNo(); + } + + $objectType = new ObjectType($type->getValue()); + } elseif ($type instanceof self) { + $objectType = $type->type; + } elseif ($type instanceof ClassStringType) { + $objectType = new ObjectWithoutClassType(); + } elseif ($type instanceof StringType) { + return AcceptsResult::createMaybe(); + } else { + return AcceptsResult::createNo(); + } + + return $this->type->accepts($objectType, $strictTypes); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($type instanceof ConstantStringType) { + $genericType = $this->type; + if ($genericType instanceof MixedType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($genericType instanceof StaticType) { + $genericType = $genericType->getStaticObjectType(); + } + + // We are transforming constant class-string to ObjectType. But we need to filter out + // an uncertainty originating in possible ObjectType's class subtypes. + $objectType = new ObjectType($type->getValue()); + + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType + // uncertainty into account. + if ($genericType instanceof TemplateType) { + $isSuperType = $genericType->getBound()->isSuperTypeOf($objectType); + } else { + $isSuperType = $genericType->isSuperTypeOf($objectType); + } + + if (!$type->isClassString()->yes()) { + $isSuperType = $isSuperType->and(IsSuperTypeOfResult::createMaybe()); + } + + return $isSuperType; + } elseif ($type instanceof self) { + return $this->type->isSuperTypeOf($type->type); + } elseif ($type instanceof StringType) { + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function traverse(callable $cb): Type + { + $newType = $cb($this->type); + if ($newType === $this->type) { + return $this; + } + + return new self($newType); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $newType = $cb($this->type, $right->getClassStringObjectType()); + if ($newType === $this->type) { + return $this; + } + + return new self($newType); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if ($receivedType instanceof ConstantStringType) { + $typeToInfer = new ObjectType($receivedType->getValue()); + } elseif ($receivedType instanceof self) { + $typeToInfer = $receivedType->type; + } elseif ($receivedType->isClassString()->yes()) { + $typeToInfer = $this->type; + if ($typeToInfer instanceof TemplateType) { + $typeToInfer = $typeToInfer->getBound(); + } + + $typeToInfer = TypeCombinator::intersect($typeToInfer, new ObjectWithoutClassType()); + } else { + return TemplateTypeMap::createEmpty(); + } + + return $this->type->inferTemplateTypes($typeToInfer); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + + return $this->type->getReferencedTemplateTypes($variance); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if (!parent::equals($type)) { + return false; + } + + if (!$this->type->equals($type->type)) { + return false; + } + + return true; + } + + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode( + new IdentifierTypeNode('class-string'), + [ + $this->type->toPhpDocNode(), + ], + ); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ConstantStringType && $typeToRemove->isClassString()->yes()) { + $generic = $this->getGenericType(); + + $genericObjectClassNames = $generic->getObjectClassNames(); + if (count($genericObjectClassNames) === 1) { + $classReflection = ReflectionProviderStaticAccessor::getInstance()->getClass($genericObjectClassNames[0]); + if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) { + return new NeverType(); + } + } + } + + return parent::tryRemove($typeToRemove); + } + +} diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php new file mode 100644 index 00000000..7691b693 --- /dev/null +++ b/src/Type/Generic/GenericObjectType.php @@ -0,0 +1,394 @@ + $types + * @param array $variances + */ + public function __construct( + string $mainType, + private array $types, + ?Type $subtractedType = null, + private ?ClassReflection $classReflection = null, + private array $variances = [], + ) + { + parent::__construct($mainType, $subtractedType, $classReflection); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf( + '%s<%s>', + parent::describe($level), + implode(', ', array_map( + static fn (Type $type, ?TemplateTypeVariance $variance = null): string => TypeProjectionHelper::describe($type, $variance, $level), + $this->types, + $this->variances, + )), + ); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if (!parent::equals($type)) { + return false; + } + + if (count($this->types) !== count($type->types)) { + return false; + } + + foreach ($this->types as $i => $genericType) { + $otherGenericType = $type->types[$i]; + if (!$genericType->equals($otherGenericType)) { + return false; + } + + $variance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); + $otherVariance = $type->variances[$i] ?? TemplateTypeVariance::createInvariant(); + if (!$variance->equals($otherVariance)) { + return false; + } + } + + return true; + } + + public function getReferencedClasses(): array + { + $classes = parent::getReferencedClasses(); + foreach ($this->types as $type) { + foreach ($type->getReferencedClasses() as $referencedClass) { + $classes[] = $referencedClass; + } + } + + return $classes; + } + + /** @return array */ + public function getTypes(): array + { + return $this->types; + } + + /** @return array */ + public function getVariances(): array + { + return $this->variances; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return $this->isSuperTypeOfInternal($type, false); + } + + private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSuperTypeOfResult + { + $nakedSuperTypeOf = parent::isSuperTypeOf($type); + if ($nakedSuperTypeOf->no()) { + return $nakedSuperTypeOf; + } + + if (!$type instanceof ObjectType) { + return $nakedSuperTypeOf; + } + + $ancestor = $type->getAncestorWithClassName($this->getClassName()); + if ($ancestor === null) { + return $nakedSuperTypeOf; + } + if (!$ancestor instanceof self) { + if ($acceptsContext) { + return $nakedSuperTypeOf; + } + + return $nakedSuperTypeOf->and(IsSuperTypeOfResult::createMaybe()); + } + + if (count($this->types) !== count($ancestor->types)) { + return IsSuperTypeOfResult::createNo(); + } + + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return $nakedSuperTypeOf; + } + + $typeList = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()); + $results = []; + foreach ($typeList as $i => $templateType) { + if (!isset($ancestor->types[$i])) { + continue; + } + if (!isset($this->types[$i])) { + continue; + } + if ($templateType instanceof ErrorType) { + continue; + } + if (!$templateType instanceof TemplateType) { + throw new ShouldNotHappenException(); + } + + $thisVariance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); + $ancestorVariance = $ancestor->variances[$i] ?? TemplateTypeVariance::createInvariant(); + if (!$thisVariance->invariant()) { + $results[] = $thisVariance->isValidVariance($templateType, $this->types[$i], $ancestor->types[$i]); + } else { + $results[] = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i]); + } + + $results[] = IsSuperTypeOfResult::createFromBoolean($thisVariance->validPosition($ancestorVariance)); + } + + if (count($results) === 0) { + return $nakedSuperTypeOf; + } + + $result = IsSuperTypeOfResult::createYes(); + foreach ($results as $innerResult) { + $result = $result->and($innerResult); + } + + return $result; + } + + public function getClassReflection(): ?ClassReflection + { + if ($this->classReflection !== null) { + return $this->classReflection; + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($this->getClassName())) { + return null; + } + + return $this->classReflection = $reflectionProvider->getClass($this->getClassName()) + ->withTypes($this->types) + ->withVariances($this->variances); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $prototype = parent::getUnresolvedPropertyPrototype($propertyName, $scope); + + return $prototype->doNotResolveTemplateTypeMapToBounds(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $prototype = parent::getUnresolvedMethodPrototype($methodName, $scope); + + return $prototype->doNotResolveTemplateTypeMapToBounds(); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if (!$receivedType instanceof TypeWithClassName) { + return TemplateTypeMap::createEmpty(); + } + + $ancestor = $receivedType->getAncestorWithClassName($this->getClassName()); + + if ($ancestor === null) { + return TemplateTypeMap::createEmpty(); + } + $ancestorClassReflection = $ancestor->getClassReflection(); + if ($ancestorClassReflection === null) { + return TemplateTypeMap::createEmpty(); + } + + $otherTypes = $ancestorClassReflection->typeMapToList($ancestorClassReflection->getActiveTemplateTypeMap()); + $typeMap = TemplateTypeMap::createEmpty(); + + foreach ($this->getTypes() as $i => $type) { + $other = $otherTypes[$i] ?? new ErrorType(); + $typeMap = $typeMap->union($type->inferTemplateTypes($other)); + } + + return $typeMap; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $classReflection = $this->getClassReflection(); + if ($classReflection !== null) { + $typeList = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()); + } else { + $typeList = []; + } + + $references = []; + + foreach ($this->types as $i => $type) { + $effectiveVariance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); + if ($effectiveVariance->invariant() && isset($typeList[$i]) && $typeList[$i] instanceof TemplateType) { + $effectiveVariance = $typeList[$i]->getVariance(); + } + + $variance = $positionVariance->compose($effectiveVariance); + foreach ($type->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->getSubtractedType() !== null ? $cb($this->getSubtractedType()) : null; + + $typesChanged = false; + $types = []; + foreach ($this->types as $type) { + $newType = $cb($type); + $types[] = $newType; + if ($newType === $type) { + continue; + } + + $typesChanged = true; + } + + if ($subtractedType !== $this->getSubtractedType() || $typesChanged) { + return $this->recreate($this->getClassName(), $types, $subtractedType, $this->variances); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof TypeWithClassName) { + return $this; + } + + $ancestor = $right->getAncestorWithClassName($this->getClassName()); + if (!$ancestor instanceof self) { + return $this; + } + + if (count($this->types) !== count($ancestor->types)) { + return $this; + } + + $typesChanged = false; + $types = []; + foreach ($this->types as $i => $leftType) { + $rightType = $ancestor->types[$i]; + $newType = $cb($leftType, $rightType); + $types[] = $newType; + if ($newType === $leftType) { + continue; + } + + $typesChanged = true; + } + + if ($typesChanged) { + return $this->recreate($this->getClassName(), $types, null); + } + + return $this; + } + + /** + * @param Type[] $types + * @param TemplateTypeVariance[] $variances + */ + protected function recreate(string $className, array $types, ?Type $subtractedType, array $variances = []): self + { + return new self( + $className, + $types, + $subtractedType, + null, + $variances, + ); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + return new self($this->getClassName(), $this->types, $subtractedType, null, $this->variances); + } + + public function toPhpDocNode(): TypeNode + { + /** @var IdentifierTypeNode $parent */ + $parent = parent::toPhpDocNode(); + return new GenericTypeNode( + $parent, + array_map(static fn (Type $type) => $type->toPhpDocNode(), $this->types), + array_map(static fn (TemplateTypeVariance $variance) => $variance->toPhpDocNodeVariance(), $this->variances), + ); + } + +} diff --git a/src/Type/Generic/GenericStaticType.php b/src/Type/Generic/GenericStaticType.php new file mode 100644 index 00000000..9ccc9895 --- /dev/null +++ b/src/Type/Generic/GenericStaticType.php @@ -0,0 +1,273 @@ + $types + * @param array $variances + */ + public function __construct( + private ClassReflection $classReflection, + private array $types, + private ?Type $subtractedType, + private array $variances, + ) + { + if (count($this->types) === 0) { + throw new ShouldNotHappenException('Cannot create GenericStaticType with zero types.'); + } + parent::__construct($classReflection, $subtractedType); + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** @return array */ + public function getVariances(): array + { + return $this->variances; + } + + public function getStaticObjectType(): ObjectType + { + if ($this->staticObjectType === null) { + if ($this->classReflection->isGeneric()) { + return $this->staticObjectType = new GenericObjectType( + $this->classReflection->getName(), + $this->types, + $this->subtractedType, + $this->classReflection, + $this->variances, + ); + } + + return $this->staticObjectType = parent::getStaticObjectType(); + } + + return $this->staticObjectType; + } + + public function changeBaseClass(ClassReflection $classReflection): StaticType + { + if ($classReflection->getName() === $this->getClassName()) { + return $this; + } + + if (!$classReflection->isGeneric()) { + return new StaticType($classReflection); + } + + $templateTags = $this->getClassReflection()->getTemplateTags(); + $i = 0; + $indexedTypes = []; + $indexedVariances = []; + foreach ($templateTags as $typeName => $tag) { + if (!array_key_exists($i, $this->types)) { + break; + } + if (!array_key_exists($i, $this->variances)) { + break; + } + $indexedTypes[$typeName] = $this->types[$i]; + $indexedVariances[$typeName] = $this->variances[$i]; + $i++; + } + + $newType = new GenericObjectType($classReflection->getName(), $classReflection->typeMapToList($classReflection->getTemplateTypeMap())); + $ancestorType = $newType->getAncestorWithClassName($this->getClassName()); + if ($ancestorType === null) { + return new self( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + $this->subtractedType, + $classReflection->varianceMapToList($classReflection->getCallSiteVarianceMap()), + ); + } + + $ancestorClassReflection = $ancestorType->getClassReflection(); + if ($ancestorClassReflection === null) { + return new self( + $classReflection, + $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), + $this->subtractedType, + $classReflection->varianceMapToList($classReflection->getCallSiteVarianceMap()), + ); + } + + $newClassTypes = []; + $newClassVariances = []; + foreach ($ancestorClassReflection->getActiveTemplateTypeMap()->getTypes() as $typeName => $templateType) { + if (!$templateType instanceof TemplateType) { + continue; + } + + if (!array_key_exists($typeName, $indexedTypes)) { + continue; + } + + $newClassTypes[$templateType->getName()] = $indexedTypes[$typeName]; + $newClassVariances[$templateType->getName()] = $indexedVariances[$typeName]; + } + + return new self($classReflection, $classReflection->typeMapToList(new TemplateTypeMap($newClassTypes)), $this->subtractedType, $classReflection->varianceMapToList(new TemplateTypeVarianceMap($newClassVariances))); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($type instanceof self) { + return $this->getStaticObjectType()->isSuperTypeOf($type->getStaticObjectType()); + } + + return parent::isSuperTypeOf($type)->and(IsSuperTypeOfResult::createMaybe()); + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->getSubtractedType() !== null ? $cb($this->getSubtractedType()) : null; + + $typesChanged = false; + $types = []; + foreach ($this->types as $type) { + $newType = $cb($type); + $types[] = $newType; + if ($newType === $type) { + continue; + } + + $typesChanged = true; + } + + if ($subtractedType !== $this->getSubtractedType() || $typesChanged) { + return new self( + $this->classReflection, + $types, + $subtractedType, + $this->variances, + ); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof TypeWithClassName) { + return $this; + } + + $ancestor = $right->getAncestorWithClassName($this->getClassName()); + if (!$ancestor instanceof self) { + return $this; + } + + if (count($this->types) !== count($ancestor->types)) { + return $this; + } + + $typesChanged = false; + $types = []; + foreach ($this->types as $i => $leftType) { + $rightType = $ancestor->types[$i]; + $newType = $cb($leftType, $rightType); + $types[] = $newType; + if ($newType === $leftType) { + continue; + } + + $typesChanged = true; + } + + if ($typesChanged) { + return new self( + $this->classReflection, + $types, + null, + $this->variances, + ); + } + + return $this; + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + if ($subtractedType !== null) { + $classReflection = $this->getClassReflection(); + if ($classReflection->getAllowedSubTypes() !== null) { + $objectType = $this->getStaticObjectType()->changeSubtractedType($subtractedType); + if ($objectType instanceof NeverType) { + return $objectType; + } + + if ($objectType instanceof ObjectType && $objectType->getSubtractedType() !== null) { + return new self($classReflection, $this->types, $objectType->getSubtractedType(), $this->variances); + } + + return TypeCombinator::intersect($this, $objectType); + } + } + + return new self( + $this->classReflection, + $this->types, + $subtractedType, + $this->variances, + ); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return $this->getStaticObjectType()->inferTemplateTypes($receivedType); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->getStaticObjectType()->getReferencedTemplateTypes($positionVariance); + } + + public function toPhpDocNode(): TypeNode + { + /** @var IdentifierTypeNode $parent */ + $parent = parent::toPhpDocNode(); + return new GenericTypeNode( + $parent, + array_map(static fn (Type $type) => $type->toPhpDocNode(), $this->types), + array_map(static fn (TemplateTypeVariance $variance) => $variance->toPhpDocNodeVariance(), $this->variances), + ); + } + +} diff --git a/src/Type/Generic/TemplateArrayType.php b/src/Type/Generic/TemplateArrayType.php new file mode 100644 index 00000000..5be2a130 --- /dev/null +++ b/src/Type/Generic/TemplateArrayType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ArrayType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getKeyType(), $bound->getItemType()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateBenevolentUnionType.php b/src/Type/Generic/TemplateBenevolentUnionType.php new file mode 100644 index 00000000..f9466e7f --- /dev/null +++ b/src/Type/Generic/TemplateBenevolentUnionType.php @@ -0,0 +1,68 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + BenevolentUnionType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getTypes()); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + /** @param Type[] $types */ + public function withTypes(array $types): self + { + return new self( + $this->scope, + $this->strategy, + $this->variance, + $this->name, + new BenevolentUnionType($types), + $this->default, + ); + } + + public function filterTypes(callable $filterCb): Type + { + $result = parent::filterTypes($filterCb); + if (!$result instanceof TemplateType) { + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $result, + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + return $result; + } + +} diff --git a/src/Type/Generic/TemplateBooleanType.php b/src/Type/Generic/TemplateBooleanType.php new file mode 100644 index 00000000..143d595b --- /dev/null +++ b/src/Type/Generic/TemplateBooleanType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + BooleanType $bound, + ?Type $default, + ) + { + parent::__construct(); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateConstantArrayType.php b/src/Type/Generic/TemplateConstantArrayType.php new file mode 100644 index 00000000..a78e1373 --- /dev/null +++ b/src/Type/Generic/TemplateConstantArrayType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ConstantArrayType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getKeyTypes(), $bound->getValueTypes(), $bound->getNextAutoIndexes(), $bound->getOptionalKeys(), $bound->isList()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateConstantIntegerType.php b/src/Type/Generic/TemplateConstantIntegerType.php new file mode 100644 index 00000000..ebfc1f84 --- /dev/null +++ b/src/Type/Generic/TemplateConstantIntegerType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ConstantIntegerType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getValue()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateConstantStringType.php b/src/Type/Generic/TemplateConstantStringType.php new file mode 100644 index 00000000..1159d305 --- /dev/null +++ b/src/Type/Generic/TemplateConstantStringType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ConstantStringType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getValue()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateFloatType.php b/src/Type/Generic/TemplateFloatType.php new file mode 100644 index 00000000..2c84a5f3 --- /dev/null +++ b/src/Type/Generic/TemplateFloatType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + FloatType $bound, + ?Type $default, + ) + { + parent::__construct(); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateGenericObjectType.php b/src/Type/Generic/TemplateGenericObjectType.php new file mode 100644 index 00000000..a09f4177 --- /dev/null +++ b/src/Type/Generic/TemplateGenericObjectType.php @@ -0,0 +1,51 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + GenericObjectType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getClassName(), $bound->getTypes(), null, null, $bound->getVariances()); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function recreate(string $className, array $types, ?Type $subtractedType, array $variances = []): GenericObjectType + { + return new self( + $this->scope, + $this->strategy, + $this->variance, + $this->name, + $this->getBound(), + $this->default, + ); + } + +} diff --git a/src/Type/Generic/TemplateIntegerType.php b/src/Type/Generic/TemplateIntegerType.php new file mode 100644 index 00000000..c3b679be --- /dev/null +++ b/src/Type/Generic/TemplateIntegerType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + IntegerType $bound, + ?Type $default, + ) + { + parent::__construct(); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateIntersectionType.php b/src/Type/Generic/TemplateIntersectionType.php new file mode 100644 index 00000000..d585ae0f --- /dev/null +++ b/src/Type/Generic/TemplateIntersectionType.php @@ -0,0 +1,38 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + IntersectionType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getTypes()); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + +} diff --git a/src/Type/Generic/TemplateKeyOfType.php b/src/Type/Generic/TemplateKeyOfType.php new file mode 100644 index 00000000..85f86c14 --- /dev/null +++ b/src/Type/Generic/TemplateKeyOfType.php @@ -0,0 +1,58 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + KeyOfType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getType()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function getResult(): Type + { + $result = $this->getBound()->getResult(); + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $result, + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateMixedType.php b/src/Type/Generic/TemplateMixedType.php new file mode 100644 index 00000000..211d3c48 --- /dev/null +++ b/src/Type/Generic/TemplateMixedType.php @@ -0,0 +1,67 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + MixedType $bound, + ?Type $default, + ) + { + parent::__construct(true); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + public function isSuperTypeOfMixed(MixedType $type): IsSuperTypeOfResult + { + return $this->isSuperTypeOf($type); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + $isSuperType = $this->isSuperTypeOf($acceptingType)->toAcceptsResult(); + if ($isSuperType->no()) { + return $isSuperType; + } + return AcceptsResult::createYes(); + } + + public function toStrictMixedType(): TemplateStrictMixedType + { + return new TemplateStrictMixedType( + $this->scope, + $this->strategy, + $this->variance, + $this->name, + new StrictMixedType(), + $this->default, + ); + } + +} diff --git a/src/Type/Generic/TemplateObjectShapeType.php b/src/Type/Generic/TemplateObjectShapeType.php new file mode 100644 index 00000000..51561a27 --- /dev/null +++ b/src/Type/Generic/TemplateObjectShapeType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ObjectShapeType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getProperties(), $bound->getOptionalProperties()); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateObjectType.php b/src/Type/Generic/TemplateObjectType.php new file mode 100644 index 00000000..3014efd0 --- /dev/null +++ b/src/Type/Generic/TemplateObjectType.php @@ -0,0 +1,40 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ObjectType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getClassName()); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + +} diff --git a/src/Type/Generic/TemplateObjectWithoutClassType.php b/src/Type/Generic/TemplateObjectWithoutClassType.php new file mode 100644 index 00000000..b0b85582 --- /dev/null +++ b/src/Type/Generic/TemplateObjectWithoutClassType.php @@ -0,0 +1,40 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + ObjectWithoutClassType $bound, + ?Type $default, + ) + { + parent::__construct(); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + +} diff --git a/src/Type/Generic/TemplateStrictMixedType.php b/src/Type/Generic/TemplateStrictMixedType.php new file mode 100644 index 00000000..0e9562c7 --- /dev/null +++ b/src/Type/Generic/TemplateStrictMixedType.php @@ -0,0 +1,49 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + StrictMixedType $bound, + ?Type $default, + ) + { + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + public function isSuperTypeOfMixed(MixedType $type): IsSuperTypeOfResult + { + return $this->isSuperTypeOf($type); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + +} diff --git a/src/Type/Generic/TemplateStringType.php b/src/Type/Generic/TemplateStringType.php new file mode 100644 index 00000000..0515258d --- /dev/null +++ b/src/Type/Generic/TemplateStringType.php @@ -0,0 +1,44 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + StringType $bound, + ?Type $default, + ) + { + parent::__construct(); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + protected function shouldGeneralizeInferredType(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateType.php b/src/Type/Generic/TemplateType.php new file mode 100644 index 00000000..77d6892c --- /dev/null +++ b/src/Type/Generic/TemplateType.php @@ -0,0 +1,33 @@ +isAcceptedBy($left, $strictTypes); + } else { + $accepts = $left->getBound()->accepts($right, $strictTypes) + ->and(AcceptsResult::createMaybe()); + if ($accepts->maybe()) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($left, $right); + + return new AcceptsResult($accepts->result, array_merge($accepts->reasons, [ + sprintf( + 'Type %s is not always the same as %s. It breaks the contract for some argument types, typically subtypes.', + $right->describe($verbosity), + $left->getName(), + ), + ])); + } + } + + return $accepts; + } + + public function isArgument(): bool + { + return true; + } + +} diff --git a/src/Type/Generic/TemplateTypeFactory.php b/src/Type/Generic/TemplateTypeFactory.php new file mode 100644 index 00000000..8f735c82 --- /dev/null +++ b/src/Type/Generic/TemplateTypeFactory.php @@ -0,0 +1,119 @@ +getName(), $tag->getBound(), $tag->getVariance(), null, $tag->getDefault()); + } + +} diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php new file mode 100644 index 00000000..012fae54 --- /dev/null +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -0,0 +1,152 @@ +getReferencedTemplateTypes($positionVariance); + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($standins, $references, $callSiteVariances, $keepErrorTypes): Type { + if ($type instanceof TemplateType && !$type->isArgument()) { + $newType = $standins->getType($type->getName()); + + $variance = TemplateTypeVariance::createInvariant(); + foreach ($references as $reference) { + // this uses identity to distinguish between different occurrences of the same template type + // see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details + if ($reference->getType() === $type) { + $variance = $reference->getPositionVariance(); + break; + } + } + + if ($newType === null) { + return $traverse($type); + } + + if ($newType instanceof ErrorType && !$keepErrorTypes) { + return $traverse($type->getDefault() ?? $type->getBound()); + } + + $callSiteVariance = $callSiteVariances->getVariance($type->getName()); + if ($callSiteVariance === null || $callSiteVariance->invariant()) { + return $newType; + } + + if (!$callSiteVariance->covariant() && $variance->covariant()) { + return $traverse($type->getBound()); + } + + if (!$callSiteVariance->contravariant() && $variance->contravariant()) { + return new NonAcceptingNeverType(); + } + + return $newType; + } + + return $traverse($type); + }); + } + + public static function resolveToDefaults(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof TemplateType) { + return $traverse($type->getDefault() ?? $type->getBound()); + } + + return $traverse($type); + }); + } + + public static function resolveToBounds(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof TemplateType) { + return $traverse($type->getBound()); + } + + return $traverse($type); + }); + } + + /** + * @template T of Type + * @param T $type + * @return T + */ + public static function toArgument(Type $type): Type + { + $ownedTemplates = []; + + /** @var T */ + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$ownedTemplates): Type { + if ($type instanceof ParametersAcceptor) { + $templateTypeMap = $type->getTemplateTypeMap(); + + foreach ($type->getParameters() as $parameter) { + $parameterType = $parameter->getType(); + if (!($parameterType instanceof TemplateType) || !$templateTypeMap->hasType($parameterType->getName())) { + continue; + } + + $ownedTemplates[] = $parameterType; + } + + $returnType = $type->getReturnType(); + + if ($returnType instanceof TemplateType && $templateTypeMap->hasType($returnType->getName())) { + $ownedTemplates[] = $returnType; + } + } + + foreach ($ownedTemplates as $ownedTemplate) { + if ($ownedTemplate === $type) { + return $traverse($type); + } + } + + if ($type instanceof TemplateType) { + return $traverse($type->toArgument()); + } + + return $traverse($type); + }); + } + + public static function generalizeInferredTemplateType(TemplateType $templateType, Type $type): Type + { + if (!$templateType->getVariance()->covariant()) { + $isArrayKey = $templateType->getBound()->describe(VerbosityLevel::precise()) === '(int|string)'; + if ($type->isScalar()->yes() && $isArrayKey) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } elseif ($type->isConstantValue()->yes() && (!$templateType->getBound()->isScalar()->yes() || $isArrayKey)) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } + } + + return $type; + } + +} diff --git a/src/Type/Generic/TemplateTypeMap.php b/src/Type/Generic/TemplateTypeMap.php new file mode 100644 index 00000000..87b9b650 --- /dev/null +++ b/src/Type/Generic/TemplateTypeMap.php @@ -0,0 +1,221 @@ + $types + * @param array $lowerBoundTypes + */ + public function __construct(private array $types, private array $lowerBoundTypes = []) + { + } + + public function convertToLowerBoundTypes(): self + { + $lowerBoundTypes = $this->types; + foreach ($this->lowerBoundTypes as $name => $type) { + if (isset($lowerBoundTypes[$name])) { + $intersection = TypeCombinator::intersect($lowerBoundTypes[$name], $type); + if ($intersection instanceof NeverType) { + continue; + } + $lowerBoundTypes[$name] = $intersection; + } else { + $lowerBoundTypes[$name] = $type; + } + } + + return new self([], $lowerBoundTypes); + } + + public static function createEmpty(): self + { + $empty = self::$empty; + + if ($empty !== null) { + return $empty; + } + + $empty = new self([], []); + self::$empty = $empty; + + return $empty; + } + + public function isEmpty(): bool + { + return $this->count() === 0; + } + + public function count(): int + { + return count($this->types + $this->lowerBoundTypes); + } + + /** @return array */ + public function getTypes(): array + { + $types = $this->types; + foreach ($this->lowerBoundTypes as $name => $type) { + if (array_key_exists($name, $types)) { + continue; + } + + $types[$name] = $type; + } + + return $types; + } + + public function hasType(string $name): bool + { + return array_key_exists($name, $this->getTypes()); + } + + public function getType(string $name): ?Type + { + return $this->getTypes()[$name] ?? null; + } + + public function unsetType(string $name): self + { + if (!$this->hasType($name)) { + return $this; + } + + $types = $this->types; + $lowerBoundTypes = $this->lowerBoundTypes; + + unset($types[$name]); + unset($lowerBoundTypes[$name]); + + if (count($types) === 0 && count($lowerBoundTypes) === 0) { + return self::createEmpty(); + } + + return new self($types, $lowerBoundTypes); + } + + public function union(self $other): self + { + $result = $this->types; + + foreach ($other->types as $name => $type) { + if (isset($result[$name])) { + $result[$name] = TypeCombinator::union($result[$name], $type); + } else { + $result[$name] = $type; + } + } + + $resultLowerBoundTypes = $this->lowerBoundTypes; + foreach ($other->lowerBoundTypes as $name => $type) { + if (isset($resultLowerBoundTypes[$name])) { + $intersection = TypeCombinator::intersect($resultLowerBoundTypes[$name], $type); + if ($intersection instanceof NeverType) { + continue; + } + $resultLowerBoundTypes[$name] = $intersection; + } else { + $resultLowerBoundTypes[$name] = $type; + } + } + + return new self($result, $resultLowerBoundTypes); + } + + public function benevolentUnion(self $other): self + { + $result = $this->types; + + foreach ($other->types as $name => $type) { + if (isset($result[$name])) { + $result[$name] = TypeUtils::toBenevolentUnion(TypeCombinator::union($result[$name], $type)); + } else { + $result[$name] = $type; + } + } + + $resultLowerBoundTypes = $this->lowerBoundTypes; + foreach ($other->lowerBoundTypes as $name => $type) { + if (isset($resultLowerBoundTypes[$name])) { + $intersection = TypeCombinator::intersect($resultLowerBoundTypes[$name], $type); + if ($intersection instanceof NeverType) { + continue; + } + $resultLowerBoundTypes[$name] = $intersection; + } else { + $resultLowerBoundTypes[$name] = $type; + } + } + + return new self($result, $resultLowerBoundTypes); + } + + public function intersect(self $other): self + { + $result = $this->types; + + foreach ($other->types as $name => $type) { + if (isset($result[$name])) { + $result[$name] = TypeCombinator::intersect($result[$name], $type); + } else { + $result[$name] = $type; + } + } + + $resultLowerBoundTypes = $this->lowerBoundTypes; + foreach ($other->lowerBoundTypes as $name => $type) { + if (isset($resultLowerBoundTypes[$name])) { + $resultLowerBoundTypes[$name] = TypeCombinator::union($resultLowerBoundTypes[$name], $type); + } else { + $resultLowerBoundTypes[$name] = $type; + } + } + + return new self($result, $resultLowerBoundTypes); + } + + /** @param callable(string,Type):Type $cb */ + public function map(callable $cb): self + { + $types = []; + foreach ($this->getTypes() as $name => $type) { + $types[$name] = $cb($name, $type); + } + + return new self($types); + } + + public function resolveToBounds(): self + { + if ($this->resolvedToBounds !== null) { + return $this->resolvedToBounds; + } + return $this->resolvedToBounds = $this->map(static fn (string $name, Type $type): Type => TypeTraverser::map( + $type, + static fn (Type $type, callable $traverse): Type => $type instanceof TemplateType ? $traverse($type->getDefault() ?? $type->getBound()) : $traverse($type), + )); + } + +} diff --git a/src/Type/Generic/TemplateTypeParameterStrategy.php b/src/Type/Generic/TemplateTypeParameterStrategy.php new file mode 100644 index 00000000..a01758be --- /dev/null +++ b/src/Type/Generic/TemplateTypeParameterStrategy.php @@ -0,0 +1,30 @@ +isAcceptedBy($left, $strictTypes); + } + + return $left->getBound()->accepts($right, $strictTypes); + } + + public function isArgument(): bool + { + return false; + } + +} diff --git a/src/Type/Generic/TemplateTypeReference.php b/src/Type/Generic/TemplateTypeReference.php new file mode 100644 index 00000000..cb9c0cac --- /dev/null +++ b/src/Type/Generic/TemplateTypeReference.php @@ -0,0 +1,23 @@ +type; + } + + public function getPositionVariance(): TemplateTypeVariance + { + return $this->positionVariance; + } + +} diff --git a/src/Type/Generic/TemplateTypeScope.php b/src/Type/Generic/TemplateTypeScope.php new file mode 100644 index 00000000..24e53a6d --- /dev/null +++ b/src/Type/Generic/TemplateTypeScope.php @@ -0,0 +1,72 @@ +className; + } + + /** @api */ + public function getFunctionName(): ?string + { + return $this->functionName; + } + + /** @api */ + public function equals(self $other): bool + { + return $this->className === $other->className + && $this->functionName === $other->functionName; + } + + /** @api */ + public function describe(): string + { + if ($this->className === null && $this->functionName === null) { + return 'anonymous function'; + } + + if ($this->className === null) { + return sprintf('function %s()', $this->functionName); + } + + if ($this->functionName === null) { + return sprintf('class %s', $this->className); + } + + return sprintf('method %s::%s()', $this->className, $this->functionName); + } + +} diff --git a/src/Type/Generic/TemplateTypeStrategy.php b/src/Type/Generic/TemplateTypeStrategy.php new file mode 100644 index 00000000..2d8e136e --- /dev/null +++ b/src/Type/Generic/TemplateTypeStrategy.php @@ -0,0 +1,16 @@ +name; + } + + public function getScope(): TemplateTypeScope + { + return $this->scope; + } + + /** @return TBound */ + public function getBound(): Type + { + return $this->bound; + } + + public function getDefault(): ?Type + { + return $this->default; + } + + public function describe(VerbosityLevel $level): string + { + $basicDescription = function () use ($level): string { + // @phpstan-ignore booleanAnd.alwaysFalse, instanceof.alwaysFalse, booleanAnd.alwaysFalse, instanceof.alwaysFalse, instanceof.alwaysTrue + if ($this->bound instanceof MixedType && $this->bound->getSubtractedType() === null && !$this->bound instanceof TemplateMixedType) { + $boundDescription = ''; + } else { + $boundDescription = sprintf(' of %s', $this->bound->describe($level)); + } + $defaultDescription = $this->default !== null ? sprintf(' = %s', $this->default->describe($level)) : ''; + return sprintf( + '%s%s%s', + $this->name, + $boundDescription, + $defaultDescription, + ); + }; + + return $level->handle( + $basicDescription, + $basicDescription, + fn (): string => sprintf('%s (%s, %s)', $basicDescription(), $this->scope->describe(), $this->isArgument() ? 'argument' : 'parameter'), + ); + } + + public function isArgument(): bool + { + return $this->strategy->isArgument(); + } + + public function toArgument(): TemplateType + { + return new self( + $this->scope, + new TemplateTypeArgumentStrategy(), + $this->variance, + $this->name, + TemplateTypeHelper::toArgument($this->getBound()), + $this->default !== null ? TemplateTypeHelper::toArgument($this->default) : null, + ); + } + + public function isValidVariance(Type $a, Type $b): IsSuperTypeOfResult + { + return $this->variance->isValidVariance($this, $a, $b); + } + + public function subtract(Type $typeToRemove): Type + { + $removedBound = TypeCombinator::remove($this->getBound(), $typeToRemove); + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $removedBound, + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + public function getTypeWithoutSubtractedType(): Type + { + $bound = $this->getBound(); + if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + return $this; + } + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $bound->getTypeWithoutSubtractedType(), + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + $bound = $this->getBound(); + if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + return $this; + } + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $bound->changeSubtractedType($subtractedType), + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + public function getSubtractedType(): ?Type + { + $bound = $this->getBound(); + if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + return null; + } + + return $bound->getSubtractedType(); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $type->scope->equals($this->scope) + && $type->name === $this->name + && $this->bound->equals($type->bound) + && ( + ($this->default === null && $type->default === null) + || ($this->default !== null && $type->default !== null && $this->default->equals($type->default)) + ); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + /** @var TBound $bound */ + $bound = $this->getBound(); + if ( + !$acceptingType instanceof $bound + && !$this instanceof $acceptingType + && !$acceptingType instanceof TemplateType + && ($acceptingType instanceof UnionType || $acceptingType instanceof IntersectionType) + ) { + return $acceptingType->accepts($this, $strictTypes); + } + + if (!$acceptingType instanceof TemplateType) { + return $acceptingType->accepts($this->getBound(), $strictTypes); + } + + if ($this->getScope()->equals($acceptingType->getScope()) && $this->getName() === $acceptingType->getName()) { + return $acceptingType->getBound()->accepts($this->getBound(), $strictTypes); + } + + return $acceptingType->getBound()->accepts($this->getBound(), $strictTypes) + ->and(new AcceptsResult(TrinaryLogic::createMaybe(), [])); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return $this->strategy->accepts($this, $type, $strictTypes); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof TemplateType || $type instanceof IntersectionType) { + return $type->isSubTypeOf($this); + } + + if ($type instanceof NeverType) { + return IsSuperTypeOfResult::createYes(); + } + + return $this->getBound()->isSuperTypeOf($type) + ->and(IsSuperTypeOfResult::createMaybe()); + } + + public function isSubTypeOf(Type $type): IsSuperTypeOfResult + { + /** @var TBound $bound */ + $bound = $this->getBound(); + if ( + !$type instanceof $bound + && !$this instanceof $type + && !$type instanceof TemplateType + && ($type instanceof UnionType || $type instanceof IntersectionType) + ) { + return $type->isSuperTypeOf($this); + } + + if (!$type instanceof TemplateType) { + return $type->isSuperTypeOf($this->getBound()); + } + + if ($this->getScope()->equals($type->getScope()) && $this->getName() === $type->getName()) { + return $type->getBound()->isSuperTypeOf($this->getBound()); + } + + return $type->getBound()->isSuperTypeOf($this->getBound()) + ->and(IsSuperTypeOfResult::createMaybe()); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ( + $receivedType instanceof TemplateType + && $this->getBound()->isSuperTypeOf($receivedType->getBound())->yes() + ) { + return new TemplateTypeMap([ + $this->name => $receivedType, + ]); + } + + $map = $this->getBound()->inferTemplateTypes($receivedType); + $resolvedBound = TypeUtils::resolveLateResolvableTypes(TemplateTypeHelper::resolveTemplateTypes( + $this->getBound(), + $map, + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createStatic(), + )); + if ($resolvedBound->isSuperTypeOf($receivedType)->yes()) { + return (new TemplateTypeMap([ + $this->name => $receivedType, + ]))->union($map); + } + + return $map; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return [new TemplateTypeReference($this, $positionVariance)]; + } + + public function getVariance(): TemplateTypeVariance + { + return $this->variance; + } + + public function getStrategy(): TemplateTypeStrategy + { + return $this->strategy; + } + + protected function shouldGeneralizeInferredType(): bool + { + return true; + } + + public function traverse(callable $cb): Type + { + $bound = $cb($this->getBound()); + $default = $this->getDefault() !== null ? $cb($this->getDefault()) : null; + + if ($this->getBound() === $bound && $this->getDefault() === $default) { + return $this; + } + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $bound, + $this->getVariance(), + $this->getStrategy(), + $default, + ); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof TemplateType) { + return $this; + } + + $bound = $cb($this->getBound(), $right->getBound()); + $default = $this->getDefault() !== null && $right->getDefault() !== null ? $cb($this->getDefault(), $right->getDefault()) : null; + + if ($this->getBound() === $bound && $this->getDefault() === $default) { + return $this; + } + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $bound, + $this->getVariance(), + $this->getStrategy(), + $default, + ); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + $bound = TypeCombinator::remove($this->getBound(), $typeToRemove); + if ($this->getBound() === $bound) { + return null; + } + + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $bound, + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode($this->name); + } + +} diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php new file mode 100644 index 00000000..d379bc29 --- /dev/null +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -0,0 +1,240 @@ +value === self::INVARIANT; + } + + public function covariant(): bool + { + return $this->value === self::COVARIANT; + } + + public function contravariant(): bool + { + return $this->value === self::CONTRAVARIANT; + } + + public function static(): bool + { + return $this->value === self::STATIC; + } + + public function bivariant(): bool + { + return $this->value === self::BIVARIANT; + } + + public function compose(self $other): self + { + if ($this->contravariant()) { + if ($other->contravariant()) { + return self::createCovariant(); + } + if ($other->covariant()) { + return self::createContravariant(); + } + if ($other->bivariant()) { + return self::createBivariant(); + } + return self::createInvariant(); + } + + if ($this->covariant()) { + if ($other->contravariant()) { + return self::createContravariant(); + } + if ($other->covariant()) { + return self::createCovariant(); + } + if ($other->bivariant()) { + return self::createBivariant(); + } + return self::createInvariant(); + } + + if ($this->invariant()) { + return self::createInvariant(); + } + + if ($this->bivariant()) { + return self::createBivariant(); + } + + return $other; + } + + public function isValidVariance(TemplateType $templateType, Type $a, Type $b): IsSuperTypeOfResult + { + if ($b instanceof NeverType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($a instanceof MixedType && !$a instanceof TemplateType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($a instanceof BenevolentUnionType) { + if (!$a->isSuperTypeOf($b)->no()) { + return IsSuperTypeOfResult::createYes(); + } + } + + if ($b instanceof BenevolentUnionType) { + if (!$b->isSuperTypeOf($a)->no()) { + return IsSuperTypeOfResult::createYes(); + } + } + + if ($b instanceof MixedType && !$b instanceof TemplateType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($this->invariant()) { + $result = $a->equals($b); + $reasons = []; + if (!$result) { + if ( + $templateType->getScope()->getClassName() !== null + && $a->isSuperTypeOf($b)->yes() + ) { + $reasons[] = sprintf( + 'Template type %s on class %s is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + $templateType->getName(), + $templateType->getScope()->getClassName(), + ); + } + } + + return new IsSuperTypeOfResult(TrinaryLogic::createFromBoolean($result), $reasons); + } + + if ($this->covariant()) { + return $a->isSuperTypeOf($b); + } + + if ($this->contravariant()) { + return $b->isSuperTypeOf($a); + } + + if ($this->bivariant()) { + return IsSuperTypeOfResult::createYes(); + } + + throw new ShouldNotHappenException(); + } + + public function equals(self $other): bool + { + return $other->value === $this->value; + } + + public function validPosition(self $other): bool + { + return $other->value === $this->value + || $other->invariant() + || $this->bivariant() + || $this->static(); + } + + public function describe(): string + { + switch ($this->value) { + case self::INVARIANT: + return 'invariant'; + case self::COVARIANT: + return 'covariant'; + case self::CONTRAVARIANT: + return 'contravariant'; + case self::STATIC: + return 'static'; + case self::BIVARIANT: + return 'bivariant'; + } + + throw new ShouldNotHappenException(); + } + + /** + * @return GenericTypeNode::VARIANCE_* + */ + public function toPhpDocNodeVariance(): string + { + switch ($this->value) { + case self::INVARIANT: + return GenericTypeNode::VARIANCE_INVARIANT; + case self::COVARIANT: + return GenericTypeNode::VARIANCE_COVARIANT; + case self::CONTRAVARIANT: + return GenericTypeNode::VARIANCE_CONTRAVARIANT; + case self::BIVARIANT: + return GenericTypeNode::VARIANCE_BIVARIANT; + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Type/Generic/TemplateTypeVarianceMap.php b/src/Type/Generic/TemplateTypeVarianceMap.php new file mode 100644 index 00000000..c2f029f2 --- /dev/null +++ b/src/Type/Generic/TemplateTypeVarianceMap.php @@ -0,0 +1,54 @@ + $variances + */ + public function __construct(private array $variances) + { + } + + public static function createEmpty(): self + { + $empty = self::$empty; + + if ($empty !== null) { + return $empty; + } + + $empty = new self([]); + self::$empty = $empty; + + return $empty; + } + + /** @return array */ + public function getVariances(): array + { + return $this->variances; + } + + public function hasVariance(string $name): bool + { + return array_key_exists($name, $this->getVariances()); + } + + public function getVariance(string $name): ?TemplateTypeVariance + { + return $this->getVariances()[$name] ?? null; + } + +} diff --git a/src/Type/Generic/TemplateUnionType.php b/src/Type/Generic/TemplateUnionType.php new file mode 100644 index 00000000..f4fc9248 --- /dev/null +++ b/src/Type/Generic/TemplateUnionType.php @@ -0,0 +1,55 @@ + */ + use TemplateTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + UnionType $bound, + ?Type $default, + ) + { + parent::__construct($bound->getTypes()); + + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + + public function filterTypes(callable $filterCb): Type + { + $result = parent::filterTypes($filterCb); + if (!$result instanceof TemplateType) { + return TemplateTypeFactory::create( + $this->getScope(), + $this->getName(), + $result, + $this->getVariance(), + $this->getStrategy(), + $this->getDefault(), + ); + } + + return $result; + } + +} diff --git a/src/Type/Generic/TypeProjectionHelper.php b/src/Type/Generic/TypeProjectionHelper.php new file mode 100644 index 00000000..e9d3bd1c --- /dev/null +++ b/src/Type/Generic/TypeProjectionHelper.php @@ -0,0 +1,32 @@ +describe($level); + + if ($variance === null || $variance->invariant()) { + return $describedType; + } + + if ($variance->bivariant()) { + return '*'; + } + + return sprintf('%s %s', $variance->describe(), $describedType); + } + +} diff --git a/src/Type/Helper/GetTemplateTypeType.php b/src/Type/Helper/GetTemplateTypeType.php new file mode 100644 index 00000000..e2d86466 --- /dev/null +++ b/src/Type/Helper/GetTemplateTypeType.php @@ -0,0 +1,107 @@ +type->getReferencedClasses(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->type->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('template-type<%s, %s, %s>', $this->type->describe($level), $this->ancestorClassName, $this->templateTypeName); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type); + } + + protected function getResult(): Type + { + return $this->type->getTemplateType($this->ancestorClassName, $this->templateTypeName); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type, $this->ancestorClassName, $this->templateTypeName); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type, $this->ancestorClassName, $this->templateTypeName); + } + + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode( + new IdentifierTypeNode('template-type'), + [ + $this->type->toPhpDocNode(), + new IdentifierTypeNode($this->ancestorClassName), + new ConstTypeNode(new ConstExprStringNode($this->templateTypeName, ConstExprStringNode::SINGLE_QUOTED)), + ], + ); + } + +} diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php new file mode 100644 index 00000000..fc98b97f --- /dev/null +++ b/src/Type/IntegerRangeType.php @@ -0,0 +1,758 @@ + $max) { + return new NeverType(); + } + if ($min === $max) { + return new ConstantIntegerType($min + $shift); + } + } + + if ($min === null && $max === null) { + return new IntegerType(); + } + + return (new self($min, $max))->shift($shift); + } + + protected static function isDisjoint(?int $minA, ?int $maxA, ?int $minB, ?int $maxB, bool $touchingIsDisjoint = true): bool + { + $offset = $touchingIsDisjoint ? 0 : 1; + return $minA !== null && $maxB !== null && $minA > $maxB + $offset + || $maxA !== null && $minB !== null && $maxA + $offset < $minB; + } + + /** + * Return the range of integers smaller than the given value + * + * @param int|float $value + */ + public static function createAllSmallerThan($value): Type + { + if (is_int($value)) { + return self::fromInterval(null, $value, -1); + } + + if ($value > PHP_INT_MAX) { + return new IntegerType(); + } + + if ($value <= PHP_INT_MIN) { + return new NeverType(); + } + + return self::fromInterval(null, (int) ceil($value), -1); + } + + /** + * Return the range of integers smaller than or equal to the given value + * + * @param int|float $value + */ + public static function createAllSmallerThanOrEqualTo($value): Type + { + if (is_int($value)) { + return self::fromInterval(null, $value); + } + + if ($value >= PHP_INT_MAX) { + return new IntegerType(); + } + + if ($value < PHP_INT_MIN) { + return new NeverType(); + } + + return self::fromInterval(null, (int) floor($value)); + } + + /** + * Return the range of integers greater than the given value + * + * @param int|float $value + */ + public static function createAllGreaterThan($value): Type + { + if (is_int($value)) { + return self::fromInterval($value, null, 1); + } + + if ($value < PHP_INT_MIN) { + return new IntegerType(); + } + + if ($value >= PHP_INT_MAX) { + return new NeverType(); + } + + return self::fromInterval((int) floor($value), null, 1); + } + + /** + * Return the range of integers greater than or equal to the given value + * + * @param int|float $value + */ + public static function createAllGreaterThanOrEqualTo($value): Type + { + if (is_int($value)) { + return self::fromInterval($value, null); + } + + if ($value <= PHP_INT_MIN) { + return new IntegerType(); + } + + if ($value > PHP_INT_MAX) { + return new NeverType(); + } + + return self::fromInterval((int) ceil($value), null); + } + + public function getMin(): ?int + { + return $this->min; + } + + public function getMax(): ?int + { + return $this->max; + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('int<%s, %s>', $this->min ?? 'min', $this->max ?? 'max'); + } + + public function shift(int $amount): Type + { + if ($amount === 0) { + return $this; + } + + $min = $this->min; + $max = $this->max; + + if ($amount < 0) { + if ($max !== null) { + if ($max < PHP_INT_MIN - $amount) { + return new NeverType(); + } + $max += $amount; + } + if ($min !== null) { + $min = $min < PHP_INT_MIN - $amount ? null : $min + $amount; + } + } else { + if ($min !== null) { + if ($min > PHP_INT_MAX - $amount) { + return new NeverType(); + } + $min += $amount; + } + if ($max !== null) { + $max = $max > PHP_INT_MAX - $amount ? null : $max + $amount; + } + } + + return self::fromInterval($min, $max); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof parent) { + return $this->isSuperTypeOf($type)->toAcceptsResult(); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self || $type instanceof ConstantIntegerType) { + if ($type instanceof self) { + $typeMin = $type->min; + $typeMax = $type->max; + } else { + $typeMin = $type->getValue(); + $typeMax = $type->getValue(); + } + + if (self::isDisjoint($this->min, $this->max, $typeMin, $typeMax)) { + return IsSuperTypeOfResult::createNo(); + } + + if ( + ($this->min === null || $typeMin !== null && $this->min <= $typeMin) + && ($this->max === null || $typeMax !== null && $this->max >= $typeMax) + ) { + return IsSuperTypeOfResult::createYes(); + } + + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof parent) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof parent) { + return $otherType->isSuperTypeOf($this); + } + + if ($otherType instanceof UnionType) { + return $this->isSubTypeOfUnionWithReason($otherType); + } + + if ($otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + private function isSubTypeOfUnionWithReason(UnionType $otherType): IsSuperTypeOfResult + { + if ($this->min !== null && $this->max !== null) { + $matchingConstantIntegers = array_filter( + $otherType->getTypes(), + fn (Type $type): bool => $type instanceof ConstantIntegerType && $type->getValue() >= $this->min && $type->getValue() <= $this->max, + ); + + if (count($matchingConstantIntegers) === ($this->max - $this->min + 1)) { + return IsSuperTypeOfResult::createYes(); + } + } + + return IsSuperTypeOfResult::createNo()->or(...array_map(fn (Type $innerType) => $this->isSubTypeOf($innerType), $otherType->getTypes())); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self && $this->min === $type->min && $this->max === $type->max; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new IntegerType(); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($this->min === null) { + $minIsSmaller = TrinaryLogic::createYes(); + } else { + $minIsSmaller = (new ConstantIntegerType($this->min))->isSmallerThan($otherType, $phpVersion); + } + + if ($this->max === null) { + $maxIsSmaller = TrinaryLogic::createNo(); + } else { + $maxIsSmaller = (new ConstantIntegerType($this->max))->isSmallerThan($otherType, $phpVersion); + } + + // 0 can have different results in contrast to the interval edges, see https://3v4l.org/iGoti + $zeroInt = new ConstantIntegerType(0); + if (!$zeroInt->isSuperTypeOf($this)->no()) { + return TrinaryLogic::extremeIdentity( + $zeroInt->isSmallerThan($otherType, $phpVersion), + $minIsSmaller, + $maxIsSmaller, + ); + } + + return TrinaryLogic::extremeIdentity($minIsSmaller, $maxIsSmaller); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($this->min === null) { + $minIsSmaller = TrinaryLogic::createYes(); + } else { + $minIsSmaller = (new ConstantIntegerType($this->min))->isSmallerThanOrEqual($otherType, $phpVersion); + } + + if ($this->max === null) { + $maxIsSmaller = TrinaryLogic::createNo(); + } else { + $maxIsSmaller = (new ConstantIntegerType($this->max))->isSmallerThanOrEqual($otherType, $phpVersion); + } + + // 0 can have different results in contrast to the interval edges, see https://3v4l.org/iGoti + $zeroInt = new ConstantIntegerType(0); + if (!$zeroInt->isSuperTypeOf($this)->no()) { + return TrinaryLogic::extremeIdentity( + $zeroInt->isSmallerThanOrEqual($otherType, $phpVersion), + $minIsSmaller, + $maxIsSmaller, + ); + } + + return TrinaryLogic::extremeIdentity($minIsSmaller, $maxIsSmaller); + } + + public function isGreaterThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($this->min === null) { + $minIsSmaller = TrinaryLogic::createNo(); + } else { + $minIsSmaller = $otherType->isSmallerThan((new ConstantIntegerType($this->min)), $phpVersion); + } + + if ($this->max === null) { + $maxIsSmaller = TrinaryLogic::createYes(); + } else { + $maxIsSmaller = $otherType->isSmallerThan((new ConstantIntegerType($this->max)), $phpVersion); + } + + // 0 can have different results in contrast to the interval edges, see https://3v4l.org/iGoti + $zeroInt = new ConstantIntegerType(0); + if (!$zeroInt->isSuperTypeOf($this)->no()) { + return TrinaryLogic::extremeIdentity( + $otherType->isSmallerThan($zeroInt, $phpVersion), + $minIsSmaller, + $maxIsSmaller, + ); + } + + return TrinaryLogic::extremeIdentity($minIsSmaller, $maxIsSmaller); + } + + public function isGreaterThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($this->min === null) { + $minIsSmaller = TrinaryLogic::createNo(); + } else { + $minIsSmaller = $otherType->isSmallerThanOrEqual((new ConstantIntegerType($this->min)), $phpVersion); + } + + if ($this->max === null) { + $maxIsSmaller = TrinaryLogic::createYes(); + } else { + $maxIsSmaller = $otherType->isSmallerThanOrEqual((new ConstantIntegerType($this->max)), $phpVersion); + } + + // 0 can have different results in contrast to the interval edges, see https://3v4l.org/iGoti + $zeroInt = new ConstantIntegerType(0); + if (!$zeroInt->isSuperTypeOf($this)->no()) { + return TrinaryLogic::extremeIdentity( + $otherType->isSmallerThanOrEqual($zeroInt, $phpVersion), + $minIsSmaller, + $maxIsSmaller, + ); + } + + return TrinaryLogic::extremeIdentity($minIsSmaller, $maxIsSmaller); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + new ConstantBooleanType(true), + ]; + + if ($this->max !== null) { + $subtractedTypes[] = self::createAllGreaterThanOrEqualTo($this->max); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = []; + + if ($this->max !== null) { + $subtractedTypes[] = self::createAllGreaterThan($this->max); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + new NullType(), + new ConstantBooleanType(false), + ]; + + if ($this->min !== null) { + $subtractedTypes[] = self::createAllSmallerThanOrEqualTo($this->min); + } + + if ($this->min !== null && $this->min > 0 || $this->max !== null && $this->max < 0) { + $subtractedTypes[] = new ConstantBooleanType(true); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = []; + + if ($this->min !== null) { + $subtractedTypes[] = self::createAllSmallerThan($this->min); + } + + if ($this->min !== null && $this->min > 0 || $this->max !== null && $this->max < 0) { + $subtractedTypes[] = new NullType(); + $subtractedTypes[] = new ConstantBooleanType(false); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function toBoolean(): BooleanType + { + $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($this); + if ($isZero->no()) { + return new ConstantBooleanType(true); + } + + if ($isZero->maybe()) { + return new BooleanType(); + } + + return new ConstantBooleanType(false); + } + + public function toAbsoluteNumber(): Type + { + if ($this->min !== null && $this->min >= 0) { + return $this; + } + + if ($this->max === null || $this->max >= 0) { + $inversedMin = $this->min !== null ? $this->min * -1 : null; + + return self::fromInterval(0, $inversedMin !== null && $this->max !== null ? max($inversedMin, $this->max) : null); + } + + return self::fromInterval($this->max * -1, $this->min !== null ? $this->min * -1 : null); + } + + public function toString(): Type + { + $finiteTypes = $this->getFiniteTypes(); + if ($finiteTypes !== []) { + return TypeCombinator::union(...$finiteTypes)->toString(); + } + + $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($this); + if ($isZero->no()) { + return new IntersectionType([ + new StringType(), + new AccessoryLowercaseStringType(), + new AccessoryUppercaseStringType(), + new AccessoryNumericStringType(), + new AccessoryNonFalsyStringType(), + ]); + } + + return new IntersectionType([ + new StringType(), + new AccessoryLowercaseStringType(), + new AccessoryUppercaseStringType(), + new AccessoryNumericStringType(), + ]); + } + + /** + * Return the union with another type, but only if it can be expressed in a simpler way than using UnionType + * + */ + public function tryUnion(Type $otherType): ?Type + { + if ($otherType instanceof self || $otherType instanceof ConstantIntegerType) { + if ($otherType instanceof self) { + $otherMin = $otherType->min; + $otherMax = $otherType->max; + } else { + $otherMin = $otherType->getValue(); + $otherMax = $otherType->getValue(); + } + + if (self::isDisjoint($this->min, $this->max, $otherMin, $otherMax, false)) { + return null; + } + + return self::fromInterval( + $this->min !== null && $otherMin !== null ? min($this->min, $otherMin) : null, + $this->max !== null && $otherMax !== null ? max($this->max, $otherMax) : null, + ); + } + + if (get_class($otherType) === parent::class) { + return $otherType; + } + + return null; + } + + /** + * Return the intersection with another type, but only if it can be expressed in a simpler way than using + * IntersectionType + * + */ + public function tryIntersect(Type $otherType): ?Type + { + if ($otherType instanceof self || $otherType instanceof ConstantIntegerType) { + if ($otherType instanceof self) { + $otherMin = $otherType->min; + $otherMax = $otherType->max; + } else { + $otherMin = $otherType->getValue(); + $otherMax = $otherType->getValue(); + } + + if (self::isDisjoint($this->min, $this->max, $otherMin, $otherMax, false)) { + return new NeverType(); + } + + if ($this->min === null) { + $newMin = $otherMin; + } elseif ($otherMin === null) { + $newMin = $this->min; + } else { + $newMin = max($this->min, $otherMin); + } + + if ($this->max === null) { + $newMax = $otherMax; + } elseif ($otherMax === null) { + $newMax = $this->max; + } else { + $newMax = min($this->max, $otherMax); + } + + return self::fromInterval($newMin, $newMax); + } + + if (get_class($otherType) === parent::class) { + return $this; + } + + return null; + } + + /** + * Return the different with another type, or null if it cannot be represented. + * + */ + public function tryRemove(Type $typeToRemove): ?Type + { + if (get_class($typeToRemove) === parent::class) { + return new NeverType(); + } + + if ($typeToRemove instanceof self || $typeToRemove instanceof ConstantIntegerType) { + if ($typeToRemove instanceof self) { + $removeMin = $typeToRemove->min; + $removeMax = $typeToRemove->max; + } else { + $removeMin = $typeToRemove->getValue(); + $removeMax = $typeToRemove->getValue(); + } + + if ( + $this->min !== null && $removeMax !== null && $removeMax < $this->min + || $this->max !== null && $removeMin !== null && $this->max < $removeMin + ) { + return $this; + } + + if ($removeMin !== null && $removeMin !== PHP_INT_MIN) { + $lowerPart = self::fromInterval($this->min, $removeMin - 1); + } else { + $lowerPart = null; + } + if ($removeMax !== null && $removeMax !== PHP_INT_MAX) { + $upperPart = self::fromInterval($removeMax + 1, $this->max); + } else { + $upperPart = null; + } + + if ($lowerPart !== null && $upperPart !== null) { + return TypeCombinator::union($lowerPart, $upperPart); + } + + return $lowerPart ?? $upperPart; + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + if ($exponent instanceof UnionType) { + $results = []; + foreach ($exponent->getTypes() as $unionType) { + $results[] = $this->exponentiate($unionType); + } + return TypeCombinator::union(...$results); + } + + if ($exponent instanceof IntegerRangeType) { + $min = null; + $max = null; + if ($this->getMin() !== null && $exponent->getMin() !== null) { + $min = $this->getMin() ** $exponent->getMin(); + } + if ($this->getMax() !== null && $exponent->getMax() !== null) { + $max = $this->getMax() ** $exponent->getMax(); + } + + if (($min !== null || $max !== null) && !is_float($min) && !is_float($max)) { + return self::fromInterval($min, $max); + } + } + + if ($exponent instanceof ConstantScalarType) { + $exponentValue = $exponent->getValue(); + if (is_int($exponentValue)) { + $min = null; + $max = null; + if ($this->getMin() !== null) { + $min = $this->getMin() ** $exponentValue; + } + if ($this->getMax() !== null) { + $max = $this->getMax() ** $exponentValue; + } + + if (!is_float($min) && !is_float($max)) { + return self::fromInterval($min, $max); + } + } + } + + return parent::exponentiate($exponent); + } + + /** + * @return list + */ + public function getFiniteTypes(): array + { + if ($this->min === null || $this->max === null) { + return []; + } + + $size = $this->max - $this->min; + if ($size > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return []; + } + + $types = []; + for ($i = $this->min; $i <= $this->max; $i++) { + $types[] = new ConstantIntegerType($i); + } + + return $types; + } + + public function toPhpDocNode(): TypeNode + { + if ($this->min === null) { + $min = new IdentifierTypeNode('min'); + } else { + $min = new ConstTypeNode(new ConstExprIntegerNode((string) $this->min)); + } + + if ($this->max === null) { + $max = new IdentifierTypeNode('max'); + } else { + $max = new ConstTypeNode(new ConstExprIntegerNode((string) $this->max)); + } + + return new GenericTypeNode(new IdentifierTypeNode('int'), [$min, $max]); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + $zeroInt = new ConstantIntegerType(0); + if ($zeroInt->isSuperTypeOf($this)->no()) { + if ($type->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + if ($type->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + } + + if ( + $this->isSmallerThan($type, $phpVersion)->yes() + || $this->isGreaterThan($type, $phpVersion)->yes() + ) { + return new ConstantBooleanType(false); + } + + return parent::looseCompare($type, $phpVersion); + } + +} diff --git a/src/Type/IntegerType.php b/src/Type/IntegerType.php new file mode 100644 index 00000000..89c74b22 --- /dev/null +++ b/src/Type/IntegerType.php @@ -0,0 +1,200 @@ +toFloat()); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isArray()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $phpVersion->nonNumericStringAndIntegerIsFalseOnLooseComparison() + && $type->isString()->yes() + && $type->isNumericString()->no() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof IntegerRangeType || $typeToRemove instanceof ConstantIntegerType) { + if ($typeToRemove instanceof IntegerRangeType) { + $removeValueMin = $typeToRemove->getMin(); + $removeValueMax = $typeToRemove->getMax(); + } else { + $removeValueMin = $typeToRemove->getValue(); + $removeValueMax = $typeToRemove->getValue(); + } + $lowerPart = $removeValueMin !== null ? IntegerRangeType::fromInterval(null, $removeValueMin, -1) : null; + $upperPart = $removeValueMax !== null ? IntegerRangeType::fromInterval($removeValueMax, null, +1) : null; + if ($lowerPart !== null && $upperPart !== null) { + return new UnionType([$lowerPart, $upperPart]); + } + return $lowerPart ?? $upperPart ?? new NeverType(); + } + + return null; + } + + public function getFiniteTypes(): array + { + return []; + } + + public function exponentiate(Type $exponent): Type + { + return ExponentiateHelper::exponentiate($this, $exponent); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('int'); + } + +} diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php new file mode 100644 index 00000000..a4ffa468 --- /dev/null +++ b/src/Type/IntersectionType.php @@ -0,0 +1,1363 @@ + $type->describe(VerbosityLevel::value()), $types)), + )); + } + } + + /** + * @return Type[] + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @return Type[] + */ + private function getSortedTypes(): array + { + if ($this->sortedTypes) { + return $this->types; + } + + $this->types = UnionTypeHelper::sortTypes($this->types); + $this->sortedTypes = true; + + return $this->types; + } + + public function inferTemplateTypesOn(Type $templateType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + + foreach ($this->types as $type) { + $types = $types->intersect($templateType->inferTemplateTypes($type)); + } + + return $types; + } + + public function getReferencedClasses(): array + { + $classes = []; + foreach ($this->types as $type) { + foreach ($type->getReferencedClasses() as $className) { + $classes[] = $className; + } + } + + return $classes; + } + + public function getObjectClassNames(): array + { + $objectClassNames = []; + foreach ($this->types as $type) { + $innerObjectClassNames = $type->getObjectClassNames(); + foreach ($innerObjectClassNames as $innerObjectClassName) { + $objectClassNames[] = $innerObjectClassName; + } + } + + return array_values(array_unique($objectClassNames)); + } + + public function getObjectClassReflections(): array + { + $reflections = []; + foreach ($this->types as $type) { + foreach ($type->getObjectClassReflections() as $reflection) { + $reflections[] = $reflection; + } + } + + return $reflections; + } + + public function getArrays(): array + { + $arrays = []; + foreach ($this->types as $type) { + foreach ($type->getArrays() as $array) { + $arrays[] = $array; + } + } + + return $arrays; + } + + public function getConstantArrays(): array + { + $constantArrays = []; + foreach ($this->types as $type) { + foreach ($type->getConstantArrays() as $constantArray) { + $constantArrays[] = $constantArray; + } + } + + return $constantArrays; + } + + public function getConstantStrings(): array + { + $strings = []; + foreach ($this->types as $type) { + foreach ($type->getConstantStrings() as $string) { + $strings[] = $string; + } + } + + return $strings; + } + + public function accepts(Type $otherType, bool $strictTypes): AcceptsResult + { + $result = AcceptsResult::createYes(); + foreach ($this->types as $type) { + $result = $result->and($type->accepts($otherType, $strictTypes)); + } + + if (!$result->yes()) { + $isList = $otherType->isList(); + $reasons = $result->reasons; + $verbosity = VerbosityLevel::getRecommendedLevelByType($this, $otherType); + if ($this->isList()->yes() && !$isList->yes()) { + $reasons[] = sprintf( + '%s %s a list.', + $otherType->describe($verbosity), + $isList->no() ? 'is not' : 'might not be', + ); + } + + $isNonEmpty = $otherType->isIterableAtLeastOnce(); + if ($this->isIterableAtLeastOnce()->yes() && !$isNonEmpty->yes()) { + $reasons[] = sprintf( + '%s %s empty.', + $otherType->describe($verbosity), + $isNonEmpty->no() ? 'is' : 'might be', + ); + } + + if (count($reasons) > 0) { + return new AcceptsResult($result->result, $reasons); + } + } + + return $result; + } + + public function isSuperTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof IntersectionType && $this->equals($otherType)) { + return IsSuperTypeOfResult::createYes(); + } + + if ($otherType instanceof NeverType) { + return IsSuperTypeOfResult::createYes(); + } + + return IsSuperTypeOfResult::createYes()->and(...array_map(static fn (Type $innerType) => $innerType->isSuperTypeOf($otherType), $this->types)); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if (($otherType instanceof self || $otherType instanceof UnionType) && !$otherType instanceof TemplateType) { + return $otherType->isSuperTypeOf($this); + } + + $result = IsSuperTypeOfResult::maxMin(...array_map(static fn (Type $innerType) => $otherType->isSuperTypeOf($innerType), $this->types)); + if ($this->isOversizedArray()->yes()) { + if (!$result->no()) { + return IsSuperTypeOfResult::createYes(); + } + } + + return $result; + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + $result = AcceptsResult::maxMin(...array_map(static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), $this->types)); + if ($this->isOversizedArray()->yes()) { + if (!$result->no()) { + return AcceptsResult::createYes(); + } + } + + return $result; + } + + public function equals(Type $type): bool + { + if (!$type instanceof static) { + return false; + } + + if (count($this->types) !== count($type->types)) { + return false; + } + + $otherTypes = $type->types; + foreach ($this->types as $innerType) { + $match = false; + foreach ($otherTypes as $i => $otherType) { + if (!$innerType->equals($otherType)) { + continue; + } + + $match = true; + unset($otherTypes[$i]); + break; + } + + if (!$match) { + return false; + } + } + + return count($otherTypes) === 0; + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + function () use ($level): string { + $typeNames = []; + $isList = $this->isList()->yes(); + $valueType = null; + foreach ($this->getSortedTypes() as $type) { + if ($isList) { + if ($type instanceof ArrayType || $type instanceof ConstantArrayType) { + $valueType = $type->getIterableValueType(); + continue; + } + if ($type instanceof NonEmptyArrayType) { + continue; + } + } + if ($type instanceof AccessoryType) { + continue; + } + $typeNames[] = $type->generalize(GeneralizePrecision::lessSpecific())->describe($level); + } + + if ($isList) { + $isMixedValueType = $valueType instanceof MixedType && $valueType->describe(VerbosityLevel::precise()) === 'mixed' && !$valueType->isExplicitMixed(); + $innerType = ''; + if ($valueType !== null && !$isMixedValueType) { + $innerType = sprintf('<%s>', $valueType->describe($level)); + } + + $typeNames[] = 'list' . $innerType; + } + + usort($typeNames, static function ($a, $b) { + $cmp = strcasecmp($a, $b); + if ($cmp !== 0) { + return $cmp; + } + + return $a <=> $b; + }); + + return implode('&', $typeNames); + }, + fn (): string => $this->describeItself($level, true), + fn (): string => $this->describeItself($level, false), + ); + } + + private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes): string + { + $baseTypes = []; + $typesToDescribe = []; + $skipTypeNames = []; + + $nonEmptyStr = false; + $nonFalsyStr = false; + $isList = $this->isList()->yes(); + $isArray = $this->isArray()->yes(); + $isNonEmptyArray = $this->isIterableAtLeastOnce()->yes(); + $describedTypes = []; + foreach ($this->getSortedTypes() as $i => $type) { + if ($type instanceof AccessoryNonEmptyStringType + || $type instanceof AccessoryLiteralStringType + || $type instanceof AccessoryNumericStringType + || $type instanceof AccessoryNonFalsyStringType + || $type instanceof AccessoryLowercaseStringType + || $type instanceof AccessoryUppercaseStringType + ) { + if ( + ($type instanceof AccessoryLowercaseStringType || $type instanceof AccessoryUppercaseStringType) + && !$level->isPrecise() + ) { + continue; + } + if ($type instanceof AccessoryNonFalsyStringType) { + $nonFalsyStr = true; + } + if ($type instanceof AccessoryNonEmptyStringType) { + $nonEmptyStr = true; + } + if ($nonEmptyStr && $nonFalsyStr) { + // prevent redundant 'non-empty-string&non-falsy-string' + foreach ($typesToDescribe as $key => $typeToDescribe) { + if (!($typeToDescribe instanceof AccessoryNonEmptyStringType)) { + continue; + } + + unset($typesToDescribe[$key]); + } + } + + $typesToDescribe[$i] = $type; + $skipTypeNames[] = 'string'; + continue; + } + if ($isList || $isArray) { + if ($type instanceof ArrayType) { + $keyType = $type->getKeyType(); + $valueType = $type->getItemType(); + if ($isList) { + $isMixedValueType = $valueType instanceof MixedType && $valueType->describe(VerbosityLevel::precise()) === 'mixed' && !$valueType->isExplicitMixed(); + $valueTypeDescription = ''; + if (!$isMixedValueType) { + $valueTypeDescription = sprintf('<%s>', $valueType->describe($level)); + } + + $describedTypes[$i] = ($isNonEmptyArray ? 'non-empty-list' : 'list') . $valueTypeDescription; + } else { + $isMixedKeyType = $keyType instanceof MixedType && $keyType->describe(VerbosityLevel::precise()) === 'mixed' && !$keyType->isExplicitMixed(); + $isMixedValueType = $valueType instanceof MixedType && $valueType->describe(VerbosityLevel::precise()) === 'mixed' && !$valueType->isExplicitMixed(); + $typeDescription = ''; + if (!$isMixedKeyType) { + $typeDescription = sprintf('<%s, %s>', $keyType->describe($level), $valueType->describe($level)); + } elseif (!$isMixedValueType) { + $typeDescription = sprintf('<%s>', $valueType->describe($level)); + } + + $describedTypes[$i] = ($isNonEmptyArray ? 'non-empty-array' : 'array') . $typeDescription; + } + continue; + } elseif ($type instanceof ConstantArrayType) { + $description = $type->describe($level); + $descriptionWithoutKind = substr($description, strlen('array')); + $begin = $isList ? 'list' : 'array'; + if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) { + $begin = 'non-empty-' . $begin; + } + + $describedTypes[$i] = $begin . $descriptionWithoutKind; + continue; + } + if ($type instanceof NonEmptyArrayType || $type instanceof AccessoryArrayListType) { + continue; + } + } + + if ($type instanceof CallableType && $type->isCommonCallable()) { + $typesToDescribe[$i] = $type; + $skipTypeNames[] = 'object'; + $skipTypeNames[] = 'string'; + continue; + } + + if (!$type instanceof AccessoryType) { + $baseTypes[$i] = $type; + continue; + } + + if ($skipAccessoryTypes) { + continue; + } + + $typesToDescribe[$i] = $type; + } + + foreach ($baseTypes as $i => $type) { + $typeDescription = $type->describe($level); + + if (in_array($typeDescription, ['object', 'string'], true) && in_array($typeDescription, $skipTypeNames, true)) { + foreach ($typesToDescribe as $j => $typeToDescribe) { + if ($typeToDescribe instanceof CallableType && $typeToDescribe->isCommonCallable()) { + $describedTypes[$i] = 'callable-' . $typeDescription; + unset($typesToDescribe[$j]); + continue 2; + } + } + } + + if (in_array($typeDescription, $skipTypeNames, true)) { + continue; + } + + $describedTypes[$i] = $type->describe($level); + } + + foreach ($typesToDescribe as $i => $typeToDescribe) { + $describedTypes[$i] = $typeToDescribe->describe($level); + } + + ksort($describedTypes); + + return implode('&', $describedTypes); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getTemplateType($ancestorClassName, $templateTypeName)); + } + + public function isObject(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isObject()); + } + + public function isEnum(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isEnum()); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->canAccessProperties()); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $propertyPrototypes = []; + foreach ($this->types as $type) { + if (!$type->hasProperty($propertyName)->yes()) { + continue; + } + + $propertyPrototypes[] = $type->getUnresolvedPropertyPrototype($propertyName, $scope)->withFechedOnType($this); + } + + $propertiesCount = count($propertyPrototypes); + if ($propertiesCount === 0) { + throw new ShouldNotHappenException(); + } + + if ($propertiesCount === 1) { + return $propertyPrototypes[0]; + } + + return new IntersectionTypeUnresolvedPropertyPrototypeReflection($propertyName, $propertyPrototypes); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->canCallMethods()); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $methodPrototypes = []; + foreach ($this->types as $type) { + if (!$type->hasMethod($methodName)->yes()) { + continue; + } + + $methodPrototypes[] = $type->getUnresolvedMethodPrototype($methodName, $scope)->withCalledOnType($this); + } + + $methodsCount = count($methodPrototypes); + if ($methodsCount === 0) { + throw new ShouldNotHappenException(); + } + + if ($methodsCount === 1) { + return $methodPrototypes[0]; + } + + return new IntersectionTypeUnresolvedMethodPrototypeReflection($methodName, $methodPrototypes); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->canAccessConstants()); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + foreach ($this->types as $type) { + if ($type->hasConstant($constantName)->yes()) { + return $type->getConstant($constantName); + } + } + + throw new ShouldNotHappenException(); + } + + public function isIterable(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isIterable()); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce()); + } + + public function getArraySize(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getArraySize()); + } + + public function getIterableKeyType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableKeyType()); + } + + public function getFirstIterableKeyType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getFirstIterableKeyType()); + } + + public function getLastIterableKeyType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getLastIterableKeyType()); + } + + public function getIterableValueType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableValueType()); + } + + public function getFirstIterableValueType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getFirstIterableValueType()); + } + + public function getLastIterableValueType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getLastIterableValueType()); + } + + public function isArray(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isArray()); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); + } + + public function isOversizedArray(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isOversizedArray()); + } + + public function isList(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isList()); + } + + public function isString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isString()); + } + + public function isNumericString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isNumericString()); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isNonEmptyString()); + } + + public function isNonFalsyString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isNonFalsyString()); + } + + public function isLiteralString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isLiteralString()); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isLowercaseString()); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isUppercaseString()); + } + + public function isClassString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isClassString()); + } + + public function getClassStringObjectType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getClassStringObjectType()); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getObjectTypeOrClassStringObjectType()); + } + + public function isVoid(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isVoid()); + } + + public function isScalar(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isScalar()); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return $this->intersectResults( + static fn (Type $innerType): TrinaryLogic => $innerType->looseCompare($type, $phpVersion)->toTrinaryLogic() + )->toBooleanType(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isOffsetAccessible()); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isOffsetAccessLegal()); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($this->isList()->yes() && $this->isIterableAtLeastOnce()->yes()) { + $arrayKeyOffsetType = $offsetType->toArrayKey(); + if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + return TrinaryLogic::createYes(); + } + } + + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); + } + + public function getOffsetValueType(Type $offsetType): Type + { + $result = $this->intersectTypes(static fn (Type $type): Type => $type->getOffsetValueType($offsetType)); + if ($this->isOversizedArray()->yes()) { + return TypeUtils::toBenevolentUnion($result); + } + + return $result; + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($this->isOversizedArray()->yes()) { + return $this->intersectTypes(static function (Type $type) use ($offsetType, $valueType, $unionValues): Type { + // avoid new HasOffsetValueType being intersected with oversized array + if (!$type instanceof ArrayType) { + return $type->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + if (!$offsetType instanceof ConstantStringType && !$offsetType instanceof ConstantIntegerType) { + return $type->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + if (!$offsetType->isSuperTypeOf($type->getKeyType())->yes()) { + return $type->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + return TypeCombinator::intersect( + new ArrayType( + TypeCombinator::union($type->getKeyType(), $offsetType), + TypeCombinator::union($type->getItemType(), $valueType), + ), + new NonEmptyArrayType(), + ); + }); + } + + $result = $this->intersectTypes(static fn (Type $type): Type => $type->setOffsetValueType($offsetType, $valueType, $unionValues)); + + if ($offsetType !== null && $this->isList()->yes() && $this->isIterableAtLeastOnce()->yes() && (new ConstantIntegerType(1))->isSuperTypeOf($offsetType)->yes()) { + $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); + } + + return $result; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->setExistingOffsetValueType($offsetType, $valueType)); + } + + public function unsetOffset(Type $offsetType): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->unsetOffset($offsetType)); + } + + public function getKeysArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getKeysArray()); + } + + public function getValuesArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getValuesArray()); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->chunkArray($lengthType, $preserveKeys)); + } + + public function fillKeysArray(Type $valueType): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->fillKeysArray($valueType)); + } + + public function flipArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->flipArray()); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->intersectKeyArray($otherArraysType)); + } + + public function popArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->popArray()); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->reverseArray($preserveKeys)); + } + + public function searchArray(Type $needleType): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->searchArray($needleType)); + } + + public function shiftArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->shiftArray()); + } + + public function shuffleArray(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->shuffleArray()); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->sliceArray($offsetType, $lengthType, $preserveKeys)); + } + + public function getEnumCases(): array + { + $compare = []; + foreach ($this->types as $type) { + $oneType = []; + foreach ($type->getEnumCases() as $enumCase) { + $oneType[$enumCase->getClassName() . '::' . $enumCase->getEnumCaseName()] = $enumCase; + } + $compare[] = $oneType; + } + + return array_values(array_intersect_key(...$compare)); + } + + public function isCallable(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isCallable()); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + if ($this->isCallable()->no()) { + throw new ShouldNotHappenException(); + } + + return [new TrivialParametersAcceptor()]; + } + + public function isCloneable(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isCloneable()); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isSmallerThan($otherType, $phpVersion)); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isSmallerThanOrEqual($otherType, $phpVersion)); + } + + public function isNull(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isNull()); + } + + public function isConstantValue(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantValue()); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantScalarValue()); + } + + public function getConstantScalarTypes(): array + { + $scalarTypes = []; + foreach ($this->types as $type) { + foreach ($type->getConstantScalarTypes() as $scalarType) { + $scalarTypes[] = $scalarType; + } + } + + return $scalarTypes; + } + + public function getConstantScalarValues(): array + { + $values = []; + foreach ($this->types as $type) { + foreach ($type->getConstantScalarValues() as $value) { + $values[] = $value; + } + } + + return $values; + } + + public function isTrue(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isTrue()); + } + + public function isFalse(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isFalse()); + } + + public function isBoolean(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isBoolean()); + } + + public function isFloat(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isFloat()); + } + + public function isInteger(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isInteger()); + } + + public function isGreaterThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $otherType->isSmallerThan($type, $phpVersion)); + } + + public function isGreaterThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $otherType->isSmallerThanOrEqual($type, $phpVersion)); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getSmallerType($phpVersion)); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getSmallerOrEqualType($phpVersion)); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getGreaterType($phpVersion)); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->getGreaterOrEqualType($phpVersion)); + } + + public function toBoolean(): BooleanType + { + $type = $this->intersectTypes(static fn (Type $type): BooleanType => $type->toBoolean()); + + if (!$type instanceof BooleanType) { + return new BooleanType(); + } + + return $type; + } + + public function toNumber(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toNumber()); + + return $type; + } + + public function toAbsoluteNumber(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); + + return $type; + } + + public function toString(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toString()); + + return $type; + } + + public function toInteger(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toInteger()); + + return $type; + } + + public function toFloat(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toFloat()); + + return $type; + } + + public function toArray(): Type + { + $type = $this->intersectTypes(static fn (Type $type): Type => $type->toArray()); + + return $type; + } + + public function toArrayKey(): Type + { + if ($this->isNumericString()->yes()) { + return new IntegerType(); + } + + if ($this->isString()->yes()) { + return $this; + } + + return $this->intersectTypes(static fn (Type $type): Type => $type->toArrayKey()); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->toCoercedArgumentType($strictTypes)); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + + foreach ($this->types as $type) { + $types = $types->intersect($type->inferTemplateTypes($receivedType)); + } + + return $types; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $references = []; + + foreach ($this->types as $type) { + foreach ($type->getReferencedTemplateTypes($positionVariance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function traverse(callable $cb): Type + { + $types = []; + $changed = false; + + foreach ($this->types as $type) { + $newType = $cb($type); + if ($type !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeCombinator::intersect(...$types); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $types = []; + $changed = false; + + if (!$right instanceof self) { + return $this; + } + + if (count($this->getTypes()) !== count($right->getTypes())) { + return $this; + } + + foreach ($this->getSortedTypes() as $i => $leftType) { + $rightType = $right->getSortedTypes()[$i]; + $newType = $cb($leftType, $rightType); + if ($leftType !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeCombinator::intersect(...$types); + } + + return $this; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + return $this->intersectTypes(static fn (Type $type): Type => TypeCombinator::remove($type, $typeToRemove)); + } + + public function exponentiate(Type $exponent): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->exponentiate($exponent)); + } + + public function getFiniteTypes(): array + { + $compare = []; + foreach ($this->types as $type) { + $oneType = []; + foreach ($type->getFiniteTypes() as $finiteType) { + $oneType[md5($finiteType->describe(VerbosityLevel::typeOnly()))] = $finiteType; + } + $compare[] = $oneType; + } + + $result = array_values(array_intersect_key(...$compare)); + + if (count($result) > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return []; + } + + return $result; + } + + /** + * @param callable(Type $type): TrinaryLogic $getResult + */ + private function intersectResults(callable $getResult): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->types, $getResult); + } + + /** + * @param callable(Type $type): Type $getType + */ + private function intersectTypes(callable $getType): Type + { + $operands = array_map($getType, $this->types); + return TypeCombinator::intersect(...$operands); + } + + public function toPhpDocNode(): TypeNode + { + $baseTypes = []; + $typesToDescribe = []; + $skipTypeNames = []; + + $nonEmptyStr = false; + $nonFalsyStr = false; + $isList = $this->isList()->yes(); + $isArray = $this->isArray()->yes(); + $isNonEmptyArray = $this->isIterableAtLeastOnce()->yes(); + $describedTypes = []; + + foreach ($this->getSortedTypes() as $i => $type) { + if ($type instanceof AccessoryNonEmptyStringType + || $type instanceof AccessoryLiteralStringType + || $type instanceof AccessoryNumericStringType + || $type instanceof AccessoryNonFalsyStringType + || $type instanceof AccessoryLowercaseStringType + || $type instanceof AccessoryUppercaseStringType + ) { + if ($type instanceof AccessoryNonFalsyStringType) { + $nonFalsyStr = true; + } + if ($type instanceof AccessoryNonEmptyStringType) { + $nonEmptyStr = true; + } + if ($nonEmptyStr && $nonFalsyStr) { + // prevent redundant 'non-empty-string&non-falsy-string' + foreach ($typesToDescribe as $key => $typeToDescribe) { + if (!($typeToDescribe instanceof AccessoryNonEmptyStringType)) { + continue; + } + + unset($typesToDescribe[$key]); + } + } + + $typesToDescribe[$i] = $type; + $skipTypeNames[] = 'string'; + continue; + } + + if ($isList || $isArray) { + if ($type instanceof ArrayType) { + $keyType = $type->getKeyType(); + $valueType = $type->getItemType(); + if ($isList) { + $isMixedValueType = $valueType instanceof MixedType && $valueType->describe(VerbosityLevel::precise()) === 'mixed' && !$valueType->isExplicitMixed(); + $identifierTypeNode = new IdentifierTypeNode($isNonEmptyArray ? 'non-empty-list' : 'list'); + if (!$isMixedValueType) { + $describedTypes[$i] = new GenericTypeNode($identifierTypeNode, [ + $valueType->toPhpDocNode(), + ]); + } else { + $describedTypes[$i] = $identifierTypeNode; + } + } else { + $isMixedKeyType = $keyType instanceof MixedType && $keyType->describe(VerbosityLevel::precise()) === 'mixed' && !$keyType->isExplicitMixed(); + $isMixedValueType = $valueType instanceof MixedType && $valueType->describe(VerbosityLevel::precise()) === 'mixed' && !$valueType->isExplicitMixed(); + $identifierTypeNode = new IdentifierTypeNode($isNonEmptyArray ? 'non-empty-array' : 'array'); + if (!$isMixedKeyType) { + $describedTypes[$i] = new GenericTypeNode($identifierTypeNode, [ + $keyType->toPhpDocNode(), + $valueType->toPhpDocNode(), + ]); + } elseif (!$isMixedValueType) { + $describedTypes[$i] = new GenericTypeNode($identifierTypeNode, [ + $valueType->toPhpDocNode(), + ]); + } else { + $describedTypes[$i] = $identifierTypeNode; + } + } + continue; + } elseif ($type instanceof ConstantArrayType) { + $constantArrayTypeNode = $type->toPhpDocNode(); + if ($constantArrayTypeNode instanceof ArrayShapeNode) { + $newKind = $constantArrayTypeNode->kind; + if ($isList) { + if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) { + $newKind = ArrayShapeNode::KIND_NON_EMPTY_LIST; + } else { + $newKind = ArrayShapeNode::KIND_LIST; + } + } elseif ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) { + $newKind = ArrayShapeNode::KIND_NON_EMPTY_ARRAY; + } + + if ($newKind !== $constantArrayTypeNode->kind) { + if ($constantArrayTypeNode->sealed) { + $constantArrayTypeNode = ArrayShapeNode::createSealed($constantArrayTypeNode->items, $newKind); + } else { + $constantArrayTypeNode = ArrayShapeNode::createUnsealed($constantArrayTypeNode->items, $constantArrayTypeNode->unsealedType, $newKind); + } + } + + $describedTypes[$i] = $constantArrayTypeNode; + continue; + } + } + if ($type instanceof NonEmptyArrayType || $type instanceof AccessoryArrayListType) { + continue; + } + } + + if (!$type instanceof AccessoryType) { + $baseTypes[$i] = $type; + continue; + } + + $accessoryPhpDocNode = $type->toPhpDocNode(); + if ($accessoryPhpDocNode instanceof IdentifierTypeNode && $accessoryPhpDocNode->name === '') { + continue; + } + + $typesToDescribe[$i] = $type; + } + + foreach ($baseTypes as $i => $type) { + $typeNode = $type->toPhpDocNode(); + if ($typeNode instanceof GenericTypeNode && $typeNode->type->name === 'array') { + $nonEmpty = false; + $typeName = 'array'; + foreach ($typesToDescribe as $j => $typeToDescribe) { + if ($typeToDescribe instanceof AccessoryArrayListType) { + $typeName = 'list'; + if (count($typeNode->genericTypes) > 1) { + array_shift($typeNode->genericTypes); + } + } elseif ($typeToDescribe instanceof NonEmptyArrayType) { + $nonEmpty = true; + } else { + continue; + } + + unset($typesToDescribe[$j]); + } + + if ($nonEmpty) { + $typeName = 'non-empty-' . $typeName; + } + + $describedTypes[$i] = new GenericTypeNode( + new IdentifierTypeNode($typeName), + $typeNode->genericTypes, + ); + continue; + } + + if ($typeNode instanceof IdentifierTypeNode && in_array($typeNode->name, $skipTypeNames, true)) { + continue; + } + + $describedTypes[$i] = $typeNode; + } + + foreach ($typesToDescribe as $i => $typeToDescribe) { + $describedTypes[$i] = $typeToDescribe->toPhpDocNode(); + } + + ksort($describedTypes); + + $describedTypes = array_values($describedTypes); + + if (count($describedTypes) === 1) { + return $describedTypes[0]; + } + + return new IntersectionTypeNode($describedTypes); + } + +} diff --git a/src/Type/IsSuperTypeOfResult.php b/src/Type/IsSuperTypeOfResult.php new file mode 100644 index 00000000..9c1465f6 --- /dev/null +++ b/src/Type/IsSuperTypeOfResult.php @@ -0,0 +1,165 @@ + $reasons + */ + public function __construct( + public readonly TrinaryLogic $result, + public readonly array $reasons, + ) + { + } + + public function yes(): bool + { + return $this->result->yes(); + } + + public function maybe(): bool + { + return $this->result->maybe(); + } + + public function no(): bool + { + return $this->result->no(); + } + + public static function createYes(): self + { + return new self(TrinaryLogic::createYes(), []); + } + + /** + * @param list $reasons + */ + public static function createNo(array $reasons = []): self + { + return new self(TrinaryLogic::createNo(), $reasons); + } + + public static function createMaybe(): self + { + return new self(TrinaryLogic::createMaybe(), []); + } + + public static function createFromBoolean(bool $value): self + { + return new self(TrinaryLogic::createFromBoolean($value), []); + } + + public function toAcceptsResult(): AcceptsResult + { + return new AcceptsResult($this->result, $this->reasons); + } + + public function and(self ...$others): self + { + $results = []; + $reasons = []; + foreach ($others as $other) { + $results[] = $other->result; + $reasons[] = $other->reasons; + } + + return new self( + $this->result->and(...$results), + array_values(array_unique(array_merge($this->reasons, ...$reasons))), + ); + } + + public function or(self ...$others): self + { + $results = []; + $reasons = []; + foreach ($others as $other) { + $results[] = $other->result; + $reasons[] = $other->reasons; + } + + return new self( + $this->result->or(...$results), + array_values(array_unique(array_merge($this->reasons, ...$reasons))), + ); + } + + /** + * @param callable(string): string $cb + */ + public function decorateReasons(callable $cb): self + { + $reasons = []; + foreach ($this->reasons as $reason) { + $reasons[] = $cb($reason); + } + + return new self($this->result, $reasons); + } + + public static function extremeIdentity(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + + $result = TrinaryLogic::extremeIdentity(...array_map(static fn (self $result) => $result->result, $operands)); + + return new self($result, self::mergeReasons($operands)); + } + + public static function maxMin(self ...$operands): self + { + if ($operands === []) { + throw new ShouldNotHappenException(); + } + + $result = TrinaryLogic::maxMin(...array_map(static fn (self $result) => $result->result, $operands)); + + return new self($result, self::mergeReasons($operands)); + } + + public function negate(): self + { + return new self($this->result->negate(), $this->reasons); + } + + public function describe(): string + { + return $this->result->describe(); + } + + /** + * @param array $operands + * + * @return list + */ + private static function mergeReasons(array $operands): array + { + $reasons = []; + foreach ($operands as $operand) { + foreach ($operand->reasons as $reason) { + $reasons[] = $reason; + } + } + + return array_values(array_unique($reasons)); + } + +} diff --git a/src/Type/IterableType.php b/src/Type/IterableType.php new file mode 100644 index 00000000..cf7dca78 --- /dev/null +++ b/src/Type/IterableType.php @@ -0,0 +1,518 @@ +keyType; + } + + public function getItemType(): Type + { + return $this->itemType; + } + + public function getReferencedClasses(): array + { + return array_merge( + $this->keyType->getReferencedClasses(), + $this->getItemType()->getReferencedClasses(), + ); + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type->isConstantArray()->yes() && $type->isIterableAtLeastOnce()->no()) { + return AcceptsResult::createYes(); + } + if ($type->isIterable()->yes()) { + return $this->getIterableValueType()->accepts($type->getIterableValueType(), $strictTypes) + ->and($this->getIterableKeyType()->accepts($type->getIterableKeyType(), $strictTypes)); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return (new IsSuperTypeOfResult($type->isIterable(), [])) + ->and($this->getIterableValueType()->isSuperTypeOf($type->getIterableValueType())) + ->and($this->getIterableKeyType()->isSuperTypeOf($type->getIterableKeyType())); + } + + public function isSuperTypeOfMixed(Type $type): IsSuperTypeOfResult + { + return (new IsSuperTypeOfResult($type->isIterable(), [])) + ->and($this->isNestedTypeSuperTypeOf($this->getIterableValueType(), $type->getIterableValueType())) + ->and($this->isNestedTypeSuperTypeOf($this->getIterableKeyType(), $type->getIterableKeyType())); + } + + private function isNestedTypeSuperTypeOf(Type $a, Type $b): IsSuperTypeOfResult + { + if (!$a instanceof MixedType || !$b instanceof MixedType) { + return $a->isSuperTypeOf($b); + } + + if ($a instanceof TemplateMixedType || $b instanceof TemplateMixedType) { + return $a->isSuperTypeOf($b); + } + + if ($a->isExplicitMixed()) { + if ($b->isExplicitMixed()) { + return IsSuperTypeOfResult::createYes(); + } + + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createYes(); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof IntersectionType || $otherType instanceof UnionType) { + return $otherType->isSuperTypeOf(new UnionType([ + new ArrayType($this->keyType, $this->itemType), + new IntersectionType([ + new ObjectType(Traversable::class), + $this, + ]), + ])); + } + + if ($otherType instanceof self) { + $limit = IsSuperTypeOfResult::createYes(); + } else { + $limit = IsSuperTypeOfResult::createMaybe(); + } + + if ($otherType->isConstantArray()->yes() && $otherType->isIterableAtLeastOnce()->no()) { + return IsSuperTypeOfResult::createMaybe(); + } + + return $limit->and( + new IsSuperTypeOfResult($otherType->isIterable(), []), + $otherType->getIterableValueType()->isSuperTypeOf($this->itemType), + $otherType->getIterableKeyType()->isSuperTypeOf($this->keyType), + ); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + return $this->keyType->equals($type->keyType) + && $this->itemType->equals($type->itemType); + } + + public function describe(VerbosityLevel $level): string + { + $isMixedKeyType = $this->keyType instanceof MixedType && $this->keyType->describe(VerbosityLevel::precise()) === 'mixed'; + $isMixedItemType = $this->itemType instanceof MixedType && $this->itemType->describe(VerbosityLevel::precise()) === 'mixed'; + if ($isMixedKeyType) { + if ($isMixedItemType) { + return 'iterable'; + } + + return sprintf('iterable<%s>', $this->itemType->describe($level)); + } + + return sprintf('iterable<%s, %s>', $this->keyType->describe($level), $this->itemType->describe($level)); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($this->getIterableKeyType()->isSuperTypeOf($offsetType)->no()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ArrayType($this->keyType, $this->getItemType()); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return TypeCombinator::union($this, new ArrayType(new MixedType(true), new MixedType(true)), new ObjectType(Traversable::class)); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + return $this->keyType; + } + + public function getFirstIterableKeyType(): Type + { + return $this->keyType; + } + + public function getLastIterableKeyType(): Type + { + return $this->keyType; + } + + public function getIterableValueType(): Type + { + return $this->getItemType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->getItemType(); + } + + public function getLastIterableValueType(): Type + { + return $this->getItemType(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getEnumCases(): array + { + return []; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if (!$receivedType->isIterable()->yes()) { + return TemplateTypeMap::createEmpty(); + } + + $keyTypeMap = $this->getIterableKeyType()->inferTemplateTypes($receivedType->getIterableKeyType()); + $valueTypeMap = $this->getIterableValueType()->inferTemplateTypes($receivedType->getIterableValueType()); + + return $keyTypeMap->union($valueTypeMap); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + + return array_merge( + $this->getIterableKeyType()->getReferencedTemplateTypes($variance), + $this->getIterableValueType()->getReferencedTemplateTypes($variance), + ); + } + + public function traverse(callable $cb): Type + { + $keyType = $cb($this->keyType); + $itemType = $cb($this->itemType); + + if ($keyType !== $this->keyType || $itemType !== $this->itemType) { + return new self($keyType, $itemType); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $keyType = $cb($this->keyType, $right->getIterableKeyType()); + $itemType = $cb($this->itemType, $right->getIterableValueType()); + + if ($keyType !== $this->keyType || $itemType !== $this->itemType) { + return new self($keyType, $itemType); + } + + return $this; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + $arrayType = new ArrayType(new MixedType(), new MixedType()); + if ($typeToRemove->isSuperTypeOf($arrayType)->yes()) { + return new GenericObjectType(Traversable::class, [ + $this->getIterableKeyType(), + $this->getIterableValueType(), + ]); + } + + $traversableType = new ObjectType(Traversable::class); + if ($typeToRemove->isSuperTypeOf($traversableType)->yes()) { + return new ArrayType($this->getIterableKeyType(), $this->getIterableValueType()); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + $isMixedKeyType = $this->keyType instanceof MixedType && $this->keyType->describe(VerbosityLevel::precise()) === 'mixed'; + $isMixedItemType = $this->itemType instanceof MixedType && $this->itemType->describe(VerbosityLevel::precise()) === 'mixed'; + + if ($isMixedKeyType) { + if ($isMixedItemType) { + return new IdentifierTypeNode('iterable'); + } + + return new GenericTypeNode( + new IdentifierTypeNode('iterable'), + [ + $this->itemType->toPhpDocNode(), + ], + ); + } + + return new GenericTypeNode( + new IdentifierTypeNode('iterable'), + [ + $this->keyType->toPhpDocNode(), + $this->itemType->toPhpDocNode(), + ], + ); + } + +} diff --git a/src/Type/JustNullableTypeTrait.php b/src/Type/JustNullableTypeTrait.php new file mode 100644 index 00000000..a3be21fe --- /dev/null +++ b/src/Type/JustNullableTypeTrait.php @@ -0,0 +1,173 @@ +isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return get_class($type) === static::class; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + +} diff --git a/src/Type/KeyOfType.php b/src/Type/KeyOfType.php new file mode 100644 index 00000000..aa47c435 --- /dev/null +++ b/src/Type/KeyOfType.php @@ -0,0 +1,95 @@ +type; + } + + public function getReferencedClasses(): array + { + return $this->type->getReferencedClasses(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->type->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('key-of<%s>', $this->type->describe($level)); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type); + } + + protected function getResult(): Type + { + return $this->type->getIterableKeyType(); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode(new IdentifierTypeNode('key-of'), [$this->type->toPhpDocNode()]); + } + +} diff --git a/src/Type/LateResolvableType.php b/src/Type/LateResolvableType.php new file mode 100644 index 00000000..fae4dc25 --- /dev/null +++ b/src/Type/LateResolvableType.php @@ -0,0 +1,14 @@ +container->getByType(TypeAliasResolver::class); + } + +} diff --git a/src/Type/LooseComparisonHelper.php b/src/Type/LooseComparisonHelper.php new file mode 100644 index 00000000..a795f518 --- /dev/null +++ b/src/Type/LooseComparisonHelper.php @@ -0,0 +1,51 @@ +castsNumbersToStringsOnLooseComparison()) { + $isNumber = new UnionType([ + new IntegerType(), + new FloatType(), + ]); + + if ($leftType->isString()->yes() && $leftType->isNumericString()->no() && $isNumber->isSuperTypeOf($rightType)->yes()) { + $stringValue = (string) $rightType->getValue(); + return new ConstantBooleanType($stringValue === $leftType->getValue()); + } + if ($rightType->isString()->yes() && $rightType->isNumericString()->no() && $isNumber->isSuperTypeOf($leftType)->yes()) { + $stringValue = (string) $leftType->getValue(); + return new ConstantBooleanType($stringValue === $rightType->getValue()); + } + } else { + if ($leftType->isString()->yes() && $leftType->isNumericString()->no() && $rightType->isFloat()->yes()) { + $numericPart = (float) $leftType->getValue(); + return new ConstantBooleanType($numericPart === $rightType->getValue()); + } + if ($rightType->isString()->yes() && $rightType->isNumericString()->no() && $leftType->isFloat()->yes()) { + $numericPart = (float) $rightType->getValue(); + return new ConstantBooleanType($numericPart === $leftType->getValue()); + } + if ($leftType->isString()->yes() && $leftType->isNumericString()->no() && $rightType->isInteger()->yes()) { + $numericPart = (int) $leftType->getValue(); + return new ConstantBooleanType($numericPart === $rightType->getValue()); + } + if ($rightType->isString()->yes() && $rightType->isNumericString()->no() && $leftType->isInteger()->yes()) { + $numericPart = (int) $rightType->getValue(); + return new ConstantBooleanType($numericPart === $leftType->getValue()); + } + } + + // @phpstan-ignore equal.notAllowed + return new ConstantBooleanType($leftType->getValue() == $rightType->getValue()); // phpcs:ignore + } + +} diff --git a/src/Type/MethodParameterClosureTypeExtension.php b/src/Type/MethodParameterClosureTypeExtension.php new file mode 100644 index 00000000..2cc56b05 --- /dev/null +++ b/src/Type/MethodParameterClosureTypeExtension.php @@ -0,0 +1,33 @@ +subtractedType = $subtractedType; + } + + public function getReferencedClasses(): array + { + return []; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getArrays(): array + { + return []; + } + + public function getConstantArrays(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return AcceptsResult::createYes(); + } + + public function isSuperTypeOfMixed(MixedType $type): IsSuperTypeOfResult + { + if ($this->subtractedType === null) { + if ($this->isExplicitMixed) { + if ($type->isExplicitMixed) { + return IsSuperTypeOfResult::createYes(); + } + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createYes(); + } + + if ($type->subtractedType === null) { + return IsSuperTypeOfResult::createMaybe(); + } + + $isSuperType = $type->subtractedType->isSuperTypeOf($this->subtractedType); + if ($isSuperType->yes()) { + if ($this->isExplicitMixed) { + if ($type->isExplicitMixed) { + return IsSuperTypeOfResult::createYes(); + } + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createYes(); + } + + return IsSuperTypeOfResult::createMaybe(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($this->subtractedType === null || $type instanceof NeverType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof self) { + if ($type->subtractedType === null) { + return IsSuperTypeOfResult::createMaybe(); + } + $isSuperType = $type->subtractedType->isSuperTypeOf($this->subtractedType); + if ($isSuperType->yes()) { + return $isSuperType; + } + + return IsSuperTypeOfResult::createMaybe(); + } + + $result = $this->subtractedType->isSuperTypeOf($type)->negate(); + if ($result->no()) { + return IsSuperTypeOfResult::createNo([ + sprintf( + 'Type %s has already been eliminated from %s.', + $this->subtractedType->describe(VerbosityLevel::precise()), + $this->describe(VerbosityLevel::typeOnly()), + ), + ]); + } + + return $result; + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return new self($this->isExplicitMixed); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return new self($this->isExplicitMixed); + } + + public function unsetOffset(Type $offsetType): Type + { + if ($this->subtractedType !== null) { + return new self($this->isExplicitMixed, TypeCombinator::remove($this->subtractedType, new ConstantArrayType([], []))); + } + return $this; + } + + public function getKeysArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new UnionType([new IntegerType(), new StringType()])), new AccessoryArrayListType()); + } + + public function getValuesArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new MixedType($this->isExplicitMixed)), new AccessoryArrayListType()); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new MixedType($this->isExplicitMixed)), new AccessoryArrayListType()); + } + + public function fillKeysArray(Type $valueType): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType($this->getIterableValueType(), $valueType); + } + + public function flipArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function popArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function searchArray(Type $needleType): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return TypeCombinator::union(new IntegerType(), new StringType(), new ConstantBooleanType(false)); + } + + public function shiftArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function shuffleArray(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new MixedType($this->isExplicitMixed)), new AccessoryArrayListType()); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function isCallable(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new CallableType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function getEnumCases(): array + { + return []; + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return [new TrivialParametersAcceptor()]; + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if ($this->subtractedType === null) { + if ($type->subtractedType === null) { + return true; + } + + return false; + } + + if ($type->subtractedType === null) { + return false; + } + + return $this->subtractedType->equals($type->subtractedType); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof self && !$otherType instanceof TemplateMixedType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($this->subtractedType !== null) { + $isSuperType = $this->subtractedType->isSuperTypeOf($otherType); + if ($isSuperType->yes()) { + return IsSuperTypeOfResult::createNo(); + } + } + + return IsSuperTypeOfResult::createMaybe(); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + $isSuperType = $this->isSuperTypeOf($acceptingType)->toAcceptsResult(); + if ($isSuperType->no()) { + return $isSuperType; + } + return AcceptsResult::createYes(); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return new self(); + } + + public function isObject(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ObjectWithoutClassType())->yes()) { + return TrinaryLogic::createNo(); + } + } + return TrinaryLogic::createMaybe(); + } + + public function isEnum(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ObjectWithoutClassType())->yes()) { + return TrinaryLogic::createNo(); + } + } + return TrinaryLogic::createMaybe(); + } + + public function canAccessProperties(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $property = new DummyPropertyReflection(); + return new CallbackUnresolvedPropertyPrototypeReflection( + $property, + $property->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canCallMethods(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $method = new DummyMethodReflection($methodName); + return new CallbackUnresolvedMethodPrototypeReflection( + $method, + $method->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return new DummyClassConstantReflection($constantName); + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'mixed', + static fn (): string => 'mixed', + function () use ($level): string { + $description = 'mixed'; + if ($this->subtractedType !== null) { + $description .= $this->subtractedType instanceof UnionType + ? sprintf('~(%s)', $this->subtractedType->describe($level)) + : sprintf('~%s', $this->subtractedType->describe($level)); + } + + return $description; + }, + function () use ($level): string { + $description = 'mixed'; + if ($this->subtractedType !== null) { + $description .= $this->subtractedType instanceof UnionType + ? sprintf('~(%s)', $this->subtractedType->describe($level)) + : sprintf('~%s', $this->subtractedType->describe($level)); + } + + if ($this->isExplicitMixed) { + $description .= '=explicit'; + } else { + $description .= '=implicit'; + } + + return $description; + }, + ); + } + + public function toBoolean(): BooleanType + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(StaticTypeFactory::falsey())->yes()) { + return new ConstantBooleanType(true); + } + } + + return new BooleanType(); + } + + public function toNumber(): Type + { + return TypeCombinator::union( + $this->toInteger(), + $this->toFloat(), + ); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toInteger(): Type + { + $castsToZero = new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + new StringType(), + new FloatType(), // every 0.x float casts to int(0) + ]); + if ( + $this->subtractedType !== null + && $this->subtractedType->isSuperTypeOf($castsToZero)->yes() + ) { + return new UnionType([ + IntegerRangeType::fromInterval(null, -1), + IntegerRangeType::fromInterval(1, null), + ]); + } + + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + if ($this->subtractedType !== null) { + $castsToEmptyString = new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantStringType(''), + ]); + if ($this->subtractedType->isSuperTypeOf($castsToEmptyString)->yes()) { + $accessories = [ + new StringType(), + new AccessoryNonEmptyStringType(), + ]; + + $castsToZeroString = new UnionType([ + new ConstantFloatType(0.0), + new ConstantStringType('0'), + new ConstantIntegerType(0), + ]); + if ($this->subtractedType->isSuperTypeOf($castsToZeroString)->yes()) { + $accessories[] = new AccessoryNonFalsyStringType(); + } + return new IntersectionType( + $accessories, + ); + } + } + + return new StringType(); + } + + public function toArray(): Type + { + $mixed = new self($this->isExplicitMixed); + + return new ArrayType($mixed, $mixed); + } + + public function toArrayKey(): Type + { + return new BenevolentUnionType([new IntegerType(), new StringType()]); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isIterable(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new IterableType(new MixedType(), new MixedType()))->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->isIterable(); + } + + public function getArraySize(): Type + { + if ($this->isIterable()->no()) { + return new ErrorType(); + } + + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + return new self($this->isExplicitMixed); + } + + public function getFirstIterableKeyType(): Type + { + return new self($this->isExplicitMixed); + } + + public function getLastIterableKeyType(): Type + { + return new self($this->isExplicitMixed); + } + + public function getIterableValueType(): Type + { + return new self($this->isExplicitMixed); + } + + public function getFirstIterableValueType(): Type + { + return new self($this->isExplicitMixed); + } + + public function getLastIterableValueType(): Type + { + return new self($this->isExplicitMixed); + } + + public function isOffsetAccessible(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $offsetAccessibles = new UnionType([ + new StringType(), + new ArrayType(new MixedType(), new MixedType()), + new ObjectType(ArrayAccess::class), + ]); + + if ($this->subtractedType->isSuperTypeOf($offsetAccessibles)->yes()) { + return TrinaryLogic::createNo(); + } + } + return TrinaryLogic::createMaybe(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ObjectWithoutClassType())->yes()) { + return TrinaryLogic::createYes(); + } + } + return TrinaryLogic::createMaybe(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($this->isOffsetAccessible()->no()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new self($this->isExplicitMixed); + } + + public function isExplicitMixed(): bool + { + return $this->isExplicitMixed; + } + + public function subtract(Type $type): Type + { + if ($type instanceof self && !$type instanceof TemplateType) { + return new NeverType(); + } + if ($this->subtractedType !== null) { + $type = TypeCombinator::union($this->subtractedType, $type); + } + + return new self($this->isExplicitMixed, $type); + } + + public function getTypeWithoutSubtractedType(): Type + { + return new self($this->isExplicitMixed); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + return new self($this->isExplicitMixed, $subtractedType); + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function isArray(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ArrayType(new MixedType(), new MixedType()))->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->isArray(); + } + + public function isOversizedArray(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $oversizedArray = TypeCombinator::intersect( + new ArrayType(new MixedType(), new MixedType()), + new OversizedArrayType(), + ); + + if ($this->subtractedType->isSuperTypeOf($oversizedArray)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isList(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $list = TypeCombinator::intersect( + new ArrayType(new IntegerType(), new MixedType()), + new AccessoryArrayListType(), + ); + + if ($this->subtractedType->isSuperTypeOf($list)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isNull(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new NullType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ConstantBooleanType(true))->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isFalse(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new ConstantBooleanType(false))->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isBoolean(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new BooleanType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isFloat(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new FloatType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isInteger(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new IntegerType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new StringType())->yes()) { + return TrinaryLogic::createNo(); + } + } + return TrinaryLogic::createMaybe(); + } + + public function isNumericString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $numericString = TypeCombinator::intersect( + new StringType(), + new AccessoryNumericStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($numericString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $nonEmptyString = TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($nonEmptyString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $nonFalsyString = TypeCombinator::intersect( + new StringType(), + new AccessoryNonFalsyStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($nonFalsyString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $literalString = TypeCombinator::intersect( + new StringType(), + new AccessoryLiteralStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($literalString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $lowercaseString = TypeCombinator::intersect( + new StringType(), + new AccessoryLowercaseStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($lowercaseString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $uppercaseString = TypeCombinator::intersect( + new StringType(), + new AccessoryUppercaseStringType(), + ); + + if ($this->subtractedType->isSuperTypeOf($uppercaseString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new StringType())->yes()) { + return TrinaryLogic::createNo(); + } + if ($this->subtractedType->isSuperTypeOf(new ClassStringType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + if (!$this->isClassString()->no()) { + return new ObjectWithoutClassType(); + } + + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + $objectOrClass = new UnionType([ + new ObjectWithoutClassType(), + new ClassStringType(), + ]); + if (!$this->isSuperTypeOf($objectOrClass)->no()) { + return new ObjectWithoutClassType(); + } + + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new VoidType())->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function isScalar(): TrinaryLogic + { + if ($this->subtractedType !== null) { + if ($this->subtractedType->isSuperTypeOf(new UnionType([new BooleanType(), new FloatType(), new IntegerType(), new StringType()]))->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($this->isSuperTypeOf($typeToRemove)->yes()) { + return $this->subtract($typeToRemove); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('mixed'); + } + +} diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php new file mode 100644 index 00000000..06698f7f --- /dev/null +++ b/src/Type/NeverType.php @@ -0,0 +1,537 @@ +isExplicit; + } + + public function getReferencedClasses(): array + { + return []; + } + + public function getArrays(): array + { + return []; + } + + public function getConstantArrays(): array + { + return []; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getConstantStrings(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return AcceptsResult::createYes(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::createYes(); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function describe(VerbosityLevel $level): string + { + return '*NEVER*'; + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return new NeverType(); + } + + public function isObject(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isEnum(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function canAccessProperties(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + throw new ShouldNotHappenException(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + throw new ShouldNotHappenException(); + } + + public function canCallMethods(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + throw new ShouldNotHappenException(); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + throw new ShouldNotHappenException(); + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getArraySize(): Type + { + return new NeverType(); + } + + public function getIterableKeyType(): Type + { + return new NeverType(); + } + + public function getFirstIterableKeyType(): Type + { + return new NeverType(); + } + + public function getLastIterableKeyType(): Type + { + return new NeverType(); + } + + public function getIterableValueType(): Type + { + return new NeverType(); + } + + public function getFirstIterableValueType(): Type + { + return new NeverType(); + } + + public function getLastIterableValueType(): Type + { + return new NeverType(); + } + + public function isArray(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantArray(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isOversizedArray(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isList(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new NeverType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return new ErrorType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return new ErrorType(); + } + + public function unsetOffset(Type $offsetType): Type + { + return new NeverType(); + } + + public function getKeysArray(): Type + { + return new NeverType(); + } + + public function getValuesArray(): Type + { + return new NeverType(); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return new NeverType(); + } + + public function fillKeysArray(Type $valueType): Type + { + return new NeverType(); + } + + public function flipArray(): Type + { + return new NeverType(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return new NeverType(); + } + + public function popArray(): Type + { + return new NeverType(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return new NeverType(); + } + + public function searchArray(Type $needleType): Type + { + return new NeverType(); + } + + public function shiftArray(): Type + { + return new NeverType(); + } + + public function shuffleArray(): Type + { + return new NeverType(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return new NeverType(); + } + + public function isCallable(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + throw new ShouldNotHappenException(); + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function toNumber(): Type + { + return $this; + } + + public function toAbsoluteNumber(): Type + { + return $this; + } + + public function toString(): Type + { + return $this; + } + + public function toInteger(): Type + { + return $this; + } + + public function toFloat(): Type + { + return $this; + } + + public function toArray(): Type + { + return $this; + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getEnumCases(): array + { + return []; + } + + public function exponentiate(Type $exponent): Type + { + return $this; + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('never'); + } + +} diff --git a/src/Type/NewObjectType.php b/src/Type/NewObjectType.php new file mode 100644 index 00000000..d032a53f --- /dev/null +++ b/src/Type/NewObjectType.php @@ -0,0 +1,95 @@ +type; + } + + public function getReferencedClasses(): array + { + return $this->type->getReferencedClasses(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->type->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('new<%s>', $this->type->describe($level)); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type); + } + + protected function getResult(): Type + { + return $this->type->getObjectTypeOrClassStringObjectType(); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode(new IdentifierTypeNode('new'), [$this->type->toPhpDocNode()]); + } + +} diff --git a/src/Type/NonAcceptingNeverType.php b/src/Type/NonAcceptingNeverType.php new file mode 100644 index 00000000..584b843b --- /dev/null +++ b/src/Type/NonAcceptingNeverType.php @@ -0,0 +1,42 @@ +isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createNo(); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($otherType instanceof ConstantScalarType) { + return TrinaryLogic::createFromBoolean(null < $otherType->getValue()); + } + + if ($otherType instanceof CompoundType) { + return $otherType->isGreaterThan($this, $phpVersion); + } + + return TrinaryLogic::createMaybe(); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($otherType instanceof ConstantScalarType) { + return TrinaryLogic::createFromBoolean(null <= $otherType->getValue()); + } + + if ($otherType instanceof CompoundType) { + return $otherType->isGreaterThanOrEqual($this, $phpVersion); + } + + return TrinaryLogic::createMaybe(); + } + + public function describe(VerbosityLevel $level): string + { + return 'null'; + } + + public function toNumber(): Type + { + return new ConstantIntegerType(0); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toString(): Type + { + return new ConstantStringType(''); + } + + public function toInteger(): Type + { + return $this->toNumber(); + } + + public function toFloat(): Type + { + return $this->toNumber()->toFloat(); + } + + public function toArray(): Type + { + return new ConstantArrayType([], []); + } + + public function toArrayKey(): Type + { + return new ConstantStringType(''); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new ErrorType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $array = new ConstantArrayType([], []); + return $array->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return $this; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getConstantScalarTypes(): array + { + return [$this]; + } + + public function getConstantScalarValues(): array + { + return [$this->getValue()]; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type instanceof ConstantScalarType) { + return LooseComparisonHelper::compareConstantScalars($this, $type, $phpVersion); + } + + if ($type->isConstantArray()->yes() && $type->isIterableAtLeastOnce()->no()) { + // @phpstan-ignore equal.alwaysTrue, equal.notAllowed + return new ConstantBooleanType($this->getValue() == []); // phpcs:ignore + } + + if ($type instanceof CompoundType) { + return $type->looseCompare($this, $phpVersion); + } + + return new BooleanType(); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + return new NeverType(); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + // All falsey types except '0' + return new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantArrayType([], []), + ]); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + // All truthy types, but also '0' + return new MixedType(false, new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantArrayType([], []), + ])); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + return new MixedType(); + } + + public function getFiniteTypes(): array + { + return [$this]; + } + + public function exponentiate(Type $exponent): Type + { + return new UnionType( + [ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], + ); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('null'); + } + +} diff --git a/src/Type/ObjectShapePropertyReflection.php b/src/Type/ObjectShapePropertyReflection.php new file mode 100644 index 00000000..0bb80164 --- /dev/null +++ b/src/Type/ObjectShapePropertyReflection.php @@ -0,0 +1,148 @@ +getClass(stdClass::class); + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function hasPhpDocType(): bool + { + return true; + } + + public function getPhpDocType(): Type + { + return $this->type; + } + + public function hasNativeType(): bool + { + return false; + } + + public function getNativeType(): Type + { + return new MixedType(); + } + + public function getReadableType(): Type + { + return $this->type; + } + + public function getWritableType(): Type + { + return new NeverType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return false; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function isProtectedSet(): bool + { + return false; + } + + public function isPrivateSet(): bool + { + return false; + } + + public function getAttributes(): array + { + return []; + } + +} diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php new file mode 100644 index 00000000..f51a90bb --- /dev/null +++ b/src/Type/ObjectShapeType.php @@ -0,0 +1,526 @@ + $properties + * @param list $optionalProperties + */ + public function __construct(private array $properties, private array $optionalProperties) + { + } + + /** + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * @return list + */ + public function getOptionalProperties(): array + { + return $this->optionalProperties; + } + + public function getReferencedClasses(): array + { + $classes = []; + foreach ($this->properties as $propertyType) { + foreach ($propertyType->getReferencedClasses() as $referencedClass) { + $classes[] = $referencedClass; + } + } + + return $classes; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + if (!array_key_exists($propertyName, $this->properties)) { + return TrinaryLogic::createNo(); + } + + if (in_array($propertyName, $this->optionalProperties, true)) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createYes(); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + if (!array_key_exists($propertyName, $this->properties)) { + throw new ShouldNotHappenException(); + } + + $property = new ObjectShapePropertyReflection($this->properties[$propertyName]); + return new CallbackUnresolvedPropertyPrototypeReflection( + $property, + $property->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + foreach ($type->getObjectClassReflections() as $classReflection) { + if (!UniversalObjectCratesClassReflectionExtension::isUniversalObjectCrate( + $reflectionProvider, + $classReflection, + )) { + continue; + } + + return AcceptsResult::createMaybe(); + } + + $result = AcceptsResult::createYes(); + $scope = new OutOfClassScope(); + foreach ($this->properties as $propertyName => $propertyType) { + $typeHasProperty = $type->hasProperty($propertyName); + $hasProperty = new AcceptsResult( + $typeHasProperty, + $typeHasProperty->yes() ? [] : [ + sprintf( + '%s %s have property $%s.', + $type->describe(VerbosityLevel::typeOnly()), + $typeHasProperty->no() ? 'does not' : 'might not', + $propertyName, + ), + ], + ); + if ($hasProperty->no()) { + if (in_array($propertyName, $this->optionalProperties, true)) { + continue; + } + return $hasProperty; + } + if ($hasProperty->maybe() && in_array($propertyName, $this->optionalProperties, true)) { + $hasProperty = AcceptsResult::createYes(); + } + + $result = $result->and($hasProperty); + + try { + $otherProperty = $type->getProperty($propertyName, $scope); + } catch (MissingPropertyFromReflectionException) { + return new AcceptsResult( + $result->result, + [ + sprintf( + '%s %s not have property $%s.', + $type->describe(VerbosityLevel::typeOnly()), + $result->no() ? 'does' : 'might', + $propertyName, + ), + ], + ); + } + if (!$otherProperty->isPublic()) { + return new AcceptsResult(TrinaryLogic::createNo(), [ + sprintf('Property %s::$%s is not public.', $otherProperty->getDeclaringClass()->getDisplayName(), $propertyName), + ]); + } + + if ($otherProperty->isStatic()) { + return new AcceptsResult(TrinaryLogic::createNo(), [ + sprintf('Property %s::$%s is static.', $otherProperty->getDeclaringClass()->getDisplayName(), $propertyName), + ]); + } + + if (!$otherProperty->isReadable()) { + return new AcceptsResult(TrinaryLogic::createNo(), [ + sprintf('Property %s::$%s is not readable.', $otherProperty->getDeclaringClass()->getDisplayName(), $propertyName), + ]); + } + + $otherPropertyType = $otherProperty->getReadableType(); + $verbosity = VerbosityLevel::getRecommendedLevelByType($propertyType, $otherPropertyType); + $acceptsValue = $propertyType->accepts($otherPropertyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Property ($%s) type %s does not accept type %s: %s', + $propertyName, + $propertyType->describe($verbosity), + $otherPropertyType->describe($verbosity), + $reason, + ), + ); + if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Property ($%s) type %s does not accept type %s.', + $propertyName, + $propertyType->describe($verbosity), + $otherPropertyType->describe($verbosity), + ), + ]); + } + if ($acceptsValue->no()) { + return $acceptsValue; + } + $result = $result->and($acceptsValue); + } + + return $result->and(new AcceptsResult($type->isObject(), [])); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($type instanceof ObjectWithoutClassType) { + return IsSuperTypeOfResult::createMaybe(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + foreach ($type->getObjectClassReflections() as $classReflection) { + if (!UniversalObjectCratesClassReflectionExtension::isUniversalObjectCrate( + $reflectionProvider, + $classReflection, + )) { + continue; + } + + return IsSuperTypeOfResult::createMaybe(); + } + + $result = IsSuperTypeOfResult::createYes(); + $scope = new OutOfClassScope(); + foreach ($this->properties as $propertyName => $propertyType) { + $hasProperty = new IsSuperTypeOfResult($type->hasProperty($propertyName), []); + if ($hasProperty->no()) { + if (in_array($propertyName, $this->optionalProperties, true)) { + continue; + } + return $hasProperty; + } + if ($hasProperty->maybe() && in_array($propertyName, $this->optionalProperties, true)) { + $hasProperty = IsSuperTypeOfResult::createYes(); + } + + $result = $result->and($hasProperty); + + try { + $otherProperty = $type->getProperty($propertyName, $scope); + } catch (MissingPropertyFromReflectionException) { + return $result; + } + + if (!$otherProperty->isPublic()) { + return IsSuperTypeOfResult::createNo(); + } + + if ($otherProperty->isStatic()) { + return IsSuperTypeOfResult::createNo(); + } + + if (!$otherProperty->isReadable()) { + return IsSuperTypeOfResult::createNo(); + } + + $otherPropertyType = $otherProperty->getReadableType(); + $isSuperType = $propertyType->isSuperTypeOf($otherPropertyType); + if ($isSuperType->no()) { + return $isSuperType; + } + $result = $result->and($isSuperType); + } + + return $result->and(new IsSuperTypeOfResult($type->isObject(), [])); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if (count($this->properties) !== count($type->properties)) { + return false; + } + + foreach ($this->properties as $name => $propertyType) { + if (!array_key_exists($name, $type->properties)) { + return false; + } + + if (!$propertyType->equals($type->properties[$name])) { + return false; + } + } + + if (count($this->optionalProperties) !== count($type->optionalProperties)) { + return false; + } + + foreach ($this->optionalProperties as $name) { + if (in_array($name, $type->optionalProperties, true)) { + continue; + } + + return false; + } + + return true; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof HasPropertyType) { + $properties = $this->properties; + unset($properties[$typeToRemove->getPropertyName()]); + $optionalProperties = array_values(array_filter($this->optionalProperties, static fn (string $propertyName) => $propertyName !== $typeToRemove->getPropertyName())); + + return new self($properties, $optionalProperties); + } + + return null; + } + + public function makePropertyRequired(string $propertyName): self + { + if (array_key_exists($propertyName, $this->properties)) { + $optionalProperties = array_values(array_filter($this->optionalProperties, static fn (string $currentPropertyName) => $currentPropertyName !== $propertyName)); + + return new self($this->properties, $optionalProperties); + } + + return $this; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + if ($receivedType instanceof self) { + $typeMap = TemplateTypeMap::createEmpty(); + $scope = new OutOfClassScope(); + foreach ($this->properties as $name => $propertyType) { + if ($receivedType->hasProperty($name)->no()) { + continue; + } + + try { + $receivedProperty = $receivedType->getProperty($name, $scope); + } catch (MissingPropertyFromReflectionException) { + continue; + } + if (!$receivedProperty->isPublic()) { + continue; + } + if ($receivedProperty->isStatic()) { + continue; + } + $receivedPropertyType = $receivedProperty->getReadableType(); + $typeMap = $typeMap->union($propertyType->inferTemplateTypes($receivedPropertyType)); + } + + return $typeMap; + } + + return TemplateTypeMap::createEmpty(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + $references = []; + foreach ($this->properties as $propertyType) { + foreach ($propertyType->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function describe(VerbosityLevel $level): string + { + $callback = function () use ($level): string { + $items = []; + foreach ($this->properties as $name => $propertyType) { + $optional = in_array($name, $this->optionalProperties, true); + $items[] = sprintf('%s%s: %s', $name, $optional ? '?' : '', $propertyType->describe($level)); + } + return sprintf('object{%s}', implode(', ', $items)); + }; + return $level->handle( + $callback, + $callback, + ); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + $properties = []; + $stillOriginal = true; + + foreach ($this->properties as $name => $propertyType) { + $transformed = $cb($propertyType); + if ($transformed !== $propertyType) { + $stillOriginal = false; + } + + $properties[$name] = $transformed; + } + + if ($stillOriginal) { + return $this; + } + + return new self($properties, $this->optionalProperties); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right->isObject()->yes()) { + return $this; + } + + $properties = []; + $stillOriginal = true; + + $scope = new OutOfClassScope(); + foreach ($this->properties as $name => $propertyType) { + if (!$right->hasProperty($name)->yes()) { + return $this; + } + $transformed = $cb($propertyType, $right->getProperty($name, $scope)->getReadableType()); + if ($transformed !== $propertyType) { + $stillOriginal = false; + } + + $properties[$name] = $transformed; + } + + if ($stillOriginal) { + return $this; + } + + return new self($properties, $this->optionalProperties); + } + + public function exponentiate(Type $exponent): Type + { + if (!$exponent instanceof NeverType && !$this->isSuperTypeOf($exponent)->no()) { + return TypeCombinator::union($this, $exponent); + } + + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + $items = []; + foreach ($this->properties as $name => $type) { + if (ConstantArrayType::isValidIdentifier($name)) { + $keyNode = new IdentifierTypeNode($name); + } else { + $keyPhpDocNode = (new ConstantStringType($name))->toPhpDocNode(); + if (!$keyPhpDocNode instanceof ConstTypeNode) { + continue; + } + + /** @var ConstExprStringNode $keyNode */ + $keyNode = $keyPhpDocNode->constExpr; + } + $items[] = new ObjectShapeItemNode( + $keyNode, + in_array($name, $this->optionalProperties, true), + $type->toPhpDocNode(), + ); + } + + return new ObjectShapeNode($items); + } + +} diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php new file mode 100644 index 00000000..4bbb787e --- /dev/null +++ b/src/Type/ObjectType.php @@ -0,0 +1,1621 @@ +> */ + private static array $superTypes = []; + + private ?self $cachedParent = null; + + /** @var self[]|null */ + private ?array $cachedInterfaces = null; + + /** @var array>> */ + private static array $methods = []; + + /** @var array>> */ + private static array $properties = []; + + /** @var array> */ + private static array $ancestors = []; + + /** @var array */ + private array $currentAncestors = []; + + private ?string $cachedDescription = null; + + /** @var array> */ + private static array $enumCases = []; + + /** @api */ + public function __construct( + private string $className, + ?Type $subtractedType = null, + private ?ClassReflection $classReflection = null, + ) + { + if ($subtractedType instanceof NeverType) { + $subtractedType = null; + } + + $this->subtractedType = $subtractedType; + } + + public static function resetCaches(): void + { + self::$superTypes = []; + self::$methods = []; + self::$properties = []; + self::$ancestors = []; + self::$enumCases = []; + } + + private static function createFromReflection(ClassReflection $reflection): self + { + if (!$reflection->isGeneric()) { + return new ObjectType($reflection->getName()); + } + + return new GenericObjectType( + $reflection->getName(), + $reflection->typeMapToList($reflection->getActiveTemplateTypeMap()), + null, + null, + $reflection->varianceMapToList($reflection->getCallSiteVarianceMap()), + ); + } + + public function getClassName(): string + { + return $this->className; + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return TrinaryLogic::createMaybe(); + } + + if ($classReflection->hasProperty($propertyName)) { + return TrinaryLogic::createYes(); + } + + if ($classReflection->allowsDynamicProperties()) { + return TrinaryLogic::createMaybe(); + } + + if (!$classReflection->isFinal()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + if (!$scope->isInClass()) { + $canAccessProperty = 'no'; + } else { + $canAccessProperty = $scope->getClassReflection()->getName(); + } + $description = $this->describeCache(); + + if (isset(self::$properties[$description][$propertyName][$canAccessProperty])) { + return self::$properties[$description][$propertyName][$canAccessProperty]; + } + + $nakedClassReflection = $this->getNakedClassReflection(); + if ($nakedClassReflection === null) { + throw new ClassNotFoundException($this->className); + } + + if ($nakedClassReflection->isEnum()) { + if ( + $propertyName === 'name' + || ($propertyName === 'value' && $nakedClassReflection->isBackedEnum()) + ) { + $properties = []; + foreach ($this->getEnumCases() as $enumCase) { + $properties[] = $enumCase->getUnresolvedPropertyPrototype($propertyName, $scope); + } + + if (count($properties) > 0) { + if (count($properties) === 1) { + return $properties[0]; + } + + return new UnionTypeUnresolvedPropertyPrototypeReflection($propertyName, $properties); + } + } + } + + if (!$nakedClassReflection->hasNativeProperty($propertyName)) { + $nakedClassReflection = $this->getClassReflection(); + } + + if ($nakedClassReflection === null) { + throw new ClassNotFoundException($this->className); + } + + $property = $nakedClassReflection->getProperty($propertyName, $scope); + + $ancestor = $this->getAncestorWithClassName($property->getDeclaringClass()->getName()); + $resolvedClassReflection = null; + if ($ancestor !== null && $ancestor->hasProperty($propertyName)->yes()) { + $resolvedClassReflection = $ancestor->getClassReflection(); + if ($ancestor !== $this) { + $property = $ancestor->getUnresolvedPropertyPrototype($propertyName, $scope)->getNakedProperty(); + } + } + if ($resolvedClassReflection === null) { + $resolvedClassReflection = $property->getDeclaringClass(); + } + + return self::$properties[$description][$propertyName][$canAccessProperty] = new CalledOnTypeUnresolvedPropertyPrototypeReflection( + $property, + $resolvedClassReflection, + true, + $this, + ); + } + + public function getPropertyWithoutTransformingStatic(string $propertyName, ClassMemberAccessAnswerer $scope): PropertyReflection + { + $classReflection = $this->getNakedClassReflection(); + if ($classReflection === null) { + throw new ClassNotFoundException($this->className); + } + + if (!$classReflection->hasProperty($propertyName)) { + $classReflection = $this->getClassReflection(); + } + + if ($classReflection === null) { + throw new ClassNotFoundException($this->className); + } + + return $classReflection->getProperty($propertyName, $scope); + } + + public function getReferencedClasses(): array + { + return [$this->className]; + } + + public function getObjectClassNames(): array + { + if ($this->className === '') { + return []; + } + return [$this->className]; + } + + public function getObjectClassReflections(): array + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return []; + } + + return [$classReflection]; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof StaticType) { + return $this->checkSubclassAcceptability($type->getClassName()); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if ($type instanceof ClosureType) { + return new AcceptsResult($this->isInstanceOf(Closure::class), []); + } + + if ($type instanceof ObjectWithoutClassType) { + return AcceptsResult::createMaybe(); + } + + $thatClassNames = $type->getObjectClassNames(); + if (count($thatClassNames) > 1) { + throw new ShouldNotHappenException(); + } + + if ($thatClassNames === []) { + return AcceptsResult::createNo(); + } + + return $this->checkSubclassAcceptability($thatClassNames[0]); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $thatClassNames = $type->getObjectClassNames(); + if (!$type instanceof CompoundType && $thatClassNames === [] && !$type instanceof ObjectWithoutClassType) { + return IsSuperTypeOfResult::createNo(); + } + + $thisDescription = $this->describeCache(); + + if ($type instanceof self) { + $description = $type->describeCache(); + } else { + $description = $type->describe(VerbosityLevel::cache()); + } + + if (isset(self::$superTypes[$thisDescription][$description])) { + return self::$superTypes[$thisDescription][$description]; + } + + if ($type instanceof CompoundType) { + return self::$superTypes[$thisDescription][$description] = $type->isSubTypeOf($this); + } + + if ($type instanceof ClosureType) { + return self::$superTypes[$thisDescription][$description] = new IsSuperTypeOfResult($this->isInstanceOf(Closure::class), []); + } + + if ($type instanceof ObjectWithoutClassType) { + if ($type->getSubtractedType() !== null) { + $isSuperType = $type->getSubtractedType()->isSuperTypeOf($this); + if ($isSuperType->yes()) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createNo(); + } + } + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createMaybe(); + } + + $transformResult = static fn (IsSuperTypeOfResult $result) => $result; + if ($this->subtractedType !== null) { + $isSuperType = $this->subtractedType->isSuperTypeOf($type); + if ($isSuperType->yes()) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createNo(); + } + if ($isSuperType->maybe()) { + $transformResult = static fn (IsSuperTypeOfResult $result) => $result->and(IsSuperTypeOfResult::createMaybe()); + } + } + + if ( + $type instanceof SubtractableType + && $type->getSubtractedType() !== null + ) { + $isSuperType = $type->getSubtractedType()->isSuperTypeOf($this); + if ($isSuperType->yes()) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createNo(); + } + } + + $thisClassName = $this->className; + if (count($thatClassNames) > 1) { + throw new ShouldNotHappenException(); + } + + if ($thatClassNames[0] === $thisClassName) { + return $transformResult(IsSuperTypeOfResult::createYes()); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + $thisClassReflection = $this->getClassReflection(); + + if ($thisClassReflection === null || !$reflectionProvider->hasClass($thatClassNames[0])) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createMaybe(); + } + + $thatClassReflection = $reflectionProvider->getClass($thatClassNames[0]); + + if ($thisClassReflection->isTrait() || $thatClassReflection->isTrait()) { + return IsSuperTypeOfResult::createNo(); + } + + if ($thisClassReflection->getName() === $thatClassReflection->getName()) { + return self::$superTypes[$thisDescription][$description] = $transformResult(IsSuperTypeOfResult::createYes()); + } + + if ($thatClassReflection->isSubclassOf($thisClassName)) { + return self::$superTypes[$thisDescription][$description] = $transformResult(IsSuperTypeOfResult::createYes()); + } + + if ($thisClassReflection->isSubclassOf($thatClassNames[0])) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createMaybe(); + } + + if ($thisClassReflection->isInterface() && !$thatClassReflection->getNativeReflection()->isFinal()) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createMaybe(); + } + + if ($thatClassReflection->isInterface() && !$thisClassReflection->getNativeReflection()->isFinal()) { + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createMaybe(); + } + + return self::$superTypes[$thisDescription][$description] = IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if ($type instanceof EnumCaseObjectType) { + return false; + } + + if ($this->className !== $type->className) { + return false; + } + + if ($this->subtractedType === null) { + return $type->subtractedType === null; + } + + if ($type->subtractedType === null) { + return false; + } + + return $this->subtractedType->equals($type->subtractedType); + } + + private function checkSubclassAcceptability(string $thatClass): AcceptsResult + { + if ($this->className === $thatClass) { + return AcceptsResult::createYes(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + + if ($this->getClassReflection() === null || !$reflectionProvider->hasClass($thatClass)) { + return AcceptsResult::createNo(); + } + + $thisReflection = $this->getClassReflection(); + $thatReflection = $reflectionProvider->getClass($thatClass); + + if ($thisReflection->getName() === $thatReflection->getName()) { + // class alias + return AcceptsResult::createYes(); + } + + if ($thisReflection->isInterface() && $thatReflection->isInterface()) { + return AcceptsResult::createFromBoolean( + $thatReflection->implementsInterface($thisReflection->getName()), + ); + } + + return AcceptsResult::createFromBoolean( + $thatReflection->isSubclassOf($thisReflection->getName()), + ); + } + + public function describe(VerbosityLevel $level): string + { + $preciseNameCallback = function (): string { + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($this->className)) { + return $this->className; + } + + return $reflectionProvider->getClassName($this->className); + }; + + $preciseWithSubtracted = function () use ($level): string { + $description = $this->className; + if ($this->subtractedType !== null) { + $description .= $this->subtractedType instanceof UnionType + ? sprintf('~(%s)', $this->subtractedType->describe($level)) + : sprintf('~%s', $this->subtractedType->describe($level)); + } + + return $description; + }; + + return $level->handle( + $preciseNameCallback, + $preciseNameCallback, + $preciseWithSubtracted, + function () use ($preciseWithSubtracted): string { + $reflection = $this->classReflection; + $line = ''; + if ($reflection !== null) { + $line .= '-'; + $line .= (string) $reflection->getNativeReflection()->getStartLine(); + $line .= '-'; + } + + return $preciseWithSubtracted() . '-' . static::class . '-' . $line . $this->describeAdditionalCacheKey(); + }, + ); + } + + protected function describeAdditionalCacheKey(): string + { + return ''; + } + + private function describeCache(): string + { + if ($this->cachedDescription !== null) { + return $this->cachedDescription; + } + + if (static::class !== self::class) { + return $this->cachedDescription = $this->describe(VerbosityLevel::cache()); + } + + $description = $this->className; + + if ($this instanceof GenericObjectType) { + $description .= '<'; + $typeDescriptions = []; + foreach ($this->getTypes() as $type) { + $typeDescriptions[] = $type->describe(VerbosityLevel::cache()); + } + $description .= '<' . implode(', ', $typeDescriptions) . '>'; + } + + if ($this->subtractedType !== null) { + $description .= $this->subtractedType instanceof UnionType + ? sprintf('~(%s)', $this->subtractedType->describe(VerbosityLevel::cache())) + : sprintf('~%s', $this->subtractedType->describe(VerbosityLevel::cache())); + } + + $reflection = $this->classReflection; + if ($reflection !== null) { + $description .= '-'; + $description .= (string) $reflection->getNativeReflection()->getStartLine(); + $description .= '-'; + } + + return $this->cachedDescription = $description; + } + + public function toNumber(): Type + { + if ($this->isInstanceOf('SimpleXMLElement')->yes()) { + return new UnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toInteger(): Type + { + if ($this->isInstanceOf('SimpleXMLElement')->yes()) { + return new IntegerType(); + } + + if (in_array($this->getClassName(), ['CurlHandle', 'CurlMultiHandle'], true)) { + return new IntegerType(); + } + + return new ErrorType(); + } + + public function toFloat(): Type + { + if ($this->isInstanceOf('SimpleXMLElement')->yes()) { + return new FloatType(); + } + return new ErrorType(); + } + + public function toString(): Type + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return new ErrorType(); + } + + if ($classReflection->hasNativeMethod('__toString')) { + return $this->getMethod('__toString', new OutOfClassScope())->getOnlyVariant()->getReturnType(); + } + + return new ErrorType(); + } + + public function toArray(): Type + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return new ArrayType(new MixedType(), new MixedType()); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + + if ( + !$classReflection->getNativeReflection()->isUserDefined() + || $classReflection->is(ArrayObject::class) + || UniversalObjectCratesClassReflectionExtension::isUniversalObjectCrate( + $reflectionProvider, + $classReflection, + ) + ) { + return new ArrayType(new MixedType(), new MixedType()); + } + $arrayKeys = []; + $arrayValues = []; + + $isFinal = $classReflection->isFinal(); + + do { + foreach ($classReflection->getNativeReflection()->getProperties() as $nativeProperty) { + if ($nativeProperty->isStatic()) { + continue; + } + + $declaringClass = $reflectionProvider->getClass($nativeProperty->getDeclaringClass()->getName()); + $property = $declaringClass->getNativeProperty($nativeProperty->getName()); + + $keyName = $nativeProperty->getName(); + if ($nativeProperty->isPrivate()) { + $keyName = sprintf( + "\0%s\0%s", + $declaringClass->getName(), + $keyName, + ); + } elseif ($nativeProperty->isProtected()) { + $keyName = sprintf( + "\0*\0%s", + $keyName, + ); + } + + $arrayKeys[] = new ConstantStringType($keyName); + $arrayValues[] = $property->getReadableType(); + } + + $classReflection = $classReflection->getParentClass(); + } while ($classReflection !== null); + + if (!$isFinal && count($arrayKeys) === 0) { + return new ArrayType(new MixedType(), new MixedType()); + } + + return new ConstantArrayType($arrayKeys, $arrayValues); + } + + public function toArrayKey(): Type + { + return $this->toString(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function toBoolean(): BooleanType + { + if ($this->isInstanceOf('SimpleXMLElement')->yes()) { + return new BooleanType(); + } + + return new ConstantBooleanType(true); + } + + public function isObject(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isEnum(): TrinaryLogic + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($classReflection->isEnum()); + } + + public function canAccessProperties(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function canCallMethods(): TrinaryLogic + { + if (strtolower($this->className) === 'stdclass') { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createYes(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return TrinaryLogic::createMaybe(); + } + + if ($classReflection->hasMethod($methodName)) { + return TrinaryLogic::createYes(); + } + + if ($classReflection->isFinal()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + if (!$scope->isInClass()) { + $canCallMethod = 'no'; + } else { + $canCallMethod = $scope->getClassReflection()->getName(); + } + $description = $this->describeCache(); + if (isset(self::$methods[$description][$methodName][$canCallMethod])) { + return self::$methods[$description][$methodName][$canCallMethod]; + } + + $nakedClassReflection = $this->getNakedClassReflection(); + if ($nakedClassReflection === null) { + throw new ClassNotFoundException($this->className); + } + + if (!$nakedClassReflection->hasNativeMethod($methodName)) { + $nakedClassReflection = $this->getClassReflection(); + } + + if ($nakedClassReflection === null) { + throw new ClassNotFoundException($this->className); + } + + $method = $nakedClassReflection->getMethod($methodName, $scope); + + $ancestor = $this->getAncestorWithClassName($method->getDeclaringClass()->getName()); + $resolvedClassReflection = null; + if ($ancestor !== null) { + $resolvedClassReflection = $ancestor->getClassReflection(); + if ($ancestor !== $this) { + $method = $ancestor->getUnresolvedMethodPrototype($methodName, $scope)->getNakedMethod(); + } + } + if ($resolvedClassReflection === null) { + $resolvedClassReflection = $method->getDeclaringClass(); + } + + return self::$methods[$description][$methodName][$canCallMethod] = new CalledOnTypeUnresolvedMethodPrototypeReflection( + $method, + $resolvedClassReflection, + true, + $this, + ); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + $class = $this->getClassReflection(); + if ($class === null) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createFromBoolean( + $class->hasConstant($constantName), + ); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + $class = $this->getClassReflection(); + if ($class === null) { + throw new ClassNotFoundException($this->className); + } + + return $class->getConstant($constantName); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return new ErrorType(); + } + + $ancestorClassReflection = $classReflection->getAncestorWithClassName($ancestorClassName); + if ($ancestorClassReflection === null) { + return new ErrorType(); + } + + $activeTemplateTypeMap = $ancestorClassReflection->getPossiblyIncompleteActiveTemplateTypeMap(); + $type = $activeTemplateTypeMap->getType($templateTypeName); + if ($type === null) { + return new ErrorType(); + } + if ($type instanceof ErrorType) { + $templateTypeMap = $ancestorClassReflection->getTemplateTypeMap(); + $templateType = $templateTypeMap->getType($templateTypeName); + if ($templateType === null) { + return $type; + } + + $bound = TemplateTypeHelper::resolveToBounds($templateType); + if ($bound instanceof MixedType && $bound->isExplicitMixed()) { + return new MixedType(false); + } + + return TemplateTypeHelper::resolveToDefaults($templateType); + } + + return $type; + } + + public function getConstantStrings(): array + { + return []; + } + + public function isIterable(): TrinaryLogic + { + return $this->isInstanceOf(Traversable::class); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->isInstanceOf(Traversable::class) + ->and(TrinaryLogic::createMaybe()); + } + + public function getArraySize(): Type + { + if ($this->isInstanceOf(Countable::class)->no()) { + return new ErrorType(); + } + + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + $isTraversable = false; + if ($this->isInstanceOf(IteratorAggregate::class)->yes()) { + $keyType = RecursionGuard::run($this, fn (): Type => $this->getMethod('getIterator', new OutOfClassScope())->getOnlyVariant()->getReturnType()->getIterableKeyType()); + $isTraversable = true; + if (!$keyType instanceof MixedType || $keyType->isExplicitMixed()) { + return $keyType; + } + } + + $extraOffsetAccessible = $this->isExtraOffsetAccessibleClass()->yes(); + if (!$extraOffsetAccessible && $this->isInstanceOf(Traversable::class)->yes()) { + $isTraversable = true; + $tKey = $this->getTemplateType(Traversable::class, 'TKey'); + if (!$tKey instanceof ErrorType) { + if (!$tKey instanceof MixedType || $tKey->isExplicitMixed()) { + return $tKey; + } + } + } + + if ($this->isInstanceOf(Iterator::class)->yes()) { + return RecursionGuard::run($this, fn (): Type => $this->getMethod('key', new OutOfClassScope())->getOnlyVariant()->getReturnType()); + } + + if ($extraOffsetAccessible) { + return new MixedType(true); + } + + if ($isTraversable) { + return new MixedType(); + } + + return new ErrorType(); + } + + public function getFirstIterableKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getLastIterableKeyType(): Type + { + return $this->getIterableKeyType(); + } + + public function getIterableValueType(): Type + { + $isTraversable = false; + if ($this->isInstanceOf(IteratorAggregate::class)->yes()) { + $valueType = RecursionGuard::run($this, fn (): Type => $this->getMethod('getIterator', new OutOfClassScope())->getOnlyVariant()->getReturnType()->getIterableValueType()); + $isTraversable = true; + if (!$valueType instanceof MixedType || $valueType->isExplicitMixed()) { + return $valueType; + } + } + + $extraOffsetAccessible = $this->isExtraOffsetAccessibleClass()->yes(); + if (!$extraOffsetAccessible && $this->isInstanceOf(Traversable::class)->yes()) { + $isTraversable = true; + $tValue = $this->getTemplateType(Traversable::class, 'TValue'); + if (!$tValue instanceof ErrorType) { + if (!$tValue instanceof MixedType || $tValue->isExplicitMixed()) { + return $tValue; + } + } + } + + if ($this->isInstanceOf(Iterator::class)->yes()) { + return RecursionGuard::run($this, fn (): Type => $this->getMethod('current', new OutOfClassScope())->getOnlyVariant()->getReturnType()); + } + + if ($extraOffsetAccessible) { + return new MixedType(true); + } + + if ($isTraversable) { + return new MixedType(); + } + + return new ErrorType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->getIterableValueType(); + } + + public function getLastIterableValueType(): Type + { + return $this->getIterableValueType(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this; + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + return $type->isFalse()->yes() + ? new ConstantBooleanType(false) + : new BooleanType(); + } + + private function isExtraOffsetAccessibleClass(): TrinaryLogic + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return TrinaryLogic::createMaybe(); + } + + foreach (self::EXTRA_OFFSET_CLASSES as $extraOffsetClass) { + if ($classReflection->getName() === $extraOffsetClass) { + return TrinaryLogic::createYes(); + } + if ($classReflection->isSubclassOf($extraOffsetClass)) { + return TrinaryLogic::createYes(); + } + } + + if ($classReflection->isInterface()) { + return TrinaryLogic::createMaybe(); + } + + if ($classReflection->isFinal()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->isInstanceOf(ArrayAccess::class)->or( + $this->isExtraOffsetAccessibleClass(), + ); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->isOffsetAccessible(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + if ($this->isInstanceOf(ArrayAccess::class)->yes()) { + $acceptedOffsetType = RecursionGuard::run($this, function (): Type { + $parameters = $this->getMethod('offsetSet', new OutOfClassScope())->getOnlyVariant()->getParameters(); + if (count($parameters) < 2) { + throw new ShouldNotHappenException(sprintf( + 'Method %s::%s() has less than 2 parameters.', + $this->className, + 'offsetSet', + )); + } + + $offsetParameter = $parameters[0]; + + return $offsetParameter->getType(); + }); + + if ($acceptedOffsetType->isSuperTypeOf($offsetType)->no()) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + return $this->isExtraOffsetAccessibleClass() + ->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if (!$this->isExtraOffsetAccessibleClass()->no()) { + return new MixedType(); + } + + if ($this->isInstanceOf(ArrayAccess::class)->yes()) { + return RecursionGuard::run($this, fn (): Type => $this->getMethod('offsetGet', new OutOfClassScope())->getOnlyVariant()->getReturnType()); + } + + return new ErrorType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($this->isOffsetAccessible()->no()) { + return new ErrorType(); + } + + if ($this->isInstanceOf(ArrayAccess::class)->yes()) { + $acceptedValueType = new NeverType(); + $acceptedOffsetType = RecursionGuard::run($this, function () use (&$acceptedValueType): Type { + $parameters = $this->getMethod('offsetSet', new OutOfClassScope())->getOnlyVariant()->getParameters(); + if (count($parameters) < 2) { + throw new ShouldNotHappenException(sprintf( + 'Method %s::%s() has less than 2 parameters.', + $this->className, + 'offsetSet', + )); + } + + $offsetParameter = $parameters[0]; + $acceptedValueType = $parameters[1]->getType(); + + return $offsetParameter->getType(); + }); + + if ($offsetType === null) { + $offsetType = new NullType(); + } + + if ( + (!$offsetType instanceof MixedType && !$acceptedOffsetType->isSuperTypeOf($offsetType)->yes()) + || (!$valueType instanceof MixedType && !$acceptedValueType->isSuperTypeOf($valueType)->yes()) + ) { + return new ErrorType(); + } + } + + // in the future we may return intersection of $this and OffsetAccessibleType() + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + if ($this->isOffsetAccessible()->no()) { + return new ErrorType(); + } + + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + if ($this->isOffsetAccessible()->no()) { + return new ErrorType(); + } + + return $this; + } + + public function getEnumCases(): array + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return []; + } + + if (!$classReflection->isEnum()) { + return []; + } + + $cacheKey = $this->describeCache(); + if (array_key_exists($cacheKey, self::$enumCases)) { + return self::$enumCases[$cacheKey]; + } + + $className = $classReflection->getName(); + + if ($this->subtractedType !== null) { + $subtractedEnumCaseNames = []; + + foreach ($this->subtractedType->getEnumCases() as $subtractedCase) { + $subtractedEnumCaseNames[$subtractedCase->getEnumCaseName()] = true; + } + + $cases = []; + foreach ($classReflection->getEnumCases() as $enumCase) { + if (array_key_exists($enumCase->getName(), $subtractedEnumCaseNames)) { + continue; + } + $cases[] = new EnumCaseObjectType($className, $enumCase->getName(), $classReflection); + } + } else { + $cases = []; + foreach ($classReflection->getEnumCases() as $enumCase) { + $cases[] = new EnumCaseObjectType($className, $enumCase->getName(), $classReflection); + } + } + + return self::$enumCases[$cacheKey] = $cases; + } + + public function isCallable(): TrinaryLogic + { + $parametersAcceptors = RecursionGuard::run($this, fn () => $this->findCallableParametersAcceptors()); + if ($parametersAcceptors === null) { + return TrinaryLogic::createNo(); + } + if ($parametersAcceptors instanceof ErrorType) { + return TrinaryLogic::createNo(); + } + + if ( + count($parametersAcceptors) === 1 + && $parametersAcceptors[0] instanceof TrivialParametersAcceptor + ) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createYes(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + if ($this->className === Closure::class) { + return [new TrivialParametersAcceptor('Closure')]; + } + $parametersAcceptors = $this->findCallableParametersAcceptors(); + if ($parametersAcceptors === null) { + throw new ShouldNotHappenException(); + } + + return $parametersAcceptors; + } + + /** + * @return CallableParametersAcceptor[]|null + */ + private function findCallableParametersAcceptors(): ?array + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return [new TrivialParametersAcceptor()]; + } + + if ($classReflection->hasNativeMethod('__invoke')) { + $method = $this->getMethod('__invoke', new OutOfClassScope()); + return FunctionCallableVariant::createFromVariants( + $method, + $method->getVariants(), + ); + } + + if (!$classReflection->getNativeReflection()->isFinal()) { + return [new TrivialParametersAcceptor()]; + } + + return null; + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isInstanceOf(string $className): TrinaryLogic + { + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return TrinaryLogic::createMaybe(); + } + + if ($classReflection->getName() === $className || $classReflection->isSubclassOf($className)) { + return TrinaryLogic::createYes(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if ($reflectionProvider->hasClass($className)) { + $thatClassReflection = $reflectionProvider->getClass($className); + if ($thatClassReflection->isFinal()) { + return TrinaryLogic::createNo(); + } + } + + if ($classReflection->isInterface()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); + } + + public function subtract(Type $type): Type + { + if ($this->subtractedType !== null) { + $type = TypeCombinator::union($this->subtractedType, $type); + } + + return $this->changeSubtractedType($type); + } + + public function getTypeWithoutSubtractedType(): Type + { + return $this->changeSubtractedType(null); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + if ($subtractedType !== null) { + $classReflection = $this->getClassReflection(); + $allowedSubTypes = $classReflection !== null ? $classReflection->getAllowedSubTypes() : null; + if ($allowedSubTypes !== null) { + $preciseVerbosity = VerbosityLevel::precise(); + + $originalAllowedSubTypes = $allowedSubTypes; + $subtractedSubTypes = []; + + $subtractedTypes = TypeUtils::flattenTypes($subtractedType); + foreach ($subtractedTypes as $subType) { + foreach ($allowedSubTypes as $key => $allowedSubType) { + if ($subType->equals($allowedSubType)) { + $description = $allowedSubType->describe($preciseVerbosity); + $subtractedSubTypes[$description] = $subType; + unset($allowedSubTypes[$key]); + continue 2; + } + } + + return new self($this->className, $subtractedType); + } + + if (count($allowedSubTypes) === 1) { + return array_values($allowedSubTypes)[0]; + } + + $subtractedSubTypes = array_values($subtractedSubTypes); + $subtractedSubTypesCount = count($subtractedSubTypes); + if ($subtractedSubTypesCount === count($originalAllowedSubTypes)) { + return new NeverType(); + } + + if ($subtractedSubTypesCount === 0) { + return new self($this->className); + } + + if ($subtractedSubTypesCount === 1) { + return new self($this->className, $subtractedSubTypes[0]); + } + + return new self($this->className, new UnionType($subtractedSubTypes)); + } + } + + if ($this->subtractedType === null && $subtractedType === null) { + return $this; + } + + return new self($this->className, $subtractedType); + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->subtractedType !== null ? $cb($this->subtractedType) : null; + + if ($subtractedType !== $this->subtractedType) { + return new self( + $this->className, + $subtractedType, + ); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->subtractedType === null) { + return $this; + } + + return new self($this->className); + } + + public function getNakedClassReflection(): ?ClassReflection + { + if ($this->classReflection !== null) { + return $this->classReflection; + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($this->className)) { + return null; + } + + return $reflectionProvider->getClass($this->className); + } + + public function getClassReflection(): ?ClassReflection + { + if ($this->classReflection !== null) { + return $this->classReflection; + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($this->className)) { + return null; + } + + $classReflection = $reflectionProvider->getClass($this->className); + if ($classReflection->isGeneric()) { + return $classReflection->withTypes(array_values($classReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes())); + } + + return $classReflection; + } + + public function getAncestorWithClassName(string $className): ?self + { + if ($this->className === $className) { + return $this; + } + + if ($this->classReflection !== null && $className === $this->classReflection->getName()) { + return $this; + } + + if (array_key_exists($className, $this->currentAncestors)) { + return $this->currentAncestors[$className]; + } + + $description = $this->describeCache(); + if ( + array_key_exists($description, self::$ancestors) + && array_key_exists($className, self::$ancestors[$description]) + ) { + return self::$ancestors[$description][$className]; + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($className)) { + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = null; + } + $theirReflection = $reflectionProvider->getClass($className); + + $thisReflection = $this->getClassReflection(); + if ($thisReflection === null) { + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = null; + } + if ($theirReflection->getName() === $thisReflection->getName()) { + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $this; + } + + foreach ($this->getInterfaces() as $interface) { + $ancestor = $interface->getAncestorWithClassName($className); + if ($ancestor !== null) { + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $ancestor; + } + } + + $parent = $this->getParent(); + if ($parent !== null) { + $ancestor = $parent->getAncestorWithClassName($className); + if ($ancestor !== null) { + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $ancestor; + } + } + + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = null; + } + + private function getParent(): ?ObjectType + { + if ($this->cachedParent !== null) { + return $this->cachedParent; + } + $thisReflection = $this->getClassReflection(); + if ($thisReflection === null) { + return null; + } + + $parentReflection = $thisReflection->getParentClass(); + if ($parentReflection === null) { + return null; + } + + return $this->cachedParent = self::createFromReflection($parentReflection); + } + + /** @return ObjectType[] */ + private function getInterfaces(): array + { + if ($this->cachedInterfaces !== null) { + return $this->cachedInterfaces; + } + $thisReflection = $this->getClassReflection(); + if ($thisReflection === null) { + return $this->cachedInterfaces = []; + } + + return $this->cachedInterfaces = array_map(static fn (ClassReflection $interfaceReflection): self => self::createFromReflection($interfaceReflection), $thisReflection->getInterfaces()); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ObjectType) { + foreach (UnionType::EQUAL_UNION_CLASSES as $baseClass => $classes) { + if ($this->getClassName() !== $baseClass) { + continue; + } + + foreach ($classes as $index => $class) { + if ($typeToRemove->getClassName() === $class) { + unset($classes[$index]); + + return TypeCombinator::union( + ...array_map(static fn (string $objectClass): Type => new ObjectType($objectClass), $classes), + ); + } + } + } + } + + if ($this->isSuperTypeOf($typeToRemove)->yes()) { + return $this->subtract($typeToRemove); + } + + return null; + } + + public function getFiniteTypes(): array + { + return $this->getEnumCases(); + } + + public function exponentiate(Type $exponent): Type + { + $object = new ObjectWithoutClassType(); + if (!$exponent instanceof NeverType && !$object->isSuperTypeOf($this)->no() && !$object->isSuperTypeOf($exponent)->no()) { + return TypeCombinator::union($this, $exponent); + } + return new ErrorType(); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode($this->getClassName()); + } + +} diff --git a/src/Type/ObjectWithoutClassType.php b/src/Type/ObjectWithoutClassType.php new file mode 100644 index 00000000..59649ddf --- /dev/null +++ b/src/Type/ObjectWithoutClassType.php @@ -0,0 +1,226 @@ +subtractedType = $subtractedType; + } + + public function getReferencedClasses(): array + { + return []; + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return AcceptsResult::createFromBoolean( + $type instanceof self || $type instanceof ObjectShapeType || $type->getObjectClassNames() !== [], + ); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($type instanceof self) { + if ($this->subtractedType === null) { + return IsSuperTypeOfResult::createYes(); + } + if ($type->subtractedType !== null) { + $isSuperType = $type->subtractedType->isSuperTypeOf($this->subtractedType); + if ($isSuperType->yes()) { + return $isSuperType; + } + } + + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof ObjectShapeType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type->getObjectClassNames() === []) { + return IsSuperTypeOfResult::createNo(); + } + + if ($this->subtractedType === null) { + return IsSuperTypeOfResult::createYes(); + } + + return $this->subtractedType->isSuperTypeOf($type)->negate(); + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if ($this->subtractedType === null) { + if ($type->subtractedType === null) { + return true; + } + + return false; + } + + if ($type->subtractedType === null) { + return false; + } + + return $this->subtractedType->equals($type->subtractedType); + } + + public function describe(VerbosityLevel $level): string + { + return $level->handle( + static fn (): string => 'object', + static fn (): string => 'object', + function () use ($level): string { + $description = 'object'; + if ($this->subtractedType !== null) { + $description .= $this->subtractedType instanceof UnionType + ? sprintf('~(%s)', $this->subtractedType->describe($level)) + : sprintf('~%s', $this->subtractedType->describe($level)); + } + + return $description; + }, + ); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getEnumCases(): array + { + return []; + } + + public function subtract(Type $type): Type + { + if ($type instanceof self) { + return new NeverType(); + } + if ($this->subtractedType !== null) { + $type = TypeCombinator::union($this->subtractedType, $type); + } + + return new self($type); + } + + public function getTypeWithoutSubtractedType(): Type + { + return new self(); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + return new self($subtractedType); + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->subtractedType !== null ? $cb($this->subtractedType) : null; + + if ($subtractedType !== $this->subtractedType) { + return new self($subtractedType); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->subtractedType === null) { + return $this; + } + + return new self(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($this->isSuperTypeOf($typeToRemove)->yes()) { + return $this->subtract($typeToRemove); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + if (!$exponent instanceof NeverType && !$this->isSuperTypeOf($exponent)->no()) { + return TypeCombinator::union($this, $exponent); + } + + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('object'); + } + +} diff --git a/src/Type/OffsetAccessType.php b/src/Type/OffsetAccessType.php new file mode 100644 index 00000000..75477b54 --- /dev/null +++ b/src/Type/OffsetAccessType.php @@ -0,0 +1,118 @@ +type->getReferencedClasses(), + $this->offset->getReferencedClasses(), + ); + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return array_merge( + $this->type->getReferencedTemplateTypes($positionVariance), + $this->offset->getReferencedTemplateTypes($positionVariance), + ); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type) + && $this->offset->equals($type->offset); + } + + public function describe(VerbosityLevel $level): string + { + $printer = new Printer(); + + return $printer->print($this->toPhpDocNode()); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type) + && !TypeUtils::containsTemplateType($this->offset); + } + + protected function getResult(): Type + { + return $this->type->getOffsetValueType($this->offset); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + $offset = $cb($this->offset); + + if ($this->type === $type && $this->offset === $offset) { + return $this; + } + + return new self($type, $offset); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + $offset = $cb($this->offset, $right->offset); + + if ($this->type === $type && $this->offset === $offset) { + return $this; + } + + return new self($type, $offset); + } + + public function toPhpDocNode(): TypeNode + { + return new OffsetAccessTypeNode( + $this->type->toPhpDocNode(), + $this->offset->toPhpDocNode(), + ); + } + +} diff --git a/src/Type/OperatorTypeSpecifyingExtension.php b/src/Type/OperatorTypeSpecifyingExtension.php new file mode 100644 index 00000000..fed0c749 --- /dev/null +++ b/src/Type/OperatorTypeSpecifyingExtension.php @@ -0,0 +1,32 @@ +extensions, static fn (OperatorTypeSpecifyingExtension $extension): bool => $extension->isOperatorSupported($operator, $leftType, $rightType))); + } + +} diff --git a/src/Type/ParserNodeTypeToPHPStanType.php b/src/Type/ParserNodeTypeToPHPStanType.php new file mode 100644 index 00000000..eb3a5bca --- /dev/null +++ b/src/Type/ParserNodeTypeToPHPStanType.php @@ -0,0 +1,103 @@ +getName(); + } elseif ( + $lowercasedClassName === 'parent' + && $classReflection !== null + && $classReflection->getParentClass() !== null + ) { + $typeClassName = $classReflection->getParentClass()->getName(); + } + + return new ObjectType($typeClassName); + } elseif ($type instanceof NullableType) { + return TypeCombinator::addNull(self::resolve($type->type, $classReflection)); + } elseif ($type instanceof Node\UnionType) { + $types = []; + foreach ($type->types as $unionTypeType) { + $types[] = self::resolve($unionTypeType, $classReflection); + } + + return TypeCombinator::union(...$types); + } elseif ($type instanceof Node\IntersectionType) { + $types = []; + foreach ($type->types as $intersectionTypeType) { + $innerType = self::resolve($intersectionTypeType, $classReflection); + if (!$innerType->isObject()->yes()) { + return new NeverType(); + } + + $types[] = $innerType; + } + + return TypeCombinator::intersect(...$types); + } elseif (!$type instanceof Identifier) { + throw new ShouldNotHappenException(get_class($type)); + } + + $type = $type->name; + if ($type === 'string') { + return new StringType(); + } elseif ($type === 'int') { + return new IntegerType(); + } elseif ($type === 'bool') { + return new BooleanType(); + } elseif ($type === 'float') { + return new FloatType(); + } elseif ($type === 'callable') { + return new CallableType(); + } elseif ($type === 'array') { + return new ArrayType(new MixedType(), new MixedType()); + } elseif ($type === 'iterable') { + return new IterableType(new MixedType(), new MixedType()); + } elseif ($type === 'void') { + return new VoidType(); + } elseif ($type === 'object') { + return new ObjectWithoutClassType(); + } elseif ($type === 'true') { + return new ConstantBooleanType(true); + } elseif ($type === 'false') { + return new ConstantBooleanType(false); + } elseif ($type === 'null') { + return new NullType(); + } elseif ($type === 'mixed') { + return new MixedType(true); + } elseif ($type === 'never') { + return new NonAcceptingNeverType(); + } + + return new MixedType(); + } + +} diff --git a/src/Type/Php/AbsFunctionDynamicReturnTypeExtension.php b/src/Type/Php/AbsFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..0e37620c --- /dev/null +++ b/src/Type/Php/AbsFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,44 @@ +getName() === 'abs'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + + if (!isset($args[0])) { + return null; + } + + $inputType = $scope->getType($args[0]->value); + + $outputType = $inputType->toAbsoluteNumber(); + + if ($outputType instanceof ErrorType) { + return null; + } + + return $outputType; + } + +} diff --git a/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php b/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php new file mode 100644 index 00000000..c7f7de1d --- /dev/null +++ b/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php @@ -0,0 +1,72 @@ + 0, + 'array_diff_assoc' => 0, + 'array_diff_key' => 0, + 'array_diff_uassoc' => 0, + 'array_diff_ukey' => 0, + 'array_diff' => 0, + 'array_udiff_assoc' => 0, + 'array_udiff_uassoc' => 0, + 'array_udiff' => 0, + 'array_intersect_assoc' => 0, + 'array_intersect_uassoc' => 0, + 'array_intersect_ukey' => 0, + 'array_intersect' => 0, + 'array_uintersect_assoc' => 0, + 'array_uintersect_uassoc' => 0, + 'array_uintersect' => 0, + ]; + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return array_key_exists($functionReflection->getName(), self::FUNCTION_NAMES); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $argumentPosition = self::FUNCTION_NAMES[$functionReflection->getName()]; + + if (!isset($functionCall->getArgs()[$argumentPosition])) { + return null; + } + + $argument = $functionCall->getArgs()[$argumentPosition]; + $argumentType = $scope->getType($argument->value); + $argumentKeyType = $argumentType->getIterableKeyType(); + $argumentValueType = $argumentType->getIterableValueType(); + if ($argument->unpack) { + $argumentKeyType = $argumentKeyType->generalize(GeneralizePrecision::moreSpecific()); + $argumentValueType = $argumentValueType->getIterableValueType()->generalize(GeneralizePrecision::moreSpecific()); + } + + $array = new ArrayType( + $argumentKeyType, + $argumentValueType, + ); + if ($functionReflection->getName() === 'array_unique' && $argumentType->isIterableAtLeastOnce()->yes()) { + $array = TypeCombinator::intersect($array, new NonEmptyArrayType()); + } + + return $array; + } + +} diff --git a/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php new file mode 100644 index 00000000..04ba6514 --- /dev/null +++ b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php @@ -0,0 +1,160 @@ +getName() === 'array_change_key_case'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (!isset($functionCall->getArgs()[1])) { + $case = CASE_LOWER; + } else { + $caseType = $scope->getType($functionCall->getArgs()[1]->value); + $scalarValues = $caseType->getConstantScalarValues(); + if (count($scalarValues) === 1) { + $case = (int) $scalarValues[0]; + } else { + $case = null; + } + } + + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $arrayTypes = []; + foreach ($constantArrays as $constantArray) { + $newConstantArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $valueType = $valueTypes[$i]; + + $constantStrings = $keyType->getConstantStrings(); + if (count($constantStrings) > 0) { + $keyType = TypeCombinator::union( + ...array_map( + fn (ConstantStringType $type): Type => $this->mapConstantString($type, $case), + $constantStrings, + ), + ); + } + + $newConstantArrayBuilder->setOffsetValueType( + $keyType, + $valueType, + $constantArray->isOptionalKey($i), + ); + } + $newConstantArrayType = $newConstantArrayBuilder->getArray(); + if ($constantArray->isList()->yes()) { + $newConstantArrayType = TypeCombinator::intersect($newConstantArrayType, new AccessoryArrayListType()); + } + $arrayTypes[] = $newConstantArrayType; + } + + $newArrayType = TypeCombinator::union(...$arrayTypes); + } else { + $keysType = $arrayType->getIterableKeyType(); + + $keysType = TypeTraverser::map($keysType, function (Type $type, callable $traverse) use ($case): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + $constantStrings = $type->getConstantStrings(); + if (count($constantStrings) > 0) { + return TypeCombinator::union( + ...array_map( + fn (ConstantStringType $type): Type => $this->mapConstantString($type, $case), + $constantStrings, + ), + ); + } + + if ($type->isString()->yes()) { + $types = [new StringType()]; + if ($type->isNonFalsyString()->yes()) { + $types[] = new AccessoryNonFalsyStringType(); + } elseif ($type->isNonEmptyString()->yes()) { + $types[] = new AccessoryNonEmptyStringType(); + } + if ($type->isNumericString()->yes()) { + $types[] = new AccessoryNumericStringType(); + } + if ($case === CASE_LOWER) { + $types[] = new AccessoryLowercaseStringType(); + } elseif ($case === CASE_UPPER) { + $types[] = new AccessoryUppercaseStringType(); + } + + return TypeCombinator::intersect(...$types); + } + + return $type; + }); + + $newArrayType = TypeCombinator::intersect(new ArrayType( + $keysType, + $arrayType->getIterableValueType(), + ), ...TypeUtils::getAccessoryTypes($arrayType)); + } + + if ($arrayType->isIterableAtLeastOnce()->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); + } + + return $newArrayType; + } + + private function mapConstantString(ConstantStringType $type, ?int $case): Type + { + if ($case === CASE_LOWER) { + return new ConstantStringType(strtolower($type->getValue())); + } elseif ($case === CASE_UPPER) { + return new ConstantStringType(strtoupper($type->getValue())); + } + + return TypeCombinator::union( + new ConstantStringType(strtolower($type->getValue())), + new ConstantStringType(strtoupper($type->getValue())), + ); + } + +} diff --git a/src/Type/Php/ArrayChunkFunctionReturnTypeExtension.php b/src/Type/Php/ArrayChunkFunctionReturnTypeExtension.php new file mode 100644 index 00000000..0cb3b43b --- /dev/null +++ b/src/Type/Php/ArrayChunkFunctionReturnTypeExtension.php @@ -0,0 +1,52 @@ +getName() === 'array_chunk'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if ($arrayType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + $lengthType = $scope->getType($functionCall->getArgs()[1]->value); + $negativeOrZero = IntegerRangeType::fromInterval(null, 0); + if ($negativeOrZero->isSuperTypeOf($lengthType)->yes()) { + return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType(); + } + + $preserveKeysType = isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : new ConstantBooleanType(false); + + return $arrayType->chunkArray($lengthType, $preserveKeysType->isTrue()); + } + +} diff --git a/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php b/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php new file mode 100644 index 00000000..e59fbd1c --- /dev/null +++ b/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php @@ -0,0 +1,213 @@ +getName() === 'array_column'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $numArgs = count($functionCall->getArgs()); + if ($numArgs < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + $columnType = $scope->getType($functionCall->getArgs()[1]->value); + $indexType = $numArgs >= 3 ? $scope->getType($functionCall->getArgs()[2]->value) : null; + + $constantArrayTypes = $arrayType->getConstantArrays(); + if (count($constantArrayTypes) === 1) { + $type = $this->handleConstantArray($constantArrayTypes[0], $columnType, $indexType, $scope); + if ($type !== null) { + return $type; + } + } + + return $this->handleAnyArray($arrayType, $columnType, $indexType, $scope); + } + + private function handleAnyArray(Type $arrayType, Type $columnType, ?Type $indexType, Scope $scope): Type + { + $iterableAtLeastOnce = $arrayType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new ConstantArrayType([], []); + } + + $iterableValueType = $arrayType->getIterableValueType(); + $returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false); + + if ($returnValueType === null) { + $returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, true); + $iterableAtLeastOnce = TrinaryLogic::createMaybe(); + if ($returnValueType === null) { + throw new ShouldNotHappenException(); + } + } + + if ($returnValueType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + if ($indexType !== null) { + $type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false); + if ($type !== null) { + $returnKeyType = $type; + } else { + $type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true); + if ($type !== null) { + $returnKeyType = TypeCombinator::union($type, new IntegerType()); + } else { + $returnKeyType = new IntegerType(); + } + } + } else { + $returnKeyType = new IntegerType(); + } + + $returnType = new ArrayType($this->castToArrayKeyType($returnKeyType), $returnValueType); + + if ($iterableAtLeastOnce->yes()) { + $returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType()); + } + if ($indexType === null) { + $returnType = TypeCombinator::intersect($returnType, new AccessoryArrayListType()); + } + + return $returnType; + } + + private function handleConstantArray(ConstantArrayType $arrayType, Type $columnType, ?Type $indexType, Scope $scope): ?Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($arrayType->getValueTypes() as $i => $iterableValueType) { + $valueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false); + if ($valueType === null) { + return null; + } + if ($valueType instanceof NeverType) { + continue; + } + + if ($indexType !== null) { + $type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false); + if ($type !== null) { + $keyType = $type; + } else { + $type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true); + if ($type !== null) { + $keyType = TypeCombinator::union($type, new IntegerType()); + } else { + $keyType = null; + } + } + } else { + $keyType = null; + } + + if ($keyType !== null) { + $keyType = $this->castToArrayKeyType($keyType); + } + $builder->setOffsetValueType($keyType, $valueType, $arrayType->isOptionalKey($i)); + } + + return $builder->getArray(); + } + + private function getOffsetOrProperty(Type $type, Type $offsetOrProperty, Scope $scope, bool $allowMaybe): ?Type + { + $offsetIsNull = $offsetOrProperty->isNull(); + if ($offsetIsNull->yes()) { + return $type; + } + + $returnTypes = []; + + if ($offsetIsNull->maybe()) { + $returnTypes[] = $type; + } + + if (!$type->canAccessProperties()->no()) { + $propertyTypes = $offsetOrProperty->getConstantStrings(); + if ($propertyTypes === []) { + return new MixedType(); + } + foreach ($propertyTypes as $propertyType) { + $propertyName = $propertyType->getValue(); + $hasProperty = $type->hasProperty($propertyName); + if ($hasProperty->maybe()) { + return $allowMaybe ? new MixedType() : null; + } + if (!$hasProperty->yes()) { + continue; + } + + $returnTypes[] = $type->getProperty($propertyName, $scope)->getReadableType(); + } + } + + if ($type->isOffsetAccessible()->yes()) { + $hasOffset = $type->hasOffsetValueType($offsetOrProperty); + if (!$allowMaybe && $hasOffset->maybe()) { + return null; + } + if (!$hasOffset->no()) { + $returnTypes[] = $type->getOffsetValueType($offsetOrProperty); + } + } + + if ($returnTypes === []) { + return new NeverType(); + } + + return TypeCombinator::union(...$returnTypes); + } + + private function castToArrayKeyType(Type $type): Type + { + $isArray = $type->isArray(); + if ($isArray->yes()) { + return $this->phpVersion->throwsTypeErrorForInternalFunctions() ? new NeverType() : new IntegerType(); + } + if ($isArray->no()) { + return $type->toArrayKey(); + } + $withoutArrayType = TypeCombinator::remove($type, new ArrayType(new MixedType(), new MixedType())); + $keyType = $withoutArrayType->toArrayKey(); + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return $keyType; + } + return TypeCombinator::union($keyType, new IntegerType()); + } + +} diff --git a/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php new file mode 100644 index 00000000..94565db6 --- /dev/null +++ b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php @@ -0,0 +1,140 @@ +getName() === 'array_combine'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $firstArg = $functionCall->getArgs()[0]->value; + $secondArg = $functionCall->getArgs()[1]->value; + + $keysParamType = $scope->getType($firstArg); + $valuesParamType = $scope->getType($secondArg); + + if ( + $keysParamType instanceof ConstantArrayType + && $valuesParamType instanceof ConstantArrayType + ) { + $keyTypes = $keysParamType->getValueTypes(); + $valueTypes = $valuesParamType->getValueTypes(); + + if (count($keyTypes) !== count($valueTypes)) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + return new ConstantBooleanType(false); + } + + $keyTypes = $this->sanitizeConstantArrayKeyTypes($keyTypes); + if ($keyTypes !== null) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keyTypes as $i => $keyType) { + $valueType = $valueTypes[$i]; + $builder->setOffsetValueType($keyType, $valueType); + } + + return $builder->getArray(); + } + } + + if ($keysParamType->isArray()->yes()) { + $itemType = $keysParamType->getIterableValueType(); + + if ($itemType->isInteger()->no()) { + if ($itemType->toString() instanceof ErrorType) { + return new NeverType(); + } + + $keyType = $itemType->toString(); + } else { + $keyType = $itemType; + } + } else { + $keyType = new MixedType(); + } + + $arrayType = new ArrayType( + $keyType, + $valuesParamType->isArray()->yes() ? $valuesParamType->getIterableValueType() : new MixedType(), + ); + + if ($keysParamType->isIterableAtLeastOnce()->yes() && $valuesParamType->isIterableAtLeastOnce()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return $arrayType; + } + + if ($firstArg instanceof Variable && $secondArg instanceof Variable && $firstArg->name === $secondArg->name) { + return $arrayType; + } + + return new UnionType([$arrayType, new ConstantBooleanType(false)]); + } + + /** + * @param array $types + * + * @return array|null + */ + private function sanitizeConstantArrayKeyTypes(array $types): ?array + { + $sanitizedTypes = []; + + foreach ($types as $type) { + if ($type->isInteger()->no() && ! $type->toString() instanceof ErrorType) { + $type = $type->toString(); + } + + if ( + !$type instanceof ConstantIntegerType + && !$type instanceof ConstantStringType + ) { + return null; + } + + $sanitizedTypes[] = $type; + } + + return $sanitizedTypes; + } + +} diff --git a/src/Type/Php/ArrayCurrentDynamicReturnTypeExtension.php b/src/Type/Php/ArrayCurrentDynamicReturnTypeExtension.php new file mode 100644 index 00000000..2f6efc56 --- /dev/null +++ b/src/Type/Php/ArrayCurrentDynamicReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'current'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new ConstantBooleanType(false); + } + + $keyType = $argType->getIterableValueType(); + if ($iterableAtLeastOnce->yes()) { + return $keyType; + } + + return TypeCombinator::union($keyType, new ConstantBooleanType(false)); + } + +} diff --git a/src/Type/Php/ArrayFillFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFillFunctionReturnTypeExtension.php new file mode 100644 index 00000000..936092ce --- /dev/null +++ b/src/Type/Php/ArrayFillFunctionReturnTypeExtension.php @@ -0,0 +1,95 @@ +getName() === 'array_fill'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 3) { + return null; + } + + $numberType = $scope->getType($functionCall->getArgs()[1]->value); + $isValidNumberType = IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($numberType); + + // check against negative-int, which is not allowed + if ($isValidNumberType->no()) { + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + return new NeverType(); + } + return new ConstantBooleanType(false); + } + + $startIndexType = $scope->getType($functionCall->getArgs()[0]->value); + $valueType = $scope->getType($functionCall->getArgs()[2]->value); + + if ( + $startIndexType instanceof ConstantIntegerType + && $numberType instanceof ConstantIntegerType + && $numberType->getValue() <= self::MAX_SIZE_USE_CONSTANT_ARRAY + ) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $nextIndex = $startIndexType->getValue(); + for ($i = 0; $i < $numberType->getValue(); $i++) { + $arrayBuilder->setOffsetValueType( + new ConstantIntegerType($nextIndex), + $valueType, + ); + if ($nextIndex < 0) { + $nextIndex = 0; + } else { + $nextIndex++; + } + } + + return $arrayBuilder->getArray(); + } + + $resultType = new ArrayType(new IntegerType(), $valueType); + if ((new ConstantIntegerType(0))->isSuperTypeOf($startIndexType)->yes()) { + $resultType = TypeCombinator::intersect($resultType, new AccessoryArrayListType()); + } + if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($numberType)->yes()) { + $resultType = TypeCombinator::intersect($resultType, new NonEmptyArrayType()); + } + + if (!$isValidNumberType->yes() && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + $resultType = TypeCombinator::union($resultType, new ConstantBooleanType(false)); + } + + return $resultType; + } + +} diff --git a/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php new file mode 100644 index 00000000..3cc2b8c6 --- /dev/null +++ b/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_fill_keys'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $keysType = $scope->getType($functionCall->getArgs()[0]->value); + if ($keysType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + return $keysType->fillKeysArray($scope->getType($functionCall->getArgs()[1]->value)); + } + +} diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php new file mode 100644 index 00000000..56c3ad44 --- /dev/null +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'array_filter'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $arrayArg = $functionCall->getArgs()[0]->value ?? null; + $callbackArg = $functionCall->getArgs()[1]->value ?? null; + $flagArg = $functionCall->getArgs()[2]->value ?? null; + + return $this->arrayFilterFunctionReturnTypeHelper->getType($scope, $arrayArg, $callbackArg, $flagArg); + } + +} diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php new file mode 100644 index 00000000..ddec75ca --- /dev/null +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -0,0 +1,341 @@ +getType($arrayArg); + $arrayArgType = TypeUtils::toBenevolentUnion($arrayArgType); + $keyType = $arrayArgType->getIterableKeyType(); + $itemType = $arrayArgType->getIterableValueType(); + + if ($itemType instanceof NeverType || $keyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + if ($arrayArgType instanceof MixedType) { + return new BenevolentUnionType([ + new ArrayType(new MixedType(), new MixedType()), + new NullType(), + ]); + } + + if ($callbackArg === null || $scope->getType($callbackArg)->isNull()->yes()) { + return TypeCombinator::union( + ...array_map([$this, 'removeFalsey'], $arrayArgType->getArrays()), + ); + } + + $mode = $this->determineMode($flagArg, $scope); + if ($mode === null) { + return new ArrayType($keyType, $itemType); + } + + if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { + $statement = $callbackArg->stmts[0]; + if ($statement instanceof Return_ && $statement->expr !== null) { + if ($mode === self::USE_ITEM) { + $keyVar = null; + $itemVar = $callbackArg->params[0]->var; + } elseif ($mode === self::USE_KEY) { + $keyVar = $callbackArg->params[0]->var; + $itemVar = null; + } elseif ($mode === self::USE_BOTH) { + $keyVar = $callbackArg->params[1]->var ?? null; + $itemVar = $callbackArg->params[0]->var; + } + return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $statement->expr); + } + } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { + if ($mode === self::USE_ITEM) { + $keyVar = null; + $itemVar = $callbackArg->params[0]->var; + } elseif ($mode === self::USE_KEY) { + $keyVar = $callbackArg->params[0]->var; + $itemVar = null; + } elseif ($mode === self::USE_BOTH) { + $keyVar = $callbackArg->params[1]->var ?? null; + $itemVar = $callbackArg->params[0]->var; + } + return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $callbackArg->expr); + } elseif ( + ($callbackArg instanceof FuncCall || $callbackArg instanceof MethodCall || $callbackArg instanceof StaticCall) + && $callbackArg->isFirstClassCallable() + ) { + [$args, $itemVar, $keyVar] = $this->createDummyArgs($mode); + $expr = clone $callbackArg; + $expr->args = $args; + return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); + } else { + $constantStrings = $scope->getType($callbackArg)->getConstantStrings(); + if (count($constantStrings) > 0) { + $results = []; + [$args, $itemVar, $keyVar] = $this->createDummyArgs($mode); + + foreach ($constantStrings as $constantString) { + $funcName = self::createFunctionName($constantString->getValue()); + if ($funcName === null) { + $results[] = new ErrorType(); + continue; + } + + $expr = new FuncCall($funcName, $args); + $results[] = $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); + } + return TypeCombinator::union(...$results); + } + } + + return new ArrayType($keyType, $itemType); + } + + private function removeFalsey(Type $type): Type + { + $falseyTypes = StaticTypeFactory::falsey(); + + if (count($type->getConstantArrays()) > 0) { + $result = []; + foreach ($type->getConstantArrays() as $constantArray) { + $keys = $constantArray->getKeyTypes(); + $values = $constantArray->getValueTypes(); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($values as $offset => $value) { + $isFalsey = $falseyTypes->isSuperTypeOf($value); + + if ($isFalsey->maybe()) { + $builder->setOffsetValueType($keys[$offset], TypeCombinator::remove($value, $falseyTypes), true); + } elseif ($isFalsey->no()) { + $builder->setOffsetValueType($keys[$offset], $value, $constantArray->isOptionalKey($offset)); + } + } + + $result[] = $builder->getArray(); + } + + return TypeCombinator::union(...$result); + } + + $keyType = $type->getIterableKeyType(); + $valueType = $type->getIterableValueType(); + + $valueType = TypeCombinator::remove($valueType, $falseyTypes); + + if ($valueType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new ArrayType($keyType, $valueType); + } + + private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, Type $arrayType, Error|Variable|null $keyVar, Expr $expr): Type + { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $results = []; + foreach ($constantArrays as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $optionalKeys = $constantArray->getOptionalKeys(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $itemType = $constantArray->getValueTypes()[$i]; + [$newKeyType, $newItemType, $optional] = $this->processKeyAndItemType($scope, $keyType, $itemType, $itemVar, $keyVar, $expr); + $optional = $optional || in_array($i, $optionalKeys, true); + if ($newKeyType instanceof NeverType || $newItemType instanceof NeverType) { + continue; + } + if ($itemType->equals($newItemType) && $keyType->equals($newKeyType)) { + $builder->setOffsetValueType($keyType, $itemType, $optional); + continue; + } + + $builder->setOffsetValueType($newKeyType, $newItemType, true); + } + + $results[] = $builder->getArray(); + } + + return TypeCombinator::union(...$results); + } + + [$newKeyType, $newItemType] = $this->processKeyAndItemType($scope, $arrayType->getIterableKeyType(), $arrayType->getIterableValueType(), $itemVar, $keyVar, $expr); + + if ($newItemType instanceof NeverType || $newKeyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new ArrayType($newKeyType, $newItemType); + } + + /** + * @return array{Type, Type, bool} + */ + private function processKeyAndItemType(MutatingScope $scope, Type $keyType, Type $itemType, Error|Variable|null $itemVar, Error|Variable|null $keyVar, Expr $expr): array + { + $itemVarName = null; + if ($itemVar !== null) { + if (!$itemVar instanceof Variable || !is_string($itemVar->name)) { + throw new ShouldNotHappenException(); + } + $itemVarName = $itemVar->name; + $scope = $scope->assignVariable($itemVarName, $itemType, new MixedType(), TrinaryLogic::createYes()); + } + + $keyVarName = null; + if ($keyVar !== null) { + if (!$keyVar instanceof Variable || !is_string($keyVar->name)) { + throw new ShouldNotHappenException(); + } + $keyVarName = $keyVar->name; + $scope = $scope->assignVariable($keyVarName, $keyType, new MixedType(), TrinaryLogic::createYes()); + } + + $booleanResult = $scope->getType($expr)->toBoolean(); + if ($booleanResult->isFalse()->yes()) { + return [new NeverType(), new NeverType(), false]; + } + + $scope = $scope->filterByTruthyValue($expr); + + return [ + $keyVarName !== null ? $scope->getVariableType($keyVarName) : $keyType, + $itemVarName !== null ? $scope->getVariableType($itemVarName) : $itemType, + !$booleanResult->isTrue()->yes(), + ]; + } + + private static function createFunctionName(string $funcName): ?Name + { + if ($funcName === '') { + return null; + } + + if ($funcName[0] === '\\') { + $funcName = substr($funcName, 1); + + if ($funcName === '') { + return null; + } + + return new Name\FullyQualified($funcName); + } + + return new Name($funcName); + } + + /** + * @param self::USE_* $mode + * @return array{list, ?Variable, ?Variable} + */ + private function createDummyArgs(int $mode): array + { + if ($mode === self::USE_ITEM) { + $itemVar = new Variable('item'); + $keyVar = null; + $args = [new Arg($itemVar)]; + } elseif ($mode === self::USE_KEY) { + $itemVar = null; + $keyVar = new Variable('key'); + $args = [new Arg($keyVar)]; + } elseif ($mode === self::USE_BOTH) { + $itemVar = new Variable('item'); + $keyVar = new Variable('key'); + $args = [new Arg($itemVar), new Arg($keyVar)]; + } + return [$args, $itemVar, $keyVar]; + } + + /** + * @param non-empty-string $constantName + */ + private function getConstant(string $constantName): int + { + $constant = $this->reflectionProvider->getConstant(new Name($constantName), null); + $valueType = $constant->getValueType(); + if (!$valueType instanceof ConstantIntegerType) { + throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName)); + } + + return $valueType->getValue(); + } + + /** + * @return self::USE_*|null + */ + private function determineMode(?Expr $flagArg, Scope $scope): ?int + { + if ($flagArg === null) { + return self::USE_ITEM; + } + + $flagValues = $scope->getType($flagArg)->getConstantScalarValues(); + if (count($flagValues) !== 1) { + return null; + } + + if ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_KEY')) { + return self::USE_KEY; + } elseif ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_BOTH')) { + return self::USE_BOTH; + } + + return null; + } + +} diff --git a/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php new file mode 100644 index 00000000..74142348 --- /dev/null +++ b/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php @@ -0,0 +1,47 @@ +getName() === 'array_find'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (count($arrayType->getArrays()) < 1) { + return null; + } + + $arrayArg = $functionCall->getArgs()[0]->value ?? null; + $callbackArg = $functionCall->getArgs()[1]->value ?? null; + + $resultTypes = $this->arrayFilterFunctionReturnTypeHelper->getType($scope, $arrayArg, $callbackArg, null); + $resultType = TypeCombinator::union(...array_map(static fn ($type) => $type->getIterableValueType(), $resultTypes->getArrays())); + + return $resultTypes->isIterableAtLeastOnce()->yes() ? $resultType : TypeCombinator::addNull($resultType); + } + +} diff --git a/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php new file mode 100644 index 00000000..74e8ec40 --- /dev/null +++ b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php @@ -0,0 +1,37 @@ +getName() === 'array_find_key'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (count($arrayType->getArrays()) < 1) { + return null; + } + + return TypeCombinator::union($arrayType->getIterableKeyType(), new NullType()); + } + +} diff --git a/src/Type/Php/ArrayFlipFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFlipFunctionReturnTypeExtension.php new file mode 100644 index 00000000..496a9595 --- /dev/null +++ b/src/Type/Php/ArrayFlipFunctionReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_flip'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) !== 1) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if ($arrayType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + return $arrayType->flipArray(); + } + +} diff --git a/src/Type/Php/ArrayIntersectKeyFunctionReturnTypeExtension.php b/src/Type/Php/ArrayIntersectKeyFunctionReturnTypeExtension.php new file mode 100644 index 00000000..8a522d89 --- /dev/null +++ b/src/Type/Php/ArrayIntersectKeyFunctionReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName() === 'array_intersect_key'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) === 0) { + return null; + } + + $argTypes = []; + foreach ($args as $arg) { + $argType = $scope->getType($arg->value); + if ($arg->unpack) { + $argTypes[] = $argType->getIterableValueType(); + continue; + } + + $argTypes[] = $argType; + } + + $firstArrayType = $argTypes[0]; + $otherArraysType = TypeCombinator::union(...array_slice($argTypes, 1)); + $onlyOneArrayGiven = count($argTypes) === 1; + + if ($firstArrayType->isArray()->no() || (!$onlyOneArrayGiven && $otherArraysType->isArray()->no())) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + if ($onlyOneArrayGiven) { + return $firstArrayType; + } + + return $firstArrayType->intersectKeyArray($otherArraysType); + } + +} diff --git a/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php b/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php new file mode 100644 index 00000000..d56274ac --- /dev/null +++ b/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'key'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new NullType(); + } + + $keyType = $argType->getIterableKeyType(); + if ($iterableAtLeastOnce->yes()) { + return $keyType; + } + + return TypeCombinator::union($keyType, new NullType()); + } + +} diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..a71539b7 --- /dev/null +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -0,0 +1,124 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return in_array($functionReflection->getName(), ['array_key_exists', 'key_exists'], true) + && !$context->null(); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + if (count($node->getArgs()) < 2) { + return new SpecifiedTypes(); + } + $key = $node->getArgs()[0]->value; + $array = $node->getArgs()[1]->value; + $keyType = $scope->getType($key); + $arrayType = $scope->getType($array); + + if ( + !$keyType instanceof ConstantIntegerType + && !$keyType instanceof ConstantStringType + ) { + if ($context->true()) { + if ($arrayType->isIterableAtLeastOnce()->no()) { + return $this->typeSpecifier->create( + $array, + new NonEmptyArrayType(), + $context, + $scope, + ); + } + + $arrayKeyType = $arrayType->getIterableKeyType(); + if ($keyType->isString()->yes()) { + $arrayKeyType = $arrayKeyType->toString(); + } elseif ($keyType->isString()->maybe()) { + $arrayKeyType = TypeCombinator::union($arrayKeyType, $arrayKeyType->toString()); + } + + $specifiedTypes = $this->typeSpecifier->create( + $key, + $arrayKeyType, + $context, + $scope, + ); + + $arrayDimFetch = new ArrayDimFetch( + $array, + $key, + ); + + return $specifiedTypes->unionWith($this->typeSpecifier->create( + $arrayDimFetch, + $arrayType->getIterableValueType(), + $context, + $scope, + ))->setRootExpr(new Identical($arrayDimFetch, new ConstFetch(new Name('__PHPSTAN_FAUX_CONSTANT')))); + } + + return new SpecifiedTypes(); + } + + if ($context->true()) { + $type = TypeCombinator::intersect( + new ArrayType(new MixedType(), new MixedType()), + new HasOffsetType($keyType), + ); + } else { + $type = new HasOffsetType($keyType); + } + + return $this->typeSpecifier->create( + $array, + $type, + $context, + $scope, + ); + } + +} diff --git a/src/Type/Php/ArrayKeyFirstDynamicReturnTypeExtension.php b/src/Type/Php/ArrayKeyFirstDynamicReturnTypeExtension.php new file mode 100644 index 00000000..2a6c7440 --- /dev/null +++ b/src/Type/Php/ArrayKeyFirstDynamicReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_key_first'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new NullType(); + } + + $keyType = $argType->getFirstIterableKeyType(); + if ($iterableAtLeastOnce->yes()) { + return $keyType; + } + + return TypeCombinator::union($keyType, new NullType()); + } + +} diff --git a/src/Type/Php/ArrayKeyLastDynamicReturnTypeExtension.php b/src/Type/Php/ArrayKeyLastDynamicReturnTypeExtension.php new file mode 100644 index 00000000..a2698097 --- /dev/null +++ b/src/Type/Php/ArrayKeyLastDynamicReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_key_last'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new NullType(); + } + + $keyType = $argType->getLastIterableKeyType(); + if ($iterableAtLeastOnce->yes()) { + return $keyType; + } + + return TypeCombinator::union($keyType, new NullType()); + } + +} diff --git a/src/Type/Php/ArrayKeysFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayKeysFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..13d408f5 --- /dev/null +++ b/src/Type/Php/ArrayKeysFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,43 @@ +getName()) === 'array_keys'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) !== 1) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if ($arrayType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + return $arrayType->getKeysArray(); + } + +} diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php new file mode 100644 index 00000000..ea9851e1 --- /dev/null +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -0,0 +1,185 @@ +getName() === 'array_map'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $numArgs = count($functionCall->getArgs()); + if ($numArgs < 2) { + return null; + } + + $singleArrayArgument = !isset($functionCall->getArgs()[2]); + $callableType = $scope->getType($functionCall->getArgs()[0]->value); + $callableIsNull = $callableType->isNull()->yes(); + + $callableParametersAcceptors = null; + + if ($callableType->isCallable()->yes()) { + $callableParametersAcceptors = $callableType->getCallableParametersAcceptors($scope); + $valueType = ParametersAcceptorSelector::selectFromTypes( + array_map( + static fn (Node\Arg $arg) => $scope->getType($arg->value)->getIterableValueType(), + array_slice($functionCall->getArgs(), 1), + ), + $callableParametersAcceptors, + false, + )->getReturnType(); + } elseif ($callableIsNull) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $argTypes = []; + $areAllSameSize = true; + $expectedSize = null; + foreach (array_slice($functionCall->getArgs(), 1) as $index => $arg) { + $argTypes[$index] = $argType = $scope->getType($arg->value); + if (!$areAllSameSize || $numArgs === 2) { + continue; + } + + $arraySizes = $argType->getArraySize()->getConstantScalarValues(); + if ($arraySizes === []) { + $areAllSameSize = false; + continue; + } + + foreach ($arraySizes as $size) { + $expectedSize ??= $size; + if ($expectedSize === $size) { + continue; + } + + $areAllSameSize = false; + continue 2; + } + } + + if (!$areAllSameSize) { + $firstArr = $functionCall->getArgs()[1]->value; + $identities = []; + foreach (array_slice($functionCall->getArgs(), 2) as $arg) { + $identities[] = new Node\Expr\BinaryOp\Identical($firstArr, $arg->value); + } + + $and = array_reduce( + $identities, + static fn (Node\Expr $a, Node\Expr $b) => new Node\Expr\BinaryOp\BooleanAnd($a, $b), + new Node\Expr\ConstFetch(new Node\Name('true')), + ); + $areAllSameSize = $scope->getType($and)->isTrue()->yes(); + } + + $addNull = !$areAllSameSize; + + foreach ($argTypes as $index => $argType) { + $offsetValueType = $argType->getIterableValueType(); + if ($addNull) { + $offsetValueType = TypeCombinator::addNull($offsetValueType); + } + + $arrayBuilder->setOffsetValueType( + new ConstantIntegerType($index), + $offsetValueType, + ); + } + $valueType = $arrayBuilder->getArray(); + } else { + $valueType = new MixedType(); + } + + $arrayType = $scope->getType($functionCall->getArgs()[1]->value); + + if ($singleArrayArgument) { + if ($callableIsNull) { + return $arrayType; + } + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $arrayTypes = []; + $totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]); + if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + foreach ($constantArrays as $constantArray) { + $returnedArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $returnedArrayBuilder->setOffsetValueType( + $keyType, + $callableParametersAcceptors !== null + ? ParametersAcceptorSelector::selectFromTypes( + [$valueTypes[$i]], + $callableParametersAcceptors, + false, + )->getReturnType() + : $valueType, + $constantArray->isOptionalKey($i), + ); + } + $returnedArray = $returnedArrayBuilder->getArray(); + if ($constantArray->isList()->yes()) { + $returnedArray = TypeCombinator::intersect($returnedArray, new AccessoryArrayListType()); + } + $arrayTypes[] = $returnedArray; + } + + $mappedArrayType = TypeCombinator::union(...$arrayTypes); + } else { + $mappedArrayType = TypeCombinator::intersect(new ArrayType( + $arrayType->getIterableKeyType(), + $valueType, + ), ...TypeUtils::getAccessoryTypes($arrayType)); + } + } elseif ($arrayType->isArray()->yes()) { + $mappedArrayType = TypeCombinator::intersect(new ArrayType( + $arrayType->getIterableKeyType(), + $valueType, + ), ...TypeUtils::getAccessoryTypes($arrayType)); + } else { + $mappedArrayType = new ArrayType( + new MixedType(), + $valueType, + ); + } + } else { + $mappedArrayType = TypeCombinator::intersect(new ArrayType( + new IntegerType(), + $valueType, + ), new AccessoryArrayListType(), ...TypeUtils::getAccessoryTypes($arrayType)); + } + + if ($arrayType->isIterableAtLeastOnce()->yes()) { + $mappedArrayType = TypeCombinator::intersect($mappedArrayType, new NonEmptyArrayType()); + } + + return $mappedArrayType; + } + +} diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..4e5981a8 --- /dev/null +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,138 @@ +getName() === 'array_merge'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + + if (!isset($args[0])) { + return null; + } + + $argTypes = []; + $optionalArgTypes = []; + foreach ($args as $arg) { + $argType = $scope->getType($arg->value); + + if ($arg->unpack) { + if ($argType->isConstantArray()->yes()) { + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + $argTypes[] = $valueType; + } + } + } else { + $argTypes[] = $argType->getIterableValueType(); + } + + if (!$argType->isIterableAtLeastOnce()->yes()) { + // unpacked params can be empty, making them optional + $optionalArgTypesOffset = count($argTypes) - 1; + foreach (array_keys($argTypes) as $key) { + $optionalArgTypes[] = $optionalArgTypesOffset + $key; + } + } + } else { + $argTypes[] = $argType; + } + } + + $allConstant = TrinaryLogic::createYes()->lazyAnd( + $argTypes, + static fn (Type $argType) => $argType->isConstantArray(), + ); + + if ($allConstant->yes()) { + $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($argTypes as $argType) { + /** @var array $keyTypes */ + $keyTypes = []; + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getKeyTypes() as $keyType) { + $keyTypes[$keyType->getValue()] = $keyType; + } + } + + foreach ($keyTypes as $keyType) { + $newArrayBuilder->setOffsetValueType( + $keyType instanceof ConstantIntegerType ? null : $keyType, + $argType->getOffsetValueType($keyType), + !$argType->hasOffsetValueType($keyType)->yes(), + ); + } + } + + return $newArrayBuilder->getArray(); + } + + $keyTypes = []; + $valueTypes = []; + $nonEmpty = false; + $isList = true; + foreach ($argTypes as $key => $argType) { + $keyType = $argType->getIterableKeyType(); + $keyTypes[] = $keyType; + $valueTypes[] = $argType->getIterableValueType(); + + if (!(new IntegerType())->isSuperTypeOf($keyType)->yes()) { + $isList = false; + } + + if (in_array($key, $optionalArgTypes, true) || !$argType->isIterableAtLeastOnce()->yes()) { + continue; + } + + $nonEmpty = true; + } + + $keyType = TypeCombinator::union(...$keyTypes); + if ($keyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + $arrayType = new ArrayType( + $keyType, + TypeCombinator::union(...$valueTypes), + ); + + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + +} diff --git a/src/Type/Php/ArrayNextDynamicReturnTypeExtension.php b/src/Type/Php/ArrayNextDynamicReturnTypeExtension.php new file mode 100644 index 00000000..1f1212d7 --- /dev/null +++ b/src/Type/Php/ArrayNextDynamicReturnTypeExtension.php @@ -0,0 +1,40 @@ +getName(), ['next', 'prev'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new ConstantBooleanType(false); + } + + $valueType = $argType->getIterableValueType(); + + return TypeCombinator::union($valueType, new ConstantBooleanType(false)); + } + +} diff --git a/src/Type/Php/ArrayPointerFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ArrayPointerFunctionsDynamicReturnTypeExtension.php new file mode 100644 index 00000000..7c5e685a --- /dev/null +++ b/src/Type/Php/ArrayPointerFunctionsDynamicReturnTypeExtension.php @@ -0,0 +1,56 @@ +getName(), $this->functions, true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) === 0) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new ConstantBooleanType(false); + } + + $itemType = $functionReflection->getName() === 'reset' + ? $argType->getFirstIterableValueType() + : $argType->getLastIterableValueType(); + if ($iterableAtLeastOnce->yes()) { + return $itemType; + } + + return TypeCombinator::union($itemType, new ConstantBooleanType(false)); + } + +} diff --git a/src/Type/Php/ArrayPopFunctionReturnTypeExtension.php b/src/Type/Php/ArrayPopFunctionReturnTypeExtension.php new file mode 100644 index 00000000..6263b462 --- /dev/null +++ b/src/Type/Php/ArrayPopFunctionReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_pop'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new NullType(); + } + + $itemType = $argType->getLastIterableValueType(); + if ($iterableAtLeastOnce->yes()) { + return $itemType; + } + + return TypeCombinator::union($itemType, new NullType()); + } + +} diff --git a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php new file mode 100644 index 00000000..dc3c8117 --- /dev/null +++ b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName() === 'array_rand'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $argsCount = count($functionCall->getArgs()); + if ($argsCount < 1) { + return null; + } + + $firstArgType = $scope->getType($functionCall->getArgs()[0]->value); + $isInteger = $firstArgType->getIterableKeyType()->isInteger(); + $isString = $firstArgType->getIterableKeyType()->isString(); + + if ($isInteger->yes()) { + $valueType = new IntegerType(); + } elseif ($isString->yes()) { + $valueType = new StringType(); + } else { + $valueType = new UnionType([new IntegerType(), new StringType()]); + } + + if ($argsCount < 2) { + return $valueType; + } + + $secondArgType = $scope->getType($functionCall->getArgs()[1]->value); + + $one = new ConstantIntegerType(1); + if ($one->isSuperTypeOf($secondArgType)->yes()) { + return $valueType; + } + + $bigger2 = IntegerRangeType::fromInterval(2, null); + if ($bigger2->isSuperTypeOf($secondArgType)->yes()) { + return new ArrayType(new IntegerType(), $valueType); + } + + return TypeCombinator::union($valueType, new ArrayType(new IntegerType(), $valueType)); + } + +} diff --git a/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php new file mode 100644 index 00000000..4d19e8c0 --- /dev/null +++ b/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php @@ -0,0 +1,70 @@ +getName() === 'array_reduce'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[1])) { + return null; + } + + $callbackType = $scope->getType($functionCall->getArgs()[1]->value); + if ($callbackType->isCallable()->no()) { + return null; + } + + $callbackReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $callbackType->getCallableParametersAcceptors($scope), + )->getReturnType(); + + if (isset($functionCall->getArgs()[2])) { + $initialType = $scope->getType($functionCall->getArgs()[2]->value); + } else { + $initialType = new NullType(); + } + + $arraysType = $scope->getType($functionCall->getArgs()[0]->value); + $constantArrays = $arraysType->getConstantArrays(); + if (count($constantArrays) > 0) { + $onlyEmpty = TrinaryLogic::createYes(); + $onlyNonEmpty = TrinaryLogic::createYes(); + foreach ($constantArrays as $constantArray) { + $iterableAtLeastOnce = $constantArray->isIterableAtLeastOnce(); + $onlyEmpty = $onlyEmpty->and($iterableAtLeastOnce->negate()); + $onlyNonEmpty = $onlyNonEmpty->and($iterableAtLeastOnce); + } + + if ($onlyEmpty->yes()) { + return $initialType; + } + if ($onlyNonEmpty->yes()) { + return $callbackReturnType; + } + } + + return TypeCombinator::union($callbackReturnType, $initialType); + } + +} diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php new file mode 100644 index 00000000..243e743d --- /dev/null +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -0,0 +1,77 @@ +getName()) === 'array_replace'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arrayTypes = $this->collectArrayTypes($functionCall, $scope); + + if (count($arrayTypes) === 0) { + return null; + } + + return $this->getResultType(...$arrayTypes); + } + + private function getResultType(Type ...$arrayTypes): Type + { + $keyTypes = []; + $valueTypes = []; + $nonEmptyArray = false; + foreach ($arrayTypes as $arrayType) { + if (!$nonEmptyArray && $arrayType->isIterableAtLeastOnce()->yes()) { + $nonEmptyArray = true; + } + + $keyTypes[] = $arrayType->getIterableKeyType(); + $valueTypes[] = $arrayType->getIterableValueType(); + } + + $keyType = TypeCombinator::union(...$keyTypes); + $valueType = TypeCombinator::union(...$valueTypes); + + $arrayType = new ArrayType($keyType, $valueType); + return $nonEmptyArray ? TypeCombinator::intersect($arrayType, new NonEmptyArrayType()) : $arrayType; + } + + /** + * @return Type[] + */ + private function collectArrayTypes(FuncCall $functionCall, Scope $scope): array + { + $args = $functionCall->getArgs(); + + $arrayTypes = []; + foreach ($args as $arg) { + $argType = $scope->getType($arg->value); + if (!$argType->isArray()->yes()) { + continue; + } + + $arrayTypes[] = $arg->unpack ? $argType->getIterableValueType() : $argType; + } + + return $arrayTypes; + } + +} diff --git a/src/Type/Php/ArrayReverseFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReverseFunctionReturnTypeExtension.php new file mode 100644 index 00000000..2d9a4699 --- /dev/null +++ b/src/Type/Php/ArrayReverseFunctionReturnTypeExtension.php @@ -0,0 +1,44 @@ +getName() === 'array_reverse'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $type = $scope->getType($functionCall->getArgs()[0]->value); + if ($type->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + $preserveKeysType = isset($functionCall->getArgs()[1]) ? $scope->getType($functionCall->getArgs()[1]->value) : new ConstantBooleanType(false); + + return $type->reverseArray($preserveKeysType->isTrue()); + } + +} diff --git a/src/Type/Php/ArraySearchFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArraySearchFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..5cbc6be3 --- /dev/null +++ b/src/Type/Php/ArraySearchFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,59 @@ +getName() === 'array_search'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $argsCount = count($functionCall->getArgs()); + if ($argsCount < 2) { + return null; + } + + $haystackArgType = $scope->getType($functionCall->getArgs()[1]->value); + if ($haystackArgType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + if ($argsCount < 3) { + return TypeCombinator::union($haystackArgType->getIterableKeyType(), new ConstantBooleanType(false)); + } + + $strictArgType = $scope->getType($functionCall->getArgs()[2]->value); + if (!$strictArgType->isTrue()->yes()) { + return TypeCombinator::union($haystackArgType->getIterableKeyType(), new ConstantBooleanType(false)); + } + + $needleArgType = $scope->getType($functionCall->getArgs()[0]->value); + if ($haystackArgType->getIterableValueType()->isSuperTypeOf($needleArgType)->no()) { + return new ConstantBooleanType(false); + } + + return $haystackArgType->searchArray($needleArgType); + } + +} diff --git a/src/Type/Php/ArraySearchFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArraySearchFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..7a71f600 --- /dev/null +++ b/src/Type/Php/ArraySearchFunctionTypeSpecifyingExtension.php @@ -0,0 +1,57 @@ +getName()) === 'array_search' + && $context->true(); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $arrayArg = $node->getArgs()[1]->value ?? null; + if ($arrayArg === null) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $arrayArg, + new NonEmptyArrayType(), + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/ArrayShiftFunctionReturnTypeExtension.php b/src/Type/Php/ArrayShiftFunctionReturnTypeExtension.php new file mode 100644 index 00000000..b3dfa144 --- /dev/null +++ b/src/Type/Php/ArrayShiftFunctionReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'array_shift'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $iterableAtLeastOnce = $argType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return new NullType(); + } + + $itemType = $argType->getFirstIterableValueType(); + if ($iterableAtLeastOnce->yes()) { + return $itemType; + } + + return TypeCombinator::union($itemType, new NullType()); + } + +} diff --git a/src/Type/Php/ArraySliceFunctionReturnTypeExtension.php b/src/Type/Php/ArraySliceFunctionReturnTypeExtension.php new file mode 100644 index 00000000..74c9e38a --- /dev/null +++ b/src/Type/Php/ArraySliceFunctionReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'array_slice'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + if ($arrayType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + $offsetType = $scope->getType($args[1]->value); + $lengthType = isset($args[2]) ? $scope->getType($args[2]->value) : new NullType(); + $preserveKeysType = isset($args[3]) ? $scope->getType($args[3]->value) : new ConstantBooleanType(false); + + return $arrayType->sliceArray($offsetType, $lengthType, $preserveKeysType->isTrue()); + } + +} diff --git a/src/Type/Php/ArraySpliceFunctionReturnTypeExtension.php b/src/Type/Php/ArraySpliceFunctionReturnTypeExtension.php new file mode 100644 index 00000000..3118d0fa --- /dev/null +++ b/src/Type/Php/ArraySpliceFunctionReturnTypeExtension.php @@ -0,0 +1,36 @@ +getName() === 'array_splice'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $arrayArg = $scope->getType($functionCall->getArgs()[0]->value); + + return new ArrayType($arrayArg->getIterableKeyType(), $arrayArg->getIterableValueType()); + } + +} diff --git a/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..b1ea1924 --- /dev/null +++ b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName() === 'array_sum'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $resultTypes = []; + + if (count($argType->getConstantArrays()) > 0) { + foreach ($argType->getConstantArrays() as $constantArray) { + $node = new Int_(0); + + foreach ($constantArray->getValueTypes() as $i => $type) { + if ($constantArray->isOptionalKey($i)) { + $node = new Plus($node, new TypeExpr(TypeCombinator::union($type, new ConstantIntegerType(0)))); + } else { + $node = new Plus($node, new TypeExpr($type)); + } + } + + $resultTypes[] = $scope->getType($node); + } + } else { + $itemType = $argType->getIterableValueType(); + + $mulNode = new Mul(new TypeExpr($itemType), new TypeExpr(IntegerRangeType::fromInterval(0, null))); + + $resultTypes[] = $scope->getType(new Plus(new TypeExpr($itemType), $mulNode)); + } + + if (!$argType->isIterableAtLeastOnce()->yes()) { + $resultTypes[] = new ConstantIntegerType(0); + } + + return TypeCombinator::union(...$resultTypes)->toNumber(); + } + +} diff --git a/src/Type/Php/ArrayValuesFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayValuesFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..311f0d29 --- /dev/null +++ b/src/Type/Php/ArrayValuesFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,43 @@ +getName()) === 'array_values'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) !== 1) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if ($arrayType->isArray()->no()) { + return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); + } + + return $arrayType->getValuesArray(); + } + +} diff --git a/src/Type/Php/AssertFunctionTypeSpecifyingExtension.php b/src/Type/Php/AssertFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..b6853b89 --- /dev/null +++ b/src/Type/Php/AssertFunctionTypeSpecifyingExtension.php @@ -0,0 +1,36 @@ +getName() === 'assert' + && isset($node->getArgs()[0]); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->typeSpecifier->specifyTypesInCondition($scope, $node->getArgs()[0]->value, TypeSpecifierContext::createTruthy()); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/AssertThrowTypeExtension.php b/src/Type/Php/AssertThrowTypeExtension.php new file mode 100644 index 00000000..2e3c7820 --- /dev/null +++ b/src/Type/Php/AssertThrowTypeExtension.php @@ -0,0 +1,37 @@ +getName() === 'assert'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + if (count($funcCall->getArgs()) < 2) { + return $functionReflection->getThrowType(); + } + + $customThrow = $scope->getType($funcCall->getArgs()[1]->value); + if ((new ObjectType(Throwable::class))->isSuperTypeOf($customThrow)->yes()) { + return $customThrow; + } + + return $functionReflection->getThrowType(); + } + +} diff --git a/src/Type/Php/BackedEnumFromMethodDynamicReturnTypeExtension.php b/src/Type/Php/BackedEnumFromMethodDynamicReturnTypeExtension.php new file mode 100644 index 00000000..8a2352fe --- /dev/null +++ b/src/Type/Php/BackedEnumFromMethodDynamicReturnTypeExtension.php @@ -0,0 +1,101 @@ +getName(), ['from', 'tryFrom'], true); + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (!$methodReflection->getDeclaringClass()->isBackedEnum()) { + return null; + } + + $arguments = $methodCall->getArgs(); + if (count($arguments) < 1) { + return null; + } + + $valueType = $scope->getType($arguments[0]->value); + + $enumCases = $methodReflection->getDeclaringClass()->getEnumCases(); + if (count($enumCases) === 0) { + if ($methodReflection->getName() === 'tryFrom') { + return new NullType(); + } + + return null; + } + + if (count($valueType->getConstantScalarValues()) === 0) { + return null; + } + + $resultEnumCases = []; + $addNull = false; + foreach ($valueType->getConstantScalarValues() as $value) { + $hasMatching = false; + foreach ($enumCases as $enumCase) { + if ($enumCase->getBackingValueType() === null) { + continue; + } + + $enumCaseValues = $enumCase->getBackingValueType()->getConstantScalarValues(); + if (count($enumCaseValues) !== 1) { + continue; + } + + if ($value === $enumCaseValues[0]) { + $resultEnumCases[] = new EnumCaseObjectType($enumCase->getDeclaringEnum()->getName(), $enumCase->getName(), $enumCase->getDeclaringEnum()); + $hasMatching = true; + break; + } + } + + if ($hasMatching) { + continue; + } + + $addNull = true; + } + + if (count($resultEnumCases) === 0) { + if ($methodReflection->getName() === 'tryFrom') { + return new NullType(); + } + + return null; + } + + $result = TypeCombinator::union(...$resultEnumCases); + if ($addNull && $methodReflection->getName() === 'tryFrom') { + return TypeCombinator::addNull($result); + } + + return $result; + } + +} diff --git a/src/Type/Php/Base64DecodeDynamicFunctionReturnTypeExtension.php b/src/Type/Php/Base64DecodeDynamicFunctionReturnTypeExtension.php new file mode 100644 index 00000000..9c9d9235 --- /dev/null +++ b/src/Type/Php/Base64DecodeDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,59 @@ +getName() === 'base64_decode'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + if (!isset($functionCall->getArgs()[1])) { + return new StringType(); + } + + $argType = $scope->getType($functionCall->getArgs()[1]->value); + + if ($argType instanceof MixedType) { + return new BenevolentUnionType([new StringType(), new ConstantBooleanType(false)]); + } + + $isTrueType = $argType->isTrue(); + $isFalseType = $argType->isFalse(); + $compareTypes = $isTrueType->compareTo($isFalseType); + if ($compareTypes === $isTrueType) { + return new UnionType([new StringType(), new ConstantBooleanType(false)]); + } + if ($compareTypes === $isFalseType) { + return new StringType(); + } + + // second argument could be interpreted as true + if (!$isTrueType->no()) { + return new UnionType([new StringType(), new ConstantBooleanType(false)]); + } + + return new StringType(); + } + +} diff --git a/src/Type/Php/BcMathStringOrNullReturnTypeExtension.php b/src/Type/Php/BcMathStringOrNullReturnTypeExtension.php new file mode 100644 index 00000000..e62fdbda --- /dev/null +++ b/src/Type/Php/BcMathStringOrNullReturnTypeExtension.php @@ -0,0 +1,275 @@ +getName(), ['bcdiv', 'bcmod', 'bcpowmod', 'bcsqrt'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + if ($functionReflection->getName() === 'bcsqrt') { + return $this->getTypeForBcSqrt($functionCall, $scope); + } + + if ($functionReflection->getName() === 'bcpowmod') { + return $this->getTypeForBcPowMod($functionCall, $scope); + } + + $stringAndNumericStringType = TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()); + + if (isset($functionCall->getArgs()[1]) === false) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new NullType(); + } + + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + $defaultReturnType = $stringAndNumericStringType; + } else { + $defaultReturnType = new UnionType([$stringAndNumericStringType, new NullType()]); + } + + $secondArgument = $scope->getType($functionCall->getArgs()[1]->value); + $secondArgumentIsNumeric = ($secondArgument instanceof ConstantScalarType && is_numeric($secondArgument->getValue())) || $secondArgument->isInteger()->yes(); + + if ($secondArgument instanceof ConstantScalarType && ($this->isZero($secondArgument->getValue()) || !$secondArgumentIsNumeric)) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new NullType(); + } + + if (isset($functionCall->getArgs()[2]) === false) { + if ($secondArgument instanceof ConstantScalarType || $secondArgumentIsNumeric) { + return $stringAndNumericStringType; + } + + return $defaultReturnType; + } + + $thirdArgument = $scope->getType($functionCall->getArgs()[2]->value); + $thirdArgumentIsNumeric = false; + $thirdArgumentIsNegative = false; + if ($thirdArgument instanceof ConstantScalarType && is_numeric($thirdArgument->getValue())) { + $thirdArgumentIsNumeric = true; + $thirdArgumentIsNegative = ($thirdArgument->getValue() < 0); + } elseif ($thirdArgument->isInteger()->yes()) { + $thirdArgumentIsNumeric = true; + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($thirdArgument)->yes()) { + $thirdArgumentIsNegative = true; + } + } + + if ($thirdArgument instanceof ConstantScalarType && !is_numeric($thirdArgument->getValue())) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new NullType(); + } + + if ($this->phpVersion->throwsTypeErrorForInternalFunctions() && $thirdArgumentIsNegative) { + return new NeverType(); + } + + if (($secondArgument instanceof ConstantScalarType || $secondArgumentIsNumeric) && $thirdArgumentIsNumeric) { + return $stringAndNumericStringType; + } + + return $defaultReturnType; + } + + /** + * bcsqrt + * https://www.php.net/manual/en/function.bcsqrt.php + * > Returns the square root as a string, or NULL if operand is negative. + * + */ + private function getTypeForBcSqrt(FuncCall $functionCall, Scope $scope): Type + { + $stringAndNumericStringType = TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()); + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + $defaultReturnType = $stringAndNumericStringType; + } else { + $defaultReturnType = new UnionType([$stringAndNumericStringType, new NullType()]); + } + + if (isset($functionCall->getArgs()[0]) === false) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return $defaultReturnType; + } + + $firstArgument = $scope->getType($functionCall->getArgs()[0]->value); + + $firstArgumentIsPositive = $firstArgument instanceof ConstantScalarType && is_numeric($firstArgument->getValue()) && $firstArgument->getValue() >= 0; + $firstArgumentIsNegative = $firstArgument instanceof ConstantScalarType && is_numeric($firstArgument->getValue()) && $firstArgument->getValue() < 0; + + if ($firstArgument instanceof UnaryMinus || $firstArgumentIsNegative) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new NullType(); + } + + if (isset($functionCall->getArgs()[1]) === false) { + if ($firstArgumentIsPositive) { + return $stringAndNumericStringType; + } + + return $defaultReturnType; + } + + $secondArgument = $scope->getType($functionCall->getArgs()[1]->value); + $secondArgumentIsValid = $secondArgument instanceof ConstantScalarType && is_numeric($secondArgument->getValue()) && !$this->isZero($secondArgument->getValue()); + $secondArgumentIsNonNumeric = $secondArgument instanceof ConstantScalarType && !is_numeric($secondArgument->getValue()); + $secondArgumentIsNegative = $secondArgument instanceof ConstantScalarType && is_numeric($secondArgument->getValue()) && $secondArgument->getValue() < 0; + + if ($secondArgumentIsNonNumeric) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new NullType(); + } + + if ($secondArgument instanceof UnaryMinus || $secondArgumentIsNegative) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + } + + if ($firstArgumentIsPositive && $secondArgumentIsValid) { + return $stringAndNumericStringType; + } + + return $defaultReturnType; + } + + /** + * bcpowmod() + * https://www.php.net/manual/en/function.bcpowmod.php + * > Returns the result as a string, or FALSE if modulus is 0 or exponent is negative. + */ + private function getTypeForBcPowMod(FuncCall $functionCall, Scope $scope): Type + { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions() && isset($functionCall->getArgs()[0]) === false) { + return new NeverType(); + } + + $stringAndNumericStringType = TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()); + + if (isset($functionCall->getArgs()[1]) === false) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new UnionType([$stringAndNumericStringType, new ConstantBooleanType(false)]); + } + + $exponent = $scope->getType($functionCall->getArgs()[1]->value); + + // Expontent is non numeric + if ($this->phpVersion->throwsTypeErrorForInternalFunctions() + && $exponent instanceof ConstantScalarType && !is_numeric($exponent->getValue()) + ) { + return new NeverType(); + } + + $exponentIsNegative = IntegerRangeType::fromInterval(null, 0)->isSuperTypeOf($exponent)->yes(); + + if ($exponent instanceof ConstantScalarType) { + $exponentIsNegative = is_numeric($exponent->getValue()) && $exponent->getValue() < 0; + } + + if ($exponentIsNegative) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new ConstantBooleanType(false); + } + + if (isset($functionCall->getArgs()[2])) { + $modulus = $scope->getType($functionCall->getArgs()[2]->value); + $modulusIsZero = $modulus instanceof ConstantScalarType && $this->isZero($modulus->getValue()); + $modulusIsNonNumeric = $modulus instanceof ConstantScalarType && !is_numeric($modulus->getValue()); + + if ($modulusIsZero || $modulusIsNonNumeric) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new ConstantBooleanType(false); + } + + if ($modulus instanceof ConstantScalarType) { + return $stringAndNumericStringType; + } + } else { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + } + + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return $stringAndNumericStringType; + } + + return new UnionType([$stringAndNumericStringType, new ConstantBooleanType(false)]); + } + + /** + * Utility to help us determine if value is zero. Handles cases where we pass "0.000" too. + * + * @param mixed $value + */ + private function isZero($value): bool + { + if (is_numeric($value) === false) { + return false; + } + + if ($value > 0 || $value < 0) { + return false; + } + + return true; + } + +} diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..58e768fa --- /dev/null +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -0,0 +1,76 @@ +getName(), [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true) && isset($node->getArgs()[0]) && $context->true(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $argType = $scope->getType($node->getArgs()[0]->value); + if ($argType instanceof ConstantStringType) { + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('class_exists'), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]), + new ConstantBooleanType(true), + $context, + $scope, + ); + } + + $narrowedType = new ClassStringType(); + if ($functionReflection->getName() === 'enum_exists') { + $narrowedType = new GenericClassStringType(new ObjectType('UnitEnum')); + } + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + $narrowedType, + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/ClassImplementsFunctionReturnTypeExtension.php b/src/Type/Php/ClassImplementsFunctionReturnTypeExtension.php new file mode 100644 index 00000000..41c776c5 --- /dev/null +++ b/src/Type/Php/ClassImplementsFunctionReturnTypeExtension.php @@ -0,0 +1,68 @@ +getName(), + ['class_implements', 'class_uses', 'class_parents'], + true, + ); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 1) { + return null; + } + + $firstArgType = $scope->getType($args[0]->value); + $autoload = TrinaryLogic::createYes(); + if (isset($args[1])) { + $autoload = $scope->getType($args[1]->value)->isTrue(); + } + + $isObject = $firstArgType->isObject(); + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants()); + if ($isObject->yes()) { + return TypeCombinator::remove($variant->getReturnType(), new ConstantBooleanType(false)); + } + $isClassStringOrObject = (new UnionType([new ObjectWithoutClassType(), new ClassStringType()]))->isSuperTypeOf($firstArgType); + if ($isClassStringOrObject->yes()) { + if ($autoload->yes()) { + return TypeUtils::toBenevolentUnion($variant->getReturnType()); + } + + return $variant->getReturnType(); + } + + if ($firstArgType->isClassString()->no()) { + return new ConstantBooleanType(false); + } + + return null; + } + +} diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php new file mode 100644 index 00000000..5d3eaebe --- /dev/null +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -0,0 +1,37 @@ +getName() === 'bind'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + $closureType = $scope->getType($methodCall->getArgs()[0]->value); + if (!($closureType instanceof ClosureType)) { + return null; + } + + return $closureType; + } + +} diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php new file mode 100644 index 00000000..5591b1aa --- /dev/null +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -0,0 +1,37 @@ +getName() === 'bindTo'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $closureType = $scope->getType($methodCall->var); + if (!($closureType instanceof ClosureType)) { + return null; + } + + return $closureType; + } + +} diff --git a/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php b/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php new file mode 100644 index 00000000..9b0cea6b --- /dev/null +++ b/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php @@ -0,0 +1,64 @@ +getName() === 'fromCallable'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (!isset($methodCall->getArgs()[0])) { + return null; + } + + $callableType = $scope->getType($methodCall->getArgs()[0]->value); + if ($callableType->isCallable()->no()) { + return new ErrorType(); + } + + $closureTypes = []; + foreach ($callableType->getCallableParametersAcceptors($scope) as $variant) { + $parameters = $variant->getParameters(); + $closureTypes[] = new ClosureType( + $parameters, + $variant->getReturnType(), + $variant->isVariadic(), + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + [], + $variant->getThrowPoints(), + $variant->getImpurePoints(), + $variant->getInvalidateExpressions(), + $variant->getUsedVariables(), + $variant->acceptsNamedArguments(), + ); + } + + return TypeCombinator::union(...$closureTypes); + } + +} diff --git a/src/Type/Php/CompactFunctionReturnTypeExtension.php b/src/Type/Php/CompactFunctionReturnTypeExtension.php new file mode 100644 index 00000000..d691ff07 --- /dev/null +++ b/src/Type/Php/CompactFunctionReturnTypeExtension.php @@ -0,0 +1,89 @@ +getName() === 'compact'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) === 0) { + return null; + } + + if ($scope->canAnyVariableExist() && !$this->checkMaybeUndefinedVariables) { + return null; + } + + $array = ConstantArrayTypeBuilder::createEmpty(); + foreach ($functionCall->getArgs() as $arg) { + $type = $scope->getType($arg->value); + $constantStrings = $this->findConstantStrings($type); + if ($constantStrings === null) { + return null; + } + foreach ($constantStrings as $constantString) { + $has = $scope->hasVariableType($constantString->getValue()); + if ($has->no()) { + continue; + } + + $array->setOffsetValueType($constantString, $scope->getVariableType($constantString->getValue()), $has->maybe()); + } + } + + return $array->getArray(); + } + + /** + * @return array|null + */ + private function findConstantStrings(Type $type): ?array + { + if ($type instanceof ConstantStringType) { + return [$type]; + } + + if ($type instanceof ConstantArrayType) { + $result = []; + foreach ($type->getValueTypes() as $valueType) { + $constantStrings = $this->findConstantStrings($valueType); + if ($constantStrings === null) { + return null; + } + + $result = array_merge($result, $constantStrings); + } + + return $result; + } + + return null; + } + +} diff --git a/src/Type/Php/ConstantFunctionReturnTypeExtension.php b/src/Type/Php/ConstantFunctionReturnTypeExtension.php new file mode 100644 index 00000000..fe4d6014 --- /dev/null +++ b/src/Type/Php/ConstantFunctionReturnTypeExtension.php @@ -0,0 +1,56 @@ +getName() === 'constant'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $nameType = $scope->getType($functionCall->getArgs()[0]->value); + + $results = []; + foreach ($nameType->getConstantStrings() as $constantName) { + $expr = $this->constantHelper->createExprFromConstantName($constantName->getValue()); + if ($expr === null) { + return new ErrorType(); + } + + $results[] = $scope->getType($expr); + } + + if (count($results) > 0) { + return TypeCombinator::union(...$results); + } + + return null; + } + +} diff --git a/src/Type/Php/ConstantHelper.php b/src/Type/Php/ConstantHelper.php new file mode 100644 index 00000000..267c1952 --- /dev/null +++ b/src/Type/Php/ConstantHelper.php @@ -0,0 +1,43 @@ += 2) { + $fqcn = ltrim($classConstParts[0], '\\'); + if ($fqcn === '') { + return null; + } + + $classConstName = new FullyQualified($fqcn); + if ($classConstName->isSpecialClassName()) { + $classConstName = new Name($classConstName->toString()); + } + + return new ClassConstFetch($classConstName, new Identifier($classConstParts[1])); + } + + return new ConstFetch(new FullyQualified($constantName)); + } + +} diff --git a/src/Type/Php/CountCharsFunctionDynamicReturnTypeExtension.php b/src/Type/Php/CountCharsFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..25dbfcc2 --- /dev/null +++ b/src/Type/Php/CountCharsFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName() === 'count_chars'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + + if (count($args) < 1) { + return null; + } + + $modeType = count($args) === 2 ? $scope->getType($args[1]->value) : new ConstantIntegerType(0); + + if (IntegerRangeType::fromInterval(0, 2)->isSuperTypeOf($modeType)->yes()) { + $arrayType = new ArrayType(new IntegerType(), new IntegerType()); + + return $this->phpVersion->throwsValueErrorForInternalFunctions() + ? $arrayType + : TypeUtils::toBenevolentUnion(new UnionType([$arrayType, new ConstantBooleanType(false)])); + } + + $stringType = new StringType(); + + return $this->phpVersion->throwsValueErrorForInternalFunctions() + ? $stringType + : TypeUtils::toBenevolentUnion(new UnionType([$stringType, new ConstantBooleanType(false)])); + } + +} diff --git a/src/Type/Php/CountFunctionReturnTypeExtension.php b/src/Type/Php/CountFunctionReturnTypeExtension.php new file mode 100644 index 00000000..3b2afb10 --- /dev/null +++ b/src/Type/Php/CountFunctionReturnTypeExtension.php @@ -0,0 +1,44 @@ +getName(), ['sizeof', 'count'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + if (count($functionCall->getArgs()) > 1) { + $mode = $scope->getType($functionCall->getArgs()[1]->value); + if ($mode->isSuperTypeOf(new ConstantIntegerType(COUNT_RECURSIVE))->yes()) { + return null; + } + } + + return $scope->getType($functionCall->getArgs()[0]->value)->getArraySize(); + } + +} diff --git a/src/Type/Php/CountFunctionTypeSpecifyingExtension.php b/src/Type/Php/CountFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..f5854e45 --- /dev/null +++ b/src/Type/Php/CountFunctionTypeSpecifyingExtension.php @@ -0,0 +1,53 @@ +null() + && count($node->getArgs()) >= 1 + && in_array($functionReflection->getName(), ['sizeof', 'count'], true); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + if (!$scope->getType($node->getArgs()[0]->value)->isArray()->yes()) { + return new SpecifiedTypes([], []); + } + + return $this->typeSpecifier->create($node->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..ace7cd1e --- /dev/null +++ b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php @@ -0,0 +1,87 @@ +getName()) === 'ctype_digit' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!isset($node->getArgs()[0])) { + return new SpecifiedTypes(); + } + if ($context->null()) { + throw new ShouldNotHappenException(); + } + + $exprArg = $node->getArgs()[0]->value; + if ($context->true() && $scope->getType($exprArg)->isNumericString()->yes()) { + return new SpecifiedTypes(); + } + + $types = [ + IntegerRangeType::fromInterval(48, 57), // ASCII-codes for 0-9 + IntegerRangeType::createAllGreaterThanOrEqualTo(256), // Starting from 256 ints are interpreted as strings + ]; + + if ($context->true()) { + $types[] = new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + + $unionType = TypeCombinator::union(...$types); + $specifiedTypes = $this->typeSpecifier->create($exprArg, $unionType, $context, $scope); + + if ($exprArg instanceof Cast\String_) { + $castedType = new UnionType([ + IntegerRangeType::fromInterval(0, null), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + new ConstantBooleanType(true), + ]); + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($exprArg->expr, $castedType, $context, $scope), + ); + } + + return $specifiedTypes; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/CurlGetinfoFunctionDynamicReturnTypeExtension.php b/src/Type/Php/CurlGetinfoFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..cbcfc107 --- /dev/null +++ b/src/Type/Php/CurlGetinfoFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,201 @@ +getName() === 'curl_getinfo'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + if (count($functionCall->getArgs()) <= 1) { + return $this->createAllComponentsReturnType(); + } + + $componentType = $scope->getType($functionCall->getArgs()[1]->value); + if (!$componentType->isNull()->no()) { + return $this->createAllComponentsReturnType(); + } + + $componentType = $componentType->toInteger(); + if (!$componentType instanceof ConstantIntegerType) { + return $this->createAllComponentsReturnType(); + } + + $stringType = new StringType(); + $integerType = new IntegerType(); + $floatType = new FloatType(); + $falseType = new ConstantBooleanType(false); + $stringFalseType = TypeCombinator::union($stringType, $falseType); + $integerStringArrayType = new ArrayType($integerType, $stringType); + $nestedStringStringArrayType = new ArrayType($integerType, new ArrayType($stringType, $stringType)); + + $componentTypesPairedConstants = [ + 'CURLINFO_EFFECTIVE_URL' => $stringType, + 'CURLINFO_FILETIME' => $integerType, + 'CURLINFO_TOTAL_TIME' => $floatType, + 'CURLINFO_NAMELOOKUP_TIME' => $floatType, + 'CURLINFO_CONNECT_TIME' => $floatType, + 'CURLINFO_PRETRANSFER_TIME' => $floatType, + 'CURLINFO_STARTTRANSFER_TIME' => $floatType, + 'CURLINFO_REDIRECT_COUNT' => $integerType, + 'CURLINFO_REDIRECT_TIME' => $floatType, + 'CURLINFO_REDIRECT_URL' => $stringType, + 'CURLINFO_PRIMARY_IP' => $stringType, + 'CURLINFO_PRIMARY_PORT' => $integerType, + 'CURLINFO_LOCAL_IP' => $stringType, + 'CURLINFO_LOCAL_PORT' => $integerType, + 'CURLINFO_SIZE_UPLOAD' => $integerType, + 'CURLINFO_SIZE_DOWNLOAD' => $integerType, + 'CURLINFO_SPEED_DOWNLOAD' => $integerType, + 'CURLINFO_SPEED_UPLOAD' => $integerType, + 'CURLINFO_HEADER_SIZE' => $integerType, + 'CURLINFO_HEADER_OUT' => $stringFalseType, + 'CURLINFO_REQUEST_SIZE' => $integerType, + 'CURLINFO_SSL_VERIFYRESULT' => $integerType, + 'CURLINFO_CONTENT_LENGTH_DOWNLOAD' => $floatType, + 'CURLINFO_CONTENT_LENGTH_UPLOAD' => $floatType, + 'CURLINFO_CONTENT_TYPE' => $stringFalseType, + 'CURLINFO_PRIVATE' => $stringFalseType, + 'CURLINFO_RESPONSE_CODE' => $integerType, + 'CURLINFO_HTTP_CONNECTCODE' => $integerType, + 'CURLINFO_HTTPAUTH_AVAIL' => $integerType, + 'CURLINFO_PROXYAUTH_AVAIL' => $integerType, + 'CURLINFO_OS_ERRNO' => $integerType, + 'CURLINFO_NUM_CONNECTS' => $integerType, + 'CURLINFO_SSL_ENGINES' => $integerStringArrayType, + 'CURLINFO_COOKIELIST' => $integerStringArrayType, + 'CURLINFO_FTP_ENTRY_PATH' => $stringFalseType, + 'CURLINFO_APPCONNECT_TIME' => $floatType, + 'CURLINFO_CERTINFO' => $nestedStringStringArrayType, + 'CURLINFO_CONDITION_UNMET' => $integerType, + 'CURLINFO_RTSP_CLIENT_CSEQ' => $integerType, + 'CURLINFO_RTSP_CSEQ_RECV' => $integerType, + 'CURLINFO_RTSP_SERVER_CSEQ' => $integerType, + 'CURLINFO_RTSP_SESSION_ID' => $integerType, + 'CURLINFO_HTTP_VERSION' => $integerType, + 'CURLINFO_PROTOCOL' => $stringType, + 'CURLINFO_PROXY_SSL_VERIFYRESULT' => $integerType, + 'CURLINFO_SCHEME' => $stringType, + 'CURLINFO_CONTENT_LENGTH_DOWNLOAD_T' => $integerType, + 'CURLINFO_CONTENT_LENGTH_UPLOAD_T' => $integerType, + 'CURLINFO_SIZE_DOWNLOAD_T' => $integerType, + 'CURLINFO_SIZE_UPLOAD_T' => $integerType, + 'CURLINFO_SPEED_DOWNLOAD_T' => $integerType, + 'CURLINFO_SPEED_UPLOAD_T' => $integerType, + 'CURLINFO_APPCONNECT_TIME_T' => $integerType, + 'CURLINFO_CONNECT_TIME_T' => $integerType, + 'CURLINFO_FILETIME_T' => $integerType, + 'CURLINFO_NAMELOOKUP_TIME_T' => $integerType, + 'CURLINFO_PRETRANSFER_TIME_T' => $integerType, + 'CURLINFO_REDIRECT_TIME_T' => $integerType, + 'CURLINFO_STARTTRANSFER_TIME_T' => $integerType, + 'CURLINFO_TOTAL_TIME_T' => $integerType, + ]; + + foreach ($componentTypesPairedConstants as $constantName => $type) { + $constantNameNode = new Name($constantName); + if ($this->reflectionProvider->hasConstant($constantNameNode, $scope) === false) { + continue; + } + + $valueType = $this->reflectionProvider->getConstant($constantNameNode, $scope)->getValueType(); + if ($componentType->isSuperTypeOf($valueType)->yes()) { + return $type; + } + } + + return $falseType; + } + + private function createAllComponentsReturnType(): Type + { + $returnTypes = [ + new ConstantBooleanType(false), + ]; + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $stringType = new StringType(); + $integerType = new IntegerType(); + $floatType = new FloatType(); + $stringOrNullType = TypeCombinator::union($stringType, new NullType()); + $nestedStringStringArrayType = new ArrayType($integerType, new ArrayType($stringType, $stringType)); + + $componentTypesPairedStrings = [ + 'url' => $stringType, + 'content_type' => $stringOrNullType, + 'http_code' => $integerType, + 'header_size' => $integerType, + 'request_size' => $integerType, + 'filetime' => $integerType, + 'ssl_verify_result' => $integerType, + 'redirect_count' => $integerType, + 'total_time' => $floatType, + 'namelookup_time' => $floatType, + 'connect_time' => $floatType, + 'pretransfer_time' => $floatType, + 'size_upload' => $floatType, + 'size_download' => $floatType, + 'speed_download' => $floatType, + 'speed_upload' => $floatType, + 'download_content_length' => $floatType, + 'upload_content_length' => $floatType, + 'starttransfer_time' => $floatType, + 'redirect_time' => $floatType, + 'redirect_url' => $stringType, + 'primary_ip' => $stringType, + 'certinfo' => $nestedStringStringArrayType, + 'primary_port' => $integerType, + 'local_ip' => $stringType, + 'local_port' => $integerType, + 'http_version' => $integerType, + 'protocol' => $integerType, + 'ssl_verifyresult' => $integerType, + 'scheme' => $stringType, + ]; + foreach ($componentTypesPairedStrings as $componentName => $componentValueType) { + $builder->setOffsetValueType(new ConstantStringType($componentName), $componentValueType); + } + + $returnTypes[] = $builder->getArray(); + + return TypeUtils::toBenevolentUnion(TypeCombinator::union(...$returnTypes)); + } + +} diff --git a/src/Type/Php/DateFormatFunctionReturnTypeExtension.php b/src/Type/Php/DateFormatFunctionReturnTypeExtension.php new file mode 100644 index 00000000..b9b3cfc5 --- /dev/null +++ b/src/Type/Php/DateFormatFunctionReturnTypeExtension.php @@ -0,0 +1,42 @@ +getName() === 'date_format'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + if (count($functionCall->getArgs()) < 2) { + return new StringType(); + } + + return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType( + $scope->getType($functionCall->getArgs()[1]->value), + true, + ); + } + +} diff --git a/src/Type/Php/DateFormatMethodReturnTypeExtension.php b/src/Type/Php/DateFormatMethodReturnTypeExtension.php new file mode 100644 index 00000000..c7d198c9 --- /dev/null +++ b/src/Type/Php/DateFormatMethodReturnTypeExtension.php @@ -0,0 +1,44 @@ +getName() === 'format'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + if (count($methodCall->getArgs()) === 0) { + return new StringType(); + } + + return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType( + $scope->getType($methodCall->getArgs()[0]->value), + true, + ); + } + +} diff --git a/src/Type/Php/DateFunctionReturnTypeExtension.php b/src/Type/Php/DateFunctionReturnTypeExtension.php new file mode 100644 index 00000000..0c8f8bc7 --- /dev/null +++ b/src/Type/Php/DateFunctionReturnTypeExtension.php @@ -0,0 +1,41 @@ +getName() === 'date'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) === 0) { + return null; + } + + return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType( + $scope->getType($functionCall->getArgs()[0]->value), + false, + ); + } + +} diff --git a/src/Type/Php/DateFunctionReturnTypeHelper.php b/src/Type/Php/DateFunctionReturnTypeHelper.php new file mode 100644 index 00000000..58476cb1 --- /dev/null +++ b/src/Type/Php/DateFunctionReturnTypeHelper.php @@ -0,0 +1,115 @@ +getConstantStrings() as $formatString) { + $types[] = $this->buildReturnTypeFromFormat($formatString->getValue(), $useMicrosec); + } + + if (count($types) === 0) { + $types[] = $formatType->isNonEmptyString()->yes() + ? new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]) + : new StringType(); + } + + $type = TypeCombinator::union(...$types); + + if ($type->isNumericString()->no() && $formatType->isNonEmptyString()->yes()) { + $type = TypeCombinator::union($type, new IntersectionType([ + new StringType(), new AccessoryNonEmptyStringType(), + ])); + } + + return $type; + } + + public function buildReturnTypeFromFormat(string $formatString, bool $useMicrosec): Type + { + // see see https://www.php.net/manual/en/datetime.format.php + switch ($formatString) { + case 'd': + return $this->buildNumericRangeType(1, 31, true); + case 'j': + return $this->buildNumericRangeType(1, 31, false); + case 'N': + return $this->buildNumericRangeType(1, 7, false); + case 'w': + return $this->buildNumericRangeType(0, 6, false); + case 'm': + return $this->buildNumericRangeType(1, 12, true); + case 'n': + return $this->buildNumericRangeType(1, 12, false); + case 't': + return $this->buildNumericRangeType(28, 31, false); + case 'L': + return $this->buildNumericRangeType(0, 1, false); + case 'g': + return $this->buildNumericRangeType(1, 12, false); + case 'G': + return $this->buildNumericRangeType(0, 23, false); + case 'h': + return $this->buildNumericRangeType(1, 12, true); + case 'H': + return $this->buildNumericRangeType(0, 23, true); + case 'I': + return $this->buildNumericRangeType(0, 1, false); + case 'u': + return $useMicrosec + ? new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]) + : new ConstantStringType('000000'); + } + + $date = date($formatString); + + // If parameter string is not included, returned as ConstantStringType + if ($date === $formatString) { + return new ConstantStringType($date); + } + + if (is_numeric($date)) { + return new IntersectionType([new StringType(), new AccessoryNumericStringType()]); + } + + return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); + } + + private function buildNumericRangeType(int $min, int $max, bool $zeroPad): Type + { + $types = []; + + for ($i = $min; $i <= $max; $i++) { + $string = (string) $i; + + if ($zeroPad) { + $string = str_pad($string, 2, '0', STR_PAD_LEFT); + } + + $types[] = new ConstantStringType($string); + } + + return new UnionType($types); + } + +} diff --git a/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php b/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php new file mode 100644 index 00000000..eca35044 --- /dev/null +++ b/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php @@ -0,0 +1,65 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === DateInterval::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + try { + new DateInterval($constantString->getValue()); + } catch (\Exception $e) { // phpcs:ignore + return $this->exceptionType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $this->exceptionType(); + } + + return null; + } + + private function exceptionType(): Type + { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new ObjectType('DateMalformedIntervalStringException'); + } + + return new ObjectType('Exception'); + } + +} diff --git a/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php new file mode 100644 index 00000000..026859dc --- /dev/null +++ b/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php @@ -0,0 +1,69 @@ +getName() === 'createFromDateString'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + $arguments = $methodCall->getArgs(); + + if (!isset($arguments[0])) { + return null; + } + + $strings = $scope->getType($arguments[0]->value)->getConstantStrings(); + + $possibleReturnTypes = []; + foreach ($strings as $string) { + try { + $result = @DateInterval::createFromDateString($string->getValue()); + } catch (Throwable) { + $possibleReturnTypes[] = false; + continue; + } + $possibleReturnTypes[] = $result instanceof DateInterval ? DateInterval::class : false; + } + + // the error case, when wrong types are passed + if (count($possibleReturnTypes) === 0) { + return null; + } + + if (in_array(false, $possibleReturnTypes, true) && in_array(DateInterval::class, $possibleReturnTypes, true)) { + return null; + } + + if (in_array(false, $possibleReturnTypes, true)) { + return new ConstantBooleanType(false); + } + + return new ObjectType(DateInterval::class); + } + +} diff --git a/src/Type/Php/DatePeriodConstructorReturnTypeExtension.php b/src/Type/Php/DatePeriodConstructorReturnTypeExtension.php new file mode 100644 index 00000000..a7420c7e --- /dev/null +++ b/src/Type/Php/DatePeriodConstructorReturnTypeExtension.php @@ -0,0 +1,85 @@ +getName() === '__construct'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + if (!isset($methodCall->getArgs()[0])) { + return new ObjectType(DatePeriod::class); + } + + if (!$methodCall->class instanceof Name) { + return new ObjectType(DatePeriod::class); + } + + $className = $scope->resolveName($methodCall->class); + if (strtolower($className) !== 'dateperiod') { + return new ObjectType($className); + } + + $firstArgType = $scope->getType($methodCall->getArgs()[0]->value); + if ($firstArgType->isString()->yes()) { + $firstArgType = new ObjectType(DateTime::class); + } + $thirdArgType = null; + if (isset($methodCall->getArgs()[2])) { + $thirdArgType = $scope->getType($methodCall->getArgs()[2]->value); + } + + if (!$thirdArgType instanceof Type) { + return new GenericObjectType(DatePeriod::class, [ + $firstArgType, + new NullType(), + new IntegerType(), + ]); + } + + if ((new ObjectType(DateTimeInterface::class))->isSuperTypeOf($thirdArgType)->yes()) { + return new GenericObjectType(DatePeriod::class, [ + $firstArgType, + $thirdArgType, + new NullType(), + ]); + } + + if ($thirdArgType->isInteger()->yes()) { + return new GenericObjectType(DatePeriod::class, [ + $firstArgType, + new NullType(), + $thirdArgType, + ]); + } + + return new ObjectType(DatePeriod::class); + } + +} diff --git a/src/Type/Php/DateTimeConstructorThrowTypeExtension.php b/src/Type/Php/DateTimeConstructorThrowTypeExtension.php new file mode 100644 index 00000000..542bf256 --- /dev/null +++ b/src/Type/Php/DateTimeConstructorThrowTypeExtension.php @@ -0,0 +1,67 @@ +getName() === '__construct' && in_array($methodReflection->getDeclaringClass()->getName(), [DateTime::class, DateTimeImmutable::class], true); + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + try { + new DateTime($constantString->getValue()); + } catch (\Exception $e) { // phpcs:ignore + return $this->exceptionType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $this->exceptionType(); + } + + return null; + } + + private function exceptionType(): Type + { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new ObjectType('DateMalformedStringException'); + } + + return new ObjectType('Exception'); + } + +} diff --git a/src/Type/Php/DateTimeCreateDynamicReturnTypeExtension.php b/src/Type/Php/DateTimeCreateDynamicReturnTypeExtension.php new file mode 100644 index 00000000..0055f851 --- /dev/null +++ b/src/Type/Php/DateTimeCreateDynamicReturnTypeExtension.php @@ -0,0 +1,50 @@ +getName(), ['date_create', 'date_create_immutable'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $datetimes = $scope->getType($functionCall->getArgs()[0]->value)->getConstantStrings(); + + if (count($datetimes) === 0) { + return null; + } + + $types = []; + $className = $functionReflection->getName() === 'date_create' ? DateTime::class : DateTimeImmutable::class; + foreach ($datetimes as $constantString) { + $isValid = date_create($constantString->getValue()) !== false; + $types[] = $isValid ? new ObjectType($className) : new ConstantBooleanType(false); + } + + return TypeCombinator::union(...$types); + } + +} diff --git a/src/Type/Php/DateTimeDynamicReturnTypeExtension.php b/src/Type/Php/DateTimeDynamicReturnTypeExtension.php new file mode 100644 index 00000000..4e57af2f --- /dev/null +++ b/src/Type/Php/DateTimeDynamicReturnTypeExtension.php @@ -0,0 +1,52 @@ +getName(), ['date_create_from_format', 'date_create_immutable_from_format'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $formats = $scope->getType($functionCall->getArgs()[0]->value)->getConstantStrings(); + $datetimes = $scope->getType($functionCall->getArgs()[1]->value)->getConstantStrings(); + + if (count($formats) === 0 || count($datetimes) === 0) { + return null; + } + + $types = []; + $className = $functionReflection->getName() === 'date_create_from_format' ? DateTime::class : DateTimeImmutable::class; + foreach ($formats as $formatConstantString) { + foreach ($datetimes as $datetimeConstantString) { + $isValid = (DateTime::createFromFormat($formatConstantString->getValue(), $datetimeConstantString->getValue()) !== false); + $types[] = $isValid ? new ObjectType($className) : new ConstantBooleanType(false); + } + } + + return TypeCombinator::union(...$types); + } + +} diff --git a/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php b/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php new file mode 100644 index 00000000..bc994709 --- /dev/null +++ b/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php @@ -0,0 +1,72 @@ +getName() === 'modify' && in_array($methodReflection->getDeclaringClass()->getName(), [DateTime::class, DateTimeImmutable::class], true); + } + + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + if (!$this->phpVersion->hasDateTimeExceptions()) { + return null; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + try { + $dateTime = new DateTime(); + $dateTime->modify($constantString->getValue()); + } catch (\Exception $e) { // phpcs:ignore + return $this->exceptionType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $this->exceptionType(); + } + + return null; + } + + private function exceptionType(): Type + { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new ObjectType('DateMalformedStringException'); + } + + return new ObjectType('Exception'); + } + +} diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php new file mode 100644 index 00000000..162cadc0 --- /dev/null +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -0,0 +1,91 @@ + $dateTimeClass */ + public function __construct( + private PhpVersion $phpVersion, + private string $dateTimeClass, + ) + { + } + + public function getClass(): string + { + return $this->dateTimeClass; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'modify'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 1) { + return null; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + $hasFalse = false; + $hasDateTime = false; + + foreach ($constantStrings as $constantString) { + try { + $result = @(new DateTime())->modify($constantString->getValue()); + } catch (Throwable) { + $valueType = TypeCombinator::remove($valueType, $constantString); + continue; + } + + if ($result === false) { + $hasFalse = true; + } else { + $hasDateTime = true; + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return null; + } + + if ($hasFalse) { + if (!$hasDateTime) { + return new ConstantBooleanType(false); + } + + return null; + } elseif ($hasDateTime) { + return $scope->getType($methodCall->var); + } + + if ($this->phpVersion->hasDateTimeExceptions()) { + return new NeverType(); + } + + return null; + } + +} diff --git a/src/Type/Php/DateTimeSubMethodThrowTypeExtension.php b/src/Type/Php/DateTimeSubMethodThrowTypeExtension.php new file mode 100644 index 00000000..8f8a6190 --- /dev/null +++ b/src/Type/Php/DateTimeSubMethodThrowTypeExtension.php @@ -0,0 +1,44 @@ +getName() === 'sub' + && in_array($methodReflection->getDeclaringClass()->getName(), [DateTime::class, DateTimeImmutable::class], true); + } + + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + if (!$this->phpVersion->hasDateTimeExceptions()) { + return null; + } + + return new ObjectType('DateInvalidOperationException'); + } + +} diff --git a/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php b/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php new file mode 100644 index 00000000..a45f7543 --- /dev/null +++ b/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php @@ -0,0 +1,65 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === DateTimeZone::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + try { + new DateTimeZone($constantString->getValue()); + } catch (\Exception $e) { // phpcs:ignore + return $this->exceptionType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $this->exceptionType(); + } + + return null; + } + + private function exceptionType(): Type + { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new ObjectType('DateInvalidTimeZoneException'); + } + + return new ObjectType('Exception'); + } + +} diff --git a/src/Type/Php/DefineConstantTypeSpecifyingExtension.php b/src/Type/Php/DefineConstantTypeSpecifyingExtension.php new file mode 100644 index 00000000..306d507a --- /dev/null +++ b/src/Type/Php/DefineConstantTypeSpecifyingExtension.php @@ -0,0 +1,64 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return $functionReflection->getName() === 'define' + && $context->null() + && count($node->getArgs()) >= 2; + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $constantName = $scope->getType($node->getArgs()[0]->value); + if ( + !$constantName instanceof ConstantStringType + || $constantName->getValue() === '' + ) { + return new SpecifiedTypes([], []); + } + + return $this->typeSpecifier->create( + new Node\Expr\ConstFetch( + new Node\Name\FullyQualified($constantName->getValue()), + ), + $scope->getType($node->getArgs()[1]->value), + TypeSpecifierContext::createTruthy(), + $scope, + )->setAlwaysOverwriteTypes(); + } + +} diff --git a/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php b/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php new file mode 100644 index 00000000..51193ea5 --- /dev/null +++ b/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php @@ -0,0 +1,71 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return $functionReflection->getName() === 'defined' + && count($node->getArgs()) >= 1 + && $context->true(); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $constantName = $scope->getType($node->getArgs()[0]->value); + if ( + !$constantName instanceof ConstantStringType + || $constantName->getValue() === '' + ) { + return new SpecifiedTypes([], []); + } + + $expr = $this->constantHelper->createExprFromConstantName($constantName->getValue()); + if ($expr === null) { + return new SpecifiedTypes([], []); + } + + return $this->typeSpecifier->create( + $expr, + new MixedType(), + $context, + $scope, + ); + } + +} diff --git a/src/Type/Php/DioStatDynamicFunctionReturnTypeExtension.php b/src/Type/Php/DioStatDynamicFunctionReturnTypeExtension.php new file mode 100644 index 00000000..460ad4d7 --- /dev/null +++ b/src/Type/Php/DioStatDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,51 @@ +getName() === 'dio_stat'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $valueType = new IntegerType(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + $keys = [ + 'device', + 'inode', + 'mode', + 'nlink', + 'uid', + 'gid', + 'device_type', + 'size', + 'blocksize', + 'blocks', + 'atime', + 'mtime', + 'ctime', + ]; + + foreach ($keys as $key) { + $builder->setOffsetValueType(new ConstantStringType($key), $valueType); + } + + return TypeCombinator::addNull($builder->getArray()); + } + +} diff --git a/src/Type/Php/DsMapDynamicMethodThrowTypeExtension.php b/src/Type/Php/DsMapDynamicMethodThrowTypeExtension.php new file mode 100644 index 00000000..c0fee7da --- /dev/null +++ b/src/Type/Php/DsMapDynamicMethodThrowTypeExtension.php @@ -0,0 +1,32 @@ +getDeclaringClass()->getName() === 'Ds\Map' + && ($methodReflection->getName() === 'get' || $methodReflection->getName() === 'remove'); + } + + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->args) < 2) { + return $methodReflection->getThrowType(); + } + + return new VoidType(); + } + +} diff --git a/src/Type/Php/DsMapDynamicReturnTypeExtension.php b/src/Type/Php/DsMapDynamicReturnTypeExtension.php new file mode 100644 index 00000000..37884e00 --- /dev/null +++ b/src/Type/Php/DsMapDynamicReturnTypeExtension.php @@ -0,0 +1,62 @@ +getName(), ['get', 'remove'], true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $argsCount = count($methodCall->getArgs()); + if ($argsCount > 1) { + return null; + } + + if ($argsCount === 0) { + return null; + } + + $mapType = $scope->getType($methodCall->var); + if (!$mapType instanceof TypeWithClassName) { + return null; + } + + $mapAncestor = $mapType->getAncestorWithClassName('Ds\Map'); + if ($mapAncestor === null) { + return null; + } + + $mapAncestorClass = $mapAncestor->getClassReflection(); + if ($mapAncestorClass === null) { + return null; + } + + $valueType = $mapAncestorClass->getActiveTemplateTypeMap()->getType('TValue'); + if ($valueType === null) { + return null; + } + + return $valueType; + } + +} diff --git a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..616e8913 --- /dev/null +++ b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,95 @@ +getName() === 'explode'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $delimiterType = $scope->getType($args[0]->value); + $isEmptyString = (new ConstantStringType(''))->isSuperTypeOf($delimiterType); + if ($isEmptyString->yes()) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + return new ConstantBooleanType(false); + } + + $stringType = $scope->getType($args[1]->value); + $accessory = []; + if ($stringType->isLowercaseString()->yes()) { + $accessory[] = new AccessoryLowercaseStringType(); + } + if ($stringType->isUppercaseString()->yes()) { + $accessory[] = new AccessoryUppercaseStringType(); + } + if (count($accessory) > 0) { + $accessory[] = new StringType(); + $returnValueType = new IntersectionType($accessory); + } else { + $returnValueType = new StringType(); + } + + $returnType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $returnValueType), new AccessoryArrayListType()); + if ( + !isset($args[2]) + || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($args[2]->value))->yes() + ) { + $returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType()); + } + + if (!$this->phpVersion->throwsValueErrorForInternalFunctions() && $isEmptyString->maybe()) { + $returnType = TypeCombinator::union($returnType, new ConstantBooleanType(false)); + } + + if ($delimiterType instanceof MixedType) { + $returnType = TypeUtils::toBenevolentUnion($returnType); + } + + return $returnType; + } + +} diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php new file mode 100644 index 00000000..bdf194bf --- /dev/null +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -0,0 +1,451 @@ +|null */ + private ?array $filterTypeMap = null; + + /** @var array>|null */ + private ?array $filterTypeOptions = null; + + private ?Type $supportedFilterInputTypes = null; + + public function __construct(private ReflectionProvider $reflectionProvider, private PhpVersion $phpVersion) + { + $this->flagsString = new ConstantStringType('flags'); + } + + public function getOffsetValueType(Type $inputType, Type $offsetType, ?Type $filterType, ?Type $flagsType): Type + { + $inexistentOffsetType = $this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsType) + ? new ConstantBooleanType(false) + : new NullType(); + + $hasOffsetValueType = $inputType->hasOffsetValueType($offsetType); + if ($hasOffsetValueType->no()) { + return $inexistentOffsetType; + } + + $filteredType = $this->getType($inputType->getOffsetValueType($offsetType), $filterType, $flagsType); + + return $hasOffsetValueType->maybe() + ? TypeCombinator::union($filteredType, $inexistentOffsetType) + : $filteredType; + } + + public function getInputType(Type $typeType, Type $varNameType, ?Type $filterType, ?Type $flagsType): Type + { + $this->supportedFilterInputTypes ??= TypeCombinator::union( + $this->reflectionProvider->getConstant(new Node\Name('INPUT_GET'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_POST'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_COOKIE'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_SERVER'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_ENV'), null)->getValueType(), + ); + + if (!$typeType->isInteger()->yes() || $this->supportedFilterInputTypes->isSuperTypeOf($typeType)->no()) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + // Using a null as input mimics pre PHP 8 behaviour where filter_input + // would return the same as if the offset does not exist + $inputType = new NullType(); + } else { + // Pragmatical solution since global expressions are not passed through the scope for performance reasons + // See https://github.com/phpstan/phpstan-src/pull/2012 for details + $inputType = new ArrayType(new StringType(), new MixedType()); + } + + return $this->getOffsetValueType($inputType, $varNameType, $filterType, $flagsType); + } + + public function getType(Type $inputType, ?Type $filterType, ?Type $flagsType): Type + { + $mixedType = new MixedType(); + + if ($filterType === null) { + $filterValue = $this->getConstant('FILTER_DEFAULT'); + } else { + if (!$filterType instanceof ConstantIntegerType) { + return $mixedType; + } + $filterValue = $filterType->getValue(); + } + + if ($flagsType === null) { + $flagsType = new ConstantIntegerType(0); + } + + $hasOptions = $this->hasOptions($flagsType); + $options = $hasOptions->yes() ? $this->getOptions($flagsType, $filterValue) : []; + + $defaultType = $options['default'] ?? ($this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsType) + ? new NullType() + : new ConstantBooleanType(false)); + + $inputIsArray = $inputType->isArray(); + $hasRequireArrayFlag = $this->hasFlag($this->getConstant('FILTER_REQUIRE_ARRAY'), $flagsType); + if ($inputIsArray->no() && $hasRequireArrayFlag) { + return $defaultType; + } + + $hasForceArrayFlag = $this->hasFlag($this->getConstant('FILTER_FORCE_ARRAY'), $flagsType); + if ($inputIsArray->yes() && ($hasRequireArrayFlag || $hasForceArrayFlag)) { + $inputArrayKeyType = $inputType->getIterableKeyType(); + $inputType = $inputType->getIterableValueType(); + } + + if ($inputType->isScalar()->no() && $inputType->isNull()->no()) { + return $defaultType; + } + + $exactType = $this->determineExactType($inputType, $filterValue, $defaultType, $flagsType); + $type = $exactType ?? $this->getFilterTypeMap()[$filterValue] ?? $mixedType; + $type = $this->applyRangeOptions($type, $options, $defaultType); + + if ($inputType->isNonEmptyString()->yes() + && $type->isString()->yes() + && !$this->canStringBeSanitized($filterValue, $flagsType)) { + $accessory = new AccessoryNonEmptyStringType(); + if ($inputType->isNonFalsyString()->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } + $type = TypeCombinator::intersect($type, $accessory); + } + + if ($hasRequireArrayFlag) { + $type = new ArrayType($inputArrayKeyType ?? $mixedType, $type); + } + + if ($exactType === null || $hasOptions->maybe() || (!$inputType->equals($type) && $inputType->isSuperTypeOf($type)->yes())) { + if ($defaultType->isSuperTypeOf($type)->no()) { + $type = TypeCombinator::union($type, $defaultType); + } + } + + if (!$hasRequireArrayFlag && $hasForceArrayFlag) { + return new ArrayType($inputArrayKeyType ?? $mixedType, $type); + } + + return $type; + } + + /** + * @return array + */ + private function getFilterTypeMap(): array + { + if ($this->filterTypeMap !== null) { + return $this->filterTypeMap; + } + + $booleanType = new BooleanType(); + $floatType = new FloatType(); + $intType = new IntegerType(); + $stringType = new StringType(); + $nonFalsyStringType = TypeCombinator::intersect($stringType, new AccessoryNonFalsyStringType()); + + $this->filterTypeMap = [ + $this->getConstant('FILTER_UNSAFE_RAW') => $stringType, + $this->getConstant('FILTER_SANITIZE_EMAIL') => $stringType, + $this->getConstant('FILTER_SANITIZE_ENCODED') => $stringType, + $this->getConstant('FILTER_SANITIZE_NUMBER_FLOAT') => $stringType, + $this->getConstant('FILTER_SANITIZE_NUMBER_INT') => $stringType, + $this->getConstant('FILTER_SANITIZE_SPECIAL_CHARS') => $stringType, + $this->getConstant('FILTER_SANITIZE_STRING') => $stringType, + $this->getConstant('FILTER_SANITIZE_URL') => $stringType, + $this->getConstant('FILTER_VALIDATE_BOOLEAN') => $booleanType, + $this->getConstant('FILTER_VALIDATE_DOMAIN') => $stringType, + $this->getConstant('FILTER_VALIDATE_EMAIL') => $nonFalsyStringType, + $this->getConstant('FILTER_VALIDATE_FLOAT') => $floatType, + $this->getConstant('FILTER_VALIDATE_INT') => $intType, + $this->getConstant('FILTER_VALIDATE_IP') => $nonFalsyStringType, + $this->getConstant('FILTER_VALIDATE_MAC') => $nonFalsyStringType, + $this->getConstant('FILTER_VALIDATE_REGEXP') => $stringType, + $this->getConstant('FILTER_VALIDATE_URL') => $nonFalsyStringType, + ]; + + if ($this->reflectionProvider->hasConstant(new Node\Name('FILTER_SANITIZE_MAGIC_QUOTES'), null)) { + $this->filterTypeMap[$this->getConstant('FILTER_SANITIZE_MAGIC_QUOTES')] = $stringType; + } + + if ($this->reflectionProvider->hasConstant(new Node\Name('FILTER_SANITIZE_ADD_SLASHES'), null)) { + $this->filterTypeMap[$this->getConstant('FILTER_SANITIZE_ADD_SLASHES')] = $stringType; + } + + return $this->filterTypeMap; + } + + /** + * @return array> + */ + private function getFilterTypeOptions(): array + { + if ($this->filterTypeOptions !== null) { + return $this->filterTypeOptions; + } + + $this->filterTypeOptions = [ + $this->getConstant('FILTER_VALIDATE_INT') => ['min_range', 'max_range'], + // PHPStan does not yet support FloatRangeType + // $this->getConstant('FILTER_VALIDATE_FLOAT') => ['min_range', 'max_range'], + ]; + + return $this->filterTypeOptions; + } + + /** + * @param non-empty-string $constantName + */ + private function getConstant(string $constantName): int + { + $constant = $this->reflectionProvider->getConstant(new Node\Name($constantName), null); + $valueType = $constant->getValueType(); + if (!$valueType instanceof ConstantIntegerType) { + throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName)); + } + + return $valueType->getValue(); + } + + private function determineExactType(Type $in, int $filterValue, Type $defaultType, ?Type $flagsType): ?Type + { + if ($filterValue === $this->getConstant('FILTER_VALIDATE_BOOLEAN')) { + if ($in->isBoolean()->yes()) { + return $in; + } + + if ($in->isNull()->yes()) { + return $defaultType; + } + } + + if ($filterValue === $this->getConstant('FILTER_VALIDATE_FLOAT')) { + if ($in->isFloat()->yes()) { + return $in; + } + + if ($in->isInteger()->yes()) { + return $in->toFloat(); + } + + if ($in->isTrue()->yes()) { + return new ConstantFloatType(1); + } + + if ($in->isFalse()->yes() || $in->isNull()->yes()) { + return $defaultType; + } + } + + if ($filterValue === $this->getConstant('FILTER_VALIDATE_INT')) { + if ($in->isInteger()->yes()) { + return $in; + } + + if ($in->isTrue()->yes()) { + return new ConstantIntegerType(1); + } + + if ($in->isFalse()->yes() || $in->isNull()->yes()) { + return $defaultType; + } + + if ($in instanceof ConstantFloatType) { + return $in->getValue() - (int) $in->getValue() === 0.0 + ? $in->toInteger() + : $defaultType; + } + + if ($in instanceof ConstantStringType) { + $value = $in->getValue(); + $allowOctal = $this->hasFlag($this->getConstant('FILTER_FLAG_ALLOW_OCTAL'), $flagsType); + $allowHex = $this->hasFlag($this->getConstant('FILTER_FLAG_ALLOW_HEX'), $flagsType); + + if ($allowOctal && preg_match('/\A0[oO][0-7]+\z/', $value) === 1) { + $octalValue = octdec($value); + return is_int($octalValue) ? new ConstantIntegerType($octalValue) : $defaultType; + } + + if ($allowHex && preg_match('/\A0[xX][0-9A-Fa-f]+\z/', $value) === 1) { + $hexValue = hexdec($value); + return is_int($hexValue) ? new ConstantIntegerType($hexValue) : $defaultType; + } + + return preg_match('/\A[+-]?(?:0|[1-9][0-9]*)\z/', $value) === 1 ? $in->toInteger() : $defaultType; + } + } + + if ($filterValue === $this->getConstant('FILTER_DEFAULT')) { + if (!$this->canStringBeSanitized($filterValue, $flagsType) && $in->isString()->yes()) { + return $in; + } + + if ($in->isBoolean()->yes() || $in->isFloat()->yes() || $in->isInteger()->yes() || $in->isNull()->yes()) { + return $in->toString(); + } + } + + return null; + } + + /** @param array $typeOptions */ + private function applyRangeOptions(Type $type, array $typeOptions, Type $defaultType): Type + { + if (!$type->isInteger()->yes()) { + return $type; + } + + $range = []; + if (isset($typeOptions['min_range'])) { + if ($typeOptions['min_range'] instanceof ConstantScalarType) { + $range['min'] = (int) $typeOptions['min_range']->getValue(); + } elseif ($typeOptions['min_range'] instanceof IntegerRangeType) { + $range['min'] = $typeOptions['min_range']->getMin(); + } else { + $range['min'] = null; + } + } + if (isset($typeOptions['max_range'])) { + if ($typeOptions['max_range'] instanceof ConstantScalarType) { + $range['max'] = (int) $typeOptions['max_range']->getValue(); + } elseif ($typeOptions['max_range'] instanceof IntegerRangeType) { + $range['max'] = $typeOptions['max_range']->getMax(); + } else { + $range['max'] = null; + } + } + + if (array_key_exists('min', $range) || array_key_exists('max', $range)) { + $min = $range['min'] ?? null; + $max = $range['max'] ?? null; + $rangeType = IntegerRangeType::fromInterval($min, $max); + $rangeTypeIsSuperType = $rangeType->isSuperTypeOf($type); + + if ($rangeTypeIsSuperType->no()) { + // e.g. if 9 is filtered with a range of int<17, 19> + return $defaultType; + } + + if ($rangeTypeIsSuperType->yes() && !$rangeType->equals($type)) { + // e.g. if 18 or int<18, 19> are filtered with a range of int<17, 19> + return $type; + } + + // Open ranges on either side means that the input is potentially not part of the range + return $min === null || $max === null ? TypeCombinator::union($rangeType, $defaultType) : $rangeType; + } + + return $type; + } + + private function hasOptions(Type $flagsType): TrinaryLogic + { + return $flagsType->isArray() + ->and($flagsType->hasOffsetValueType(new ConstantStringType('options'))); + } + + /** @return array */ + private function getOptions(Type $flagsType, int $filterValue): array + { + $options = []; + + $optionsType = $flagsType->getOffsetValueType(new ConstantStringType('options')); + if (!$optionsType->isConstantArray()->yes()) { + return $options; + } + + $optionNames = array_merge(['default'], $this->getFilterTypeOptions()[$filterValue] ?? []); + foreach ($optionNames as $optionName) { + $optionalNameType = new ConstantStringType($optionName); + if (!$optionsType->hasOffsetValueType($optionalNameType)->yes()) { + $options[$optionName] = null; + continue; + } + + $options[$optionName] = $optionsType->getOffsetValueType($optionalNameType); + } + + return $options; + } + + private function hasFlag(int $flag, ?Type $flagsType): bool + { + if ($flagsType === null) { + return false; + } + + $type = $this->getFlagsValue($flagsType); + + return $type instanceof ConstantIntegerType && ($type->getValue() & $flag) === $flag; + } + + private function getFlagsValue(Type $exprType): Type + { + if (!$exprType->isConstantArray()->yes()) { + return $exprType; + } + + return $exprType->getOffsetValueType($this->flagsString); + } + + private function canStringBeSanitized(int $filterValue, ?Type $flagsType): bool + { + // If it is a validation filter, the string will not be changed + if (($filterValue & self::VALIDATION_FILTER_BITMASK) !== 0) { + return false; + } + + // FILTER_DEFAULT will not sanitize, unless it has FILTER_FLAG_STRIP_LOW, + // FILTER_FLAG_STRIP_HIGH, or FILTER_FLAG_STRIP_BACKTICK + if ($filterValue === $this->getConstant('FILTER_DEFAULT')) { + return $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_LOW'), $flagsType) + || $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_HIGH'), $flagsType) + || $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_BACKTICK'), $flagsType); + } + + return true; + } + +} diff --git a/src/Type/Php/FilterInputDynamicReturnTypeExtension.php b/src/Type/Php/FilterInputDynamicReturnTypeExtension.php new file mode 100644 index 00000000..ab2e62bc --- /dev/null +++ b/src/Type/Php/FilterInputDynamicReturnTypeExtension.php @@ -0,0 +1,39 @@ +getName() === 'filter_input'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + return $this->filterFunctionReturnTypeHelper->getInputType( + $scope->getType($functionCall->getArgs()[0]->value), + $scope->getType($functionCall->getArgs()[1]->value), + isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : null, + isset($functionCall->getArgs()[3]) ? $scope->getType($functionCall->getArgs()[3]->value) : null, + ); + } + +} diff --git a/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php new file mode 100644 index 00000000..ee4fb12e --- /dev/null +++ b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php @@ -0,0 +1,199 @@ +getName()), ['filter_var_array', 'filter_input_array'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $functionName = strtolower($functionReflection->getName()); + $inputArgType = $scope->getType($functionCall->getArgs()[0]->value); + $inputConstantArrayType = null; + if ($functionName === 'filter_var_array') { + if ($inputArgType->isArray()->no()) { + return new NeverType(); + } + + $inputConstantArrayType = $inputArgType->getConstantArrays()[0] ?? null; + } elseif ($functionName === 'filter_input_array') { + $supportedTypes = TypeCombinator::union( + $this->reflectionProvider->getConstant(new Node\Name('INPUT_GET'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_POST'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_COOKIE'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_SERVER'), null)->getValueType(), + $this->reflectionProvider->getConstant(new Node\Name('INPUT_ENV'), null)->getValueType(), + ); + + if (!$inputArgType->isInteger()->yes() || $supportedTypes->isSuperTypeOf($inputArgType)->no()) { + return null; + } + + // Pragmatical solution since global expressions are not passed through the scope for performance reasons + // See https://github.com/phpstan/phpstan-src/pull/2012 for details + $inputArgType = new ArrayType(new StringType(), new MixedType()); + } + + $filterArgType = $scope->getType($functionCall->getArgs()[1]->value); + $filterConstantArrayType = $filterArgType->getConstantArrays()[0] ?? null; + $addEmptyType = isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : null; + $addEmpty = $addEmptyType === null || $addEmptyType->isTrue()->yes(); + + $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); + + if ($filterArgType instanceof ConstantIntegerType) { + if ($inputConstantArrayType === null) { + $isList = $inputArgType->isList()->yes(); + $valueType = $this->filterFunctionReturnTypeHelper->getType( + $inputArgType->getIterableValueType(), + $filterArgType, + null, + ); + $arrayType = new ArrayType($inputArgType->getIterableKeyType(), $valueType); + + return $isList ? TypeCombinator::intersect($arrayType, new AccessoryArrayListType()) : $arrayType; + } + + // Override $add_empty option + $addEmpty = false; + + $keysType = $inputConstantArrayType; + $inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes()); + $filterTypesMap = array_fill_keys($inputKeysList, $filterArgType); + $inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes()); + $optionalKeys = []; + foreach ($inputConstantArrayType->getOptionalKeys() as $index) { + if (!isset($inputKeysList[$index])) { + continue; + } + + $optionalKeys[] = $inputKeysList[$index]; + } + } elseif ($filterConstantArrayType === null) { + if ($inputConstantArrayType === null) { + $isList = $inputArgType->isList()->yes(); + $valueType = $this->filterFunctionReturnTypeHelper->getType($inputArgType, $filterArgType, null); + + $arrayType = new ArrayType( + $inputArgType->getIterableKeyType(), + $addEmpty ? TypeCombinator::addNull($valueType) : $valueType, + ); + + return $isList ? TypeCombinator::intersect($arrayType, new AccessoryArrayListType()) : $arrayType; + } + + return null; + } else { + $keysType = $filterConstantArrayType; + $filterKeyTypes = $filterConstantArrayType->getKeyTypes(); + $filterKeysList = array_map(static fn ($type) => $type->getValue(), $filterKeyTypes); + $filterTypesMap = array_combine($filterKeysList, $keysType->getValueTypes()); + + if ($inputConstantArrayType !== null) { + $inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes()); + $inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes()); + + $optionalKeys = []; + foreach ($inputConstantArrayType->getOptionalKeys() as $index) { + if (!isset($inputKeysList[$index])) { + continue; + } + + $optionalKeys[] = $inputKeysList[$index]; + } + } else { + $optionalKeys = $filterKeysList; + $inputTypesMap = array_fill_keys($optionalKeys, $inputArgType->getIterableValueType()); + } + } + + foreach ($keysType->getKeyTypes() as $keyType) { + $optional = false; + $key = $keyType->getValue(); + $inputType = $inputTypesMap[$key] ?? null; + if ($inputType === null) { + if ($addEmpty) { + $valueTypesBuilder->setOffsetValueType($keyType, new NullType()); + } + + continue; + } + + [$filterType, $flagsType] = $this->fetchFilter($filterTypesMap[$key] ?? new MixedType()); + $valueType = $this->filterFunctionReturnTypeHelper->getType($inputType, $filterType, $flagsType); + + if (in_array($key, $optionalKeys, true)) { + if ($addEmpty) { + $valueType = TypeCombinator::addNull($valueType); + } else { + $optional = true; + } + } + + $valueTypesBuilder->setOffsetValueType($keyType, $valueType, $optional); + } + + return $valueTypesBuilder->getArray(); + } + + /** @return array{?Type, ?Type} */ + public function fetchFilter(Type $type): array + { + if (!$type->isArray()->yes()) { + return [$type, null]; + } + + $filterKey = new ConstantStringType('filter'); + if (!$type->hasOffsetValueType($filterKey)->yes()) { + return [$type, null]; + } + + $filterOffsetType = $type->getOffsetValueType($filterKey); + $filterType = null; + + if (count($filterOffsetType->getConstantScalarTypes()) > 0) { + $filterType = TypeCombinator::union(...$filterOffsetType->getConstantScalarTypes()); + } + + return [$filterType, $type]; + } + +} diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php new file mode 100644 index 00000000..b60cb110 --- /dev/null +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -0,0 +1,39 @@ +getName()) === 'filter_var'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $inputType = $scope->getType($functionCall->getArgs()[0]->value); + $filterType = isset($functionCall->getArgs()[1]) ? $scope->getType($functionCall->getArgs()[1]->value) : null; + $flagsType = isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : null; + + return $this->filterFunctionReturnTypeHelper->getType($inputType, $filterType, $flagsType); + } + +} diff --git a/src/Type/Php/FunctionExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/FunctionExistsFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..367fd4e6 --- /dev/null +++ b/src/Type/Php/FunctionExistsFunctionTypeSpecifyingExtension.php @@ -0,0 +1,63 @@ +getName() === 'function_exists' && isset($node->getArgs()[0]) && $context->true(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $argType = $scope->getType($node->getArgs()[0]->value); + if ($argType instanceof ConstantStringType) { + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('function_exists'), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]), + new ConstantBooleanType(true), + $context, + $scope, + ); + } + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + new CallableType(), + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/GetCalledClassDynamicReturnTypeExtension.php b/src/Type/Php/GetCalledClassDynamicReturnTypeExtension.php new file mode 100644 index 00000000..2109a411 --- /dev/null +++ b/src/Type/Php/GetCalledClassDynamicReturnTypeExtension.php @@ -0,0 +1,31 @@ +getName() === 'get_called_class'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + if ($scope->isInClass()) { + return $scope->getType(new ClassConstFetch(new Name('static'), 'class')); + } + return new ConstantBooleanType(false); + } + +} diff --git a/src/Type/Php/GetClassDynamicReturnTypeExtension.php b/src/Type/Php/GetClassDynamicReturnTypeExtension.php new file mode 100644 index 00000000..fb01eca9 --- /dev/null +++ b/src/Type/Php/GetClassDynamicReturnTypeExtension.php @@ -0,0 +1,96 @@ +getName() === 'get_class'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $args = $functionCall->getArgs(); + + if (count($args) === 0) { + if ($scope->isInTrait()) { + return new ClassStringType(); + } + + if ($scope->isInClass()) { + return new ConstantStringType($scope->getClassReflection()->getName(), true); + } + + return new ConstantBooleanType(false); + } + + $argType = $scope->getType($args[0]->value); + + if ($scope->isInTrait() && TypeUtils::findThisType($argType) !== null) { + return new ClassStringType(); + } + + return TypeTraverser::map( + $argType, + static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof EnumCaseObjectType) { + return new GenericClassStringType(new ObjectType($type->getClassName())); + } + + $objectClassNames = $type->getObjectClassNames(); + if ($type instanceof TemplateType && $objectClassNames === []) { + if ($type instanceof ObjectWithoutClassType) { + return new GenericClassStringType($type); + } + + return new UnionType([ + new GenericClassStringType($type), + new ConstantBooleanType(false), + ]); + } elseif ($type instanceof MixedType) { + return new UnionType([ + new ClassStringType(), + new ConstantBooleanType(false), + ]); + } elseif ($type instanceof StaticType) { + return new GenericClassStringType($type->getStaticObjectType()); + } elseif ($objectClassNames !== []) { + return new GenericClassStringType($type); + } elseif ($type instanceof ObjectWithoutClassType) { + return new ClassStringType(); + } + + return new ConstantBooleanType(false); + }, + ); + } + +} diff --git a/src/Type/Php/GetDebugTypeFunctionReturnTypeExtension.php b/src/Type/Php/GetDebugTypeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..7feb6649 --- /dev/null +++ b/src/Type/Php/GetDebugTypeFunctionReturnTypeExtension.php @@ -0,0 +1,103 @@ +getName() === 'get_debug_type'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + if ($argType instanceof UnionType) { + return new UnionType(array_map(Closure::fromCallable([self::class, 'resolveOneType']), $argType->getTypes())); + } + return self::resolveOneType($argType); + } + + /** + * @see https://www.php.net/manual/en/function.get-debug-type.php#refsect1-function.get-debug-type-returnvalues + * @see https://github.com/php/php-src/commit/ef0e4478c51540510b67f7781ad240f5e0592ee4 + */ + private static function resolveOneType(Type $type): Type + { + if ($type->isNull()->yes()) { + return new ConstantStringType('null'); + } + if ($type->isBoolean()->yes()) { + return new ConstantStringType('bool'); + } + if ($type->isInteger()->yes()) { + return new ConstantStringType('int'); + } + if ($type->isFloat()->yes()) { + return new ConstantStringType('float'); + } + if ($type->isString()->yes()) { + return new ConstantStringType('string'); + } + if ($type->isArray()->yes()) { + return new ConstantStringType('array'); + } + + // "resources" type+state is skipped since we cannot infer the state + + if ($type->isObject()->yes()) { + $reflections = $type->getObjectClassReflections(); + $types = []; + foreach ($reflections as $reflection) { + // if the class is not final, the actual returned string might be of a child class + if ($reflection->isFinal() && !$reflection->isAnonymous()) { + $types[] = new ConstantStringType($reflection->getName()); + } + + if ($reflection->isAnonymous()) { // phpcs:ignore + $parentClass = $reflection->getParentClass(); + $implementedInterfaces = $reflection->getImmediateInterfaces(); + if ($parentClass !== null) { + $types[] = new ConstantStringType($parentClass->getName() . '@anonymous'); + } elseif ($implementedInterfaces !== []) { + $firstInterface = $implementedInterfaces[array_key_first($implementedInterfaces)]; + $types[] = new ConstantStringType($firstInterface->getName() . '@anonymous'); + } else { + $types[] = new ConstantStringType('class@anonymous'); + } + } + } + + switch (count($types)) { + case 0: + return new StringType(); + case 1: + return $types[0]; + default: + return new UnionType($types); + } + } + + return new StringType(); + } + +} diff --git a/src/Type/Php/GetDefinedVarsFunctionReturnTypeExtension.php b/src/Type/Php/GetDefinedVarsFunctionReturnTypeExtension.php new file mode 100644 index 00000000..27eef977 --- /dev/null +++ b/src/Type/Php/GetDefinedVarsFunctionReturnTypeExtension.php @@ -0,0 +1,47 @@ +getName() === 'get_defined_vars'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + if ($scope->canAnyVariableExist()) { + return new ArrayType( + new StringType(), + new MixedType(), + ); + } + + $typeBuilder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($scope->getDefinedVariables() as $variable) { + $typeBuilder->setOffsetValueType(new ConstantStringType($variable), $scope->getVariableType($variable), false); + } + + foreach ($scope->getMaybeDefinedVariables() as $variable) { + $typeBuilder->setOffsetValueType(new ConstantStringType($variable), $scope->getVariableType($variable), true); + } + + return $typeBuilder->getArray(); + } + +} diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php new file mode 100644 index 00000000..dc6d0983 --- /dev/null +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,105 @@ +getName() === 'get_parent_class'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) === 0) { + if ($scope->isInTrait()) { + return null; + } + if ($scope->isInClass()) { + return $this->findParentClassType( + $scope->getClassReflection(), + ); + } + + return new ConstantBooleanType(false); + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + if ($scope->isInTrait() && TypeUtils::findThisType($argType) !== null) { + return null; + } + + $constantStrings = $argType->getConstantStrings(); + if (count($constantStrings) > 0) { + return TypeCombinator::union(...array_map(fn (ConstantStringType $stringType): Type => $this->findParentClassNameType($stringType->getValue()), $constantStrings)); + } + + $classNames = $argType->getObjectClassNames(); + if (count($classNames) > 0) { + return TypeCombinator::union(...array_map(fn (string $classNames): Type => $this->findParentClassNameType($classNames), $classNames)); + } + + return null; + } + + private function findParentClassNameType(string $className): Type + { + if (!$this->reflectionProvider->hasClass($className)) { + return new UnionType([ + new ClassStringType(), + new ConstantBooleanType(false), + ]); + } + + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->isInterface()) { + return new UnionType([ + new ClassStringType(), + new ConstantBooleanType(false), + ]); + } + + return $this->findParentClassType($classReflection); + } + + private function findParentClassType( + ClassReflection $classReflection, + ): Type + { + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null) { + return new ConstantBooleanType(false); + } + + return new ConstantStringType($parentClass->getName(), true); + } + +} diff --git a/src/Type/Php/GettimeofdayDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GettimeofdayDynamicFunctionReturnTypeExtension.php new file mode 100644 index 00000000..32c7f163 --- /dev/null +++ b/src/Type/Php/GettimeofdayDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,64 @@ +getName() === 'gettimeofday'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $arrayType = new ConstantArrayType([ + new ConstantStringType('sec'), + new ConstantStringType('usec'), + new ConstantStringType('minuteswest'), + new ConstantStringType('dsttime'), + ], [ + new IntegerType(), + new IntegerType(), + new IntegerType(), + new IntegerType(), + ]); + $floatType = new FloatType(); + + if (!isset($functionCall->getArgs()[0])) { + return $arrayType; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $isTrueType = $argType->isTrue(); + $isFalseType = $argType->isFalse(); + $compareTypes = $isTrueType->compareTo($isFalseType); + if ($compareTypes === $isTrueType) { + return $floatType; + } + if ($compareTypes === $isFalseType) { + return $arrayType; + } + + if ($argType instanceof MixedType) { + return new BenevolentUnionType([$arrayType, $floatType]); + } + + return new UnionType([$arrayType, $floatType]); + } + +} diff --git a/src/Type/Php/GettypeFunctionReturnTypeExtension.php b/src/Type/Php/GettypeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..19179b47 --- /dev/null +++ b/src/Type/Php/GettypeFunctionReturnTypeExtension.php @@ -0,0 +1,91 @@ +getName() === 'gettype'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $valueType = $scope->getType($functionCall->getArgs()[0]->value); + + return TypeTraverser::map($valueType, static function (Type $valueType, callable $traverse): Type { + if ($valueType instanceof UnionType || $valueType instanceof IntersectionType) { + return $traverse($valueType); + } + + if ($valueType->isString()->yes()) { + return new ConstantStringType('string'); + } + if ($valueType->isArray()->yes()) { + return new ConstantStringType('array'); + } + + if ($valueType->isBoolean()->yes()) { + return new ConstantStringType('boolean'); + } + + $resource = new ResourceType(); + if ($resource->isSuperTypeOf($valueType)->yes()) { + return new UnionType([ + new ConstantStringType('resource'), + new ConstantStringType('resource (closed)'), + ]); + } + + if ($valueType->isInteger()->yes()) { + return new ConstantStringType('integer'); + } + + if ($valueType->isFloat()->yes()) { + // for historical reasons "double" is returned in case of a float, and not simply "float" + return new ConstantStringType('double'); + } + + if ($valueType->isNull()->yes()) { + return new ConstantStringType('NULL'); + } + + if ($valueType->isObject()->yes()) { + return new ConstantStringType('object'); + } + + return TypeCombinator::union( + new ConstantStringType('string'), + new ConstantStringType('array'), + new ConstantStringType('boolean'), + new ConstantStringType('resource'), + new ConstantStringType('resource (closed)'), + new ConstantStringType('integer'), + new ConstantStringType('double'), + new ConstantStringType('NULL'), + new ConstantStringType('object'), + new ConstantStringType('unknown type'), + ); + }); + } + +} diff --git a/src/Type/Php/HashFunctionsReturnTypeExtension.php b/src/Type/Php/HashFunctionsReturnTypeExtension.php new file mode 100644 index 00000000..697344d6 --- /dev/null +++ b/src/Type/Php/HashFunctionsReturnTypeExtension.php @@ -0,0 +1,158 @@ + [ + 'cryptographic' => false, + 'possiblyFalse' => false, + 'binary' => 2, + ], + 'hash_file' => [ + 'cryptographic' => false, + 'possiblyFalse' => true, + 'binary' => 2, + ], + 'hash_hkdf' => [ + 'cryptographic' => true, + 'possiblyFalse' => false, + 'binary' => true, + ], + 'hash_hmac' => [ + 'cryptographic' => true, + 'possiblyFalse' => false, + 'binary' => 3, + ], + 'hash_hmac_file' => [ + 'cryptographic' => true, + 'possiblyFalse' => true, + 'binary' => 3, + ], + 'hash_pbkdf2' => [ + 'cryptographic' => true, + 'possiblyFalse' => false, + 'binary' => 5, + ], + ]; + + private const NON_CRYPTOGRAPHIC_ALGORITHMS = [ + 'adler32', + 'crc32', + 'crc32b', + 'crc32c', + 'fnv132', + 'fnv1a32', + 'fnv164', + 'fnv1a64', + 'joaat', + 'murmur3a', + 'murmur3c', + 'murmur3f', + 'xxh32', + 'xxh64', + 'xxh3', + 'xxh128', + ]; + + /** @var array */ + private array $hashAlgorithms; + + public function __construct(private PhpVersion $phpVersion) + { + $this->hashAlgorithms = hash_algos(); + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + $name = strtolower($functionReflection->getName()); + return isset(self::SUPPORTED_FUNCTIONS[$name]); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $functionData = self::SUPPORTED_FUNCTIONS[strtolower($functionReflection->getName())]; + if (is_bool($functionData['binary'])) { + $binaryType = new ConstantBooleanType($functionData['binary']); + } elseif (isset($functionCall->getArgs()[$functionData['binary']])) { + $binaryType = $scope->getType($functionCall->getArgs()[$functionData['binary']]->value); + } else { + $binaryType = new ConstantBooleanType(false); + } + + $stringTypes = [ + new StringType(), + new AccessoryNonFalsyStringType(), + ]; + if ($binaryType->isFalse()->yes()) { + $stringTypes[] = new AccessoryLowercaseStringType(); + } + $stringReturnType = new IntersectionType($stringTypes); + + $algorithmType = $scope->getType($functionCall->getArgs()[0]->value); + $constantAlgorithmTypes = $algorithmType->getConstantStrings(); + if (count($constantAlgorithmTypes) === 0) { + if ($functionData['possiblyFalse'] || !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + return TypeUtils::toBenevolentUnion(TypeCombinator::union($stringReturnType, new ConstantBooleanType(false))); + } + + return $stringReturnType; + } + + $neverType = new NeverType(); + $falseType = new ConstantBooleanType(false); + $invalidAlgorithmType = $this->phpVersion->throwsValueErrorForInternalFunctions() ? $neverType : $falseType; + + $returnTypes = array_map( + function (ConstantStringType $type) use ($functionData, $stringReturnType, $invalidAlgorithmType) { + $algorithm = strtolower($type->getValue()); + if (!in_array($algorithm, $this->hashAlgorithms, true)) { + return $invalidAlgorithmType; + } + if ($functionData['cryptographic'] && in_array($algorithm, self::NON_CRYPTOGRAPHIC_ALGORITHMS, true)) { + return $invalidAlgorithmType; + } + return $stringReturnType; + }, + $constantAlgorithmTypes, + ); + + $returnType = TypeCombinator::union(...$returnTypes); + + if ($functionData['possiblyFalse'] && !$neverType->isSuperTypeOf($returnType)->yes()) { + $returnType = TypeCombinator::union($returnType, $falseType); + } + + return $returnType; + } + +} diff --git a/src/Type/Php/HighlightStringDynamicReturnTypeExtension.php b/src/Type/Php/HighlightStringDynamicReturnTypeExtension.php new file mode 100644 index 00000000..9baeb1c4 --- /dev/null +++ b/src/Type/Php/HighlightStringDynamicReturnTypeExtension.php @@ -0,0 +1,52 @@ +getName() === 'highlight_string'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + if ($this->phpVersion->highlightStringDoesNotReturnFalse()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + + $returnType = $scope->getType($args[1]->value); + if ($returnType->isTrue()->yes()) { + return new StringType(); + } + + if ($this->phpVersion->highlightStringDoesNotReturnFalse()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + +} diff --git a/src/Type/Php/HrtimeFunctionReturnTypeExtension.php b/src/Type/Php/HrtimeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..2c9148d4 --- /dev/null +++ b/src/Type/Php/HrtimeFunctionReturnTypeExtension.php @@ -0,0 +1,51 @@ +getName() === 'hrtime'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $arrayType = new ConstantArrayType([new ConstantIntegerType(0), new ConstantIntegerType(1)], [new IntegerType(), new IntegerType()], [2], [], TrinaryLogic::createYes()); + $numberType = TypeUtils::toBenevolentUnion(TypeCombinator::union(new IntegerType(), new FloatType())); + + if (count($functionCall->getArgs()) < 1) { + return $arrayType; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $isTrueType = $argType->isTrue(); + $isFalseType = $argType->isFalse(); + $compareTypes = $isTrueType->compareTo($isFalseType); + if ($compareTypes === $isTrueType) { + return $numberType; + } + if ($compareTypes === $isFalseType) { + return $arrayType; + } + + return TypeCombinator::union($arrayType, $numberType); + } + +} diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..9b6807ed --- /dev/null +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -0,0 +1,146 @@ +getName(), [ + 'implode', + 'join', + ], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $args = $functionCall->getArgs(); + if (count($args) === 1) { + $argType = $scope->getType($args[0]->value); + if ($argType->isArray()->yes()) { + return $this->implode($argType, new ConstantStringType('')); + } + } + + if (count($args) !== 2) { + return new StringType(); + } + + $separatorType = $scope->getType($args[0]->value); + $arrayType = $scope->getType($args[1]->value); + + return $this->implode($arrayType, $separatorType); + } + + private function implode(Type $arrayType, Type $separatorType): Type + { + if (count($arrayType->getConstantArrays()) > 0 && count($separatorType->getConstantStrings()) > 0) { + $result = []; + foreach ($separatorType->getConstantStrings() as $separator) { + foreach ($arrayType->getConstantArrays() as $constantArray) { + $constantType = $this->inferConstantType($constantArray, $separator); + if ($constantType !== null) { + $result[] = $constantType; + continue; + } + + $result = []; + break 2; + } + } + + if (count($result) > 0) { + return TypeCombinator::union(...$result); + } + } + + $accessoryTypes = []; + if ($arrayType->isIterableAtLeastOnce()->yes()) { + if ($arrayType->getIterableValueType()->isNonFalsyString()->yes() || $separatorType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($arrayType->getIterableValueType()->isNonEmptyString()->yes() || $separatorType->isNonEmptyString()->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + } + + // implode is one of the four functions that can produce literal strings as blessed by the original RFC: wiki.php.net/rfc/is_literal + if ($arrayType->getIterableValueType()->isLiteralString()->yes() && $separatorType->isLiteralString()->yes()) { + $accessoryTypes[] = new AccessoryLiteralStringType(); + } + if ($arrayType->getIterableValueType()->isLowercaseString()->yes() && $separatorType->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($arrayType->getIterableValueType()->isUppercaseString()->yes() && $separatorType->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + return new IntersectionType($accessoryTypes); + } + + return new StringType(); + } + + private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType): ?Type + { + $strings = []; + foreach ($arrayType->getAllArrays() as $array) { + $valueTypes = $array->getValueTypes(); + + $arrayValues = []; + $combinationsCount = 1; + foreach ($valueTypes as $valueType) { + $constScalars = $valueType->getConstantScalarValues(); + if (count($constScalars) === 0) { + return null; + } + $arrayValues[] = $constScalars; + $combinationsCount *= count($constScalars); + } + + if ($combinationsCount > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return null; + } + + $combinations = CombinationsHelper::combinations($arrayValues); + foreach ($combinations as $combination) { + $strings[] = new ConstantStringType(implode($separatorType->getValue(), $combination)); + } + } + + if (count($strings) > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return null; + } + + return TypeCombinator::union(...$strings); + } + +} diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..5d8d02f7 --- /dev/null +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -0,0 +1,168 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return strtolower($functionReflection->getName()) === 'in_array' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $argsCount = count($node->getArgs()); + if ($argsCount < 2) { + return new SpecifiedTypes(); + } + + $isStrictComparison = false; + if ($argsCount >= 3) { + $strictNodeType = $scope->getType($node->getArgs()[2]->value); + $isStrictComparison = $strictNodeType->isTrue()->yes(); + } + + $needleExpr = $node->getArgs()[0]->value; + $arrayExpr = $node->getArgs()[1]->value; + + $needleType = $scope->getType($needleExpr); + $arrayType = $scope->getType($arrayExpr); + $arrayValueType = $arrayType->getIterableValueType(); + + $isStrictComparison = $isStrictComparison + || $needleType->isEnum()->yes() + || $arrayValueType->isEnum()->yes(); + + if ($arrayExpr instanceof Array_) { + $types = null; + foreach ($arrayExpr->items as $item) { + if ($item->unpack) { + $types = null; + break; + } + + if ($isStrictComparison) { + $itemTypes = $this->typeSpecifier->resolveIdentical(new Identical($needleExpr, $item->value), $scope, $context); + } else { + $itemTypes = $this->typeSpecifier->resolveEqual(new Equal($needleExpr, $item->value), $scope, $context); + } + + if ($types === null) { + $types = $itemTypes; + continue; + } + + $types = $context->true() ? $types->normalize($scope)->intersectWith($itemTypes->normalize($scope)) : $types->unionWith($itemTypes); + } + + if ($types !== null) { + return $types; + } + } + + if (!$isStrictComparison) { + if ( + $context->true() + && $arrayType->isArray()->yes() + && $arrayType->getIterableValueType()->isSuperTypeOf($needleType)->yes() + ) { + return $this->typeSpecifier->create( + $node->getArgs()[1]->value, + TypeCombinator::intersect($arrayType, new NonEmptyArrayType()), + $context, + $scope, + ); + } + + return new SpecifiedTypes(); + } + + $specifiedTypes = new SpecifiedTypes(); + if ( + $context->true() + || ( + $context->false() + && count($arrayValueType->getFiniteTypes()) > 0 + && count($needleType->getFiniteTypes()) > 0 + && $arrayType->isIterableAtLeastOnce()->yes() + ) + ) { + $specifiedTypes = $this->typeSpecifier->create( + $needleExpr, + $arrayValueType, + $context, + $scope, + ); + if ($needleExpr instanceof AlwaysRememberedExpr) { + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $needleExpr->getExpr(), + $arrayValueType, + $context, + $scope, + )); + } + } + + if ( + $context->true() + || ( + $context->false() + && count($needleType->getFiniteTypes()) === 1 + ) + ) { + if ($context->true()) { + $arrayValueType = TypeCombinator::union($arrayValueType, $needleType); + } else { + $arrayValueType = TypeCombinator::remove($arrayValueType, $needleType); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $node->getArgs()[1]->value, + new ArrayType(new MixedType(), $arrayValueType), + TypeSpecifierContext::createTrue(), + $scope, + )); + } + + if ($context->true() && $arrayType->isArray()->yes()) { + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $node->getArgs()[1]->value, + TypeCombinator::intersect($arrayType, new NonEmptyArrayType()), + $context, + $scope, + )); + } + + return $specifiedTypes; + } + +} diff --git a/src/Type/Php/IniGetReturnTypeExtension.php b/src/Type/Php/IniGetReturnTypeExtension.php new file mode 100644 index 00000000..3ab92fc5 --- /dev/null +++ b/src/Type/Php/IniGetReturnTypeExtension.php @@ -0,0 +1,65 @@ +getName() === 'ini_get'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 1) { + return null; + } + + $numericString = TypeCombinator::intersect( + new StringType(), + new AccessoryNumericStringType(), + ); + $types = [ + 'date.timezone' => new StringType(), + 'memory_limit' => new StringType(), + 'max_execution_time' => $numericString, + 'max_input_time' => $numericString, + 'default_socket_timeout' => $numericString, + 'precision' => $numericString, + ]; + + $argType = $scope->getType($args[0]->value); + $results = []; + foreach ($argType->getConstantStrings() as $constantString) { + if (!array_key_exists($constantString->getValue(), $types)) { + return null; + } + $results[] = $types[$constantString->getValue()]; + } + + if (count($results) > 0) { + return TypeCombinator::union(...$results); + } + + return null; + } + +} diff --git a/src/Type/Php/IntdivThrowTypeExtension.php b/src/Type/Php/IntdivThrowTypeExtension.php new file mode 100644 index 00000000..e49e72e9 --- /dev/null +++ b/src/Type/Php/IntdivThrowTypeExtension.php @@ -0,0 +1,51 @@ +getName() === 'intdiv'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + if (count($funcCall->getArgs()) < 2) { + return $functionReflection->getThrowType(); + } + + $valueType = $scope->getType($funcCall->getArgs()[0]->value)->toInteger(); + $containsMin = $valueType->isSuperTypeOf(new ConstantIntegerType(PHP_INT_MIN)); + + $divisorType = $scope->getType($funcCall->getArgs()[1]->value)->toInteger(); + if (!$containsMin->no()) { + $divisionByMinusOne = $divisorType->isSuperTypeOf(new ConstantIntegerType(-1)); + if (!$divisionByMinusOne->no()) { + return new ObjectType(ArithmeticError::class); + } + } + + $divisionByZero = $divisorType->isSuperTypeOf(new ConstantIntegerType(0)); + if (!$divisionByZero->no()) { + return new ObjectType(DivisionByZeroError::class); + } + + return null; + } + +} diff --git a/src/Type/Php/IsAFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsAFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..6d88f5f7 --- /dev/null +++ b/src/Type/Php/IsAFunctionTypeSpecifyingExtension.php @@ -0,0 +1,64 @@ +getName()) === 'is_a' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (count($node->getArgs()) < 2) { + return new SpecifiedTypes(); + } + $classType = $scope->getType($node->getArgs()[1]->value); + + if (!$classType instanceof ConstantStringType && !$context->true()) { + return new SpecifiedTypes([], []); + } + + $objectOrClassType = $scope->getType($node->getArgs()[0]->value); + $allowStringType = isset($node->getArgs()[2]) ? $scope->getType($node->getArgs()[2]->value) : new ConstantBooleanType(false); + $allowString = !$allowStringType->equals(new ConstantBooleanType(false)); + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + $this->isAFunctionTypeSpecifyingHelper->determineType($objectOrClassType, $classType, $allowString, true), + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php b/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php new file mode 100644 index 00000000..947e7554 --- /dev/null +++ b/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php @@ -0,0 +1,79 @@ +getObjectClassNames(); + if ($allowString) { + foreach ($objectOrClassType->getConstantStrings() as $constantString) { + $objectOrClassTypeClassNames[] = $constantString->getValue(); + } + $objectOrClassTypeClassNames = array_values(array_unique($objectOrClassTypeClassNames)); + } + + return TypeTraverser::map( + $classType, + static function (Type $type, callable $traverse) use ($objectOrClassTypeClassNames, $allowString, $allowSameClass): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type instanceof ConstantStringType) { + if (!$allowSameClass && $objectOrClassTypeClassNames === [$type->getValue()]) { + return new NeverType(); + } + if ($allowString) { + return TypeCombinator::union( + new ObjectType($type->getValue()), + new GenericClassStringType(new ObjectType($type->getValue())), + ); + } + + return new ObjectType($type->getValue()); + } + if ($type instanceof GenericClassStringType) { + if ($allowString) { + return TypeCombinator::union( + $type->getGenericType(), + $type, + ); + } + + return $type->getGenericType(); + } + if ($allowString) { + return TypeCombinator::union( + new ObjectWithoutClassType(), + new ClassStringType(), + ); + } + + return new ObjectWithoutClassType(); + }, + ); + } + +} diff --git a/src/Type/Php/IsArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsArrayFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..44f63993 --- /dev/null +++ b/src/Type/Php/IsArrayFunctionTypeSpecifyingExtension.php @@ -0,0 +1,47 @@ +getName()) === 'is_array' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!isset($node->getArgs()[0])) { + return new SpecifiedTypes(); + } + if ($context->null()) { + throw new ShouldNotHappenException(); + } + + return $this->typeSpecifier->create($node->getArgs()[0]->value, new ArrayType(new MixedType(true), new MixedType(true)), $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..b08b78f2 --- /dev/null +++ b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php @@ -0,0 +1,70 @@ +getName()) === 'is_callable' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($context->null()) { + throw new ShouldNotHappenException(); + } + + if (!isset($node->getArgs()[0])) { + return new SpecifiedTypes(); + } + + $value = $node->getArgs()[0]->value; + $valueType = $scope->getType($value); + if ( + $value instanceof Array_ + && count($value->items) === 2 + && $valueType->isConstantArray()->yes() + && !$valueType->isCallable()->no() + ) { + $functionCall = new FuncCall(new Name('method_exists'), [ + new Arg($value->items[0]->value), + new Arg($value->items[1]->value), + ]); + return $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); + } + + return $this->typeSpecifier->create($value, new CallableType(), $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/IsIterableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsIterableFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..4502e638 --- /dev/null +++ b/src/Type/Php/IsIterableFunctionTypeSpecifyingExtension.php @@ -0,0 +1,48 @@ +getName()) === 'is_iterable' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($context->null()) { + throw new ShouldNotHappenException(); + } + + if (!isset($node->getArgs()[0])) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create($node->getArgs()[0]->value, new IterableType(new MixedType(), new MixedType()), $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/IsSubclassOfFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsSubclassOfFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..404c85ef --- /dev/null +++ b/src/Type/Php/IsSubclassOfFunctionTypeSpecifyingExtension.php @@ -0,0 +1,65 @@ +getName()) === 'is_subclass_of' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$context->true() || count($node->getArgs()) < 2) { + return new SpecifiedTypes(); + } + + $objectOrClassType = $scope->getType($node->getArgs()[0]->value); + $classType = $scope->getType($node->getArgs()[1]->value); + $allowStringType = isset($node->getArgs()[2]) ? $scope->getType($node->getArgs()[2]->value) : new ConstantBooleanType(true); + $allowString = !$allowStringType->equals(new ConstantBooleanType(false)); + + // prevent false-positives in IsAFunctionTypeSpecifyingHelper + if ($objectOrClassType instanceof GenericClassStringType && $classType instanceof GenericClassStringType) { + return new SpecifiedTypes([], []); + } + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + $this->isAFunctionTypeSpecifyingHelper->determineType($objectOrClassType, $classType, $allowString, false), + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/IteratorToArrayFunctionReturnTypeExtension.php b/src/Type/Php/IteratorToArrayFunctionReturnTypeExtension.php new file mode 100644 index 00000000..64ac2bac --- /dev/null +++ b/src/Type/Php/IteratorToArrayFunctionReturnTypeExtension.php @@ -0,0 +1,60 @@ +getName()) === 'iterator_to_array'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arguments = $functionCall->getArgs(); + + if ($arguments === []) { + return null; + } + + $traversableType = $scope->getType($arguments[0]->value); + + if (isset($arguments[1])) { + $preserveKeysType = $scope->getType($arguments[1]->value); + + if ($preserveKeysType->isFalse()->yes()) { + return TypeCombinator::intersect(new ArrayType( + new IntegerType(), + $traversableType->getIterableValueType(), + ), new AccessoryArrayListType()); + } + } + + $arrayKeyType = $traversableType->getIterableKeyType()->toArrayKey(); + + if ($arrayKeyType instanceof ErrorType) { + return new NeverType(true); + } + + return new ArrayType( + $arrayKeyType, + $traversableType->getIterableValueType(), + ); + } + +} diff --git a/src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php b/src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php new file mode 100644 index 00000000..8c11a78e --- /dev/null +++ b/src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php @@ -0,0 +1,132 @@ + */ + private array $argumentPositions = [ + 'json_encode' => 1, + 'json_decode' => 3, + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private BitwiseFlagHelper $bitwiseFlagAnalyser, + ) + { + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + ): bool + { + if ($functionReflection->getName() === 'json_decode') { + return true; + } + + return $functionReflection->getName() === 'json_encode' && $this->reflectionProvider->hasConstant(new FullyQualified('JSON_THROW_ON_ERROR'), null); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $argumentPosition = $this->argumentPositions[$functionReflection->getName()]; + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + if ($functionReflection->getName() === 'json_decode') { + $defaultReturnType = $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType); + } + + if (!isset($functionCall->getArgs()[$argumentPosition])) { + return $defaultReturnType; + } + + $optionsExpr = $functionCall->getArgs()[$argumentPosition]->value; + if ($functionReflection->getName() === 'json_encode' && $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($optionsExpr, $scope, 'JSON_THROW_ON_ERROR')->yes()) { + return TypeCombinator::remove($defaultReturnType, new ConstantBooleanType(false)); + } + + return $defaultReturnType; + } + + private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type + { + $args = $funcCall->getArgs(); + $isForceArray = $this->isForceArray($funcCall, $scope); + if (!isset($args[0])) { + return $fallbackType; + } + + $firstValueType = $scope->getType($args[0]->value); + if ($firstValueType instanceof ConstantStringType) { + return $this->resolveConstantStringType($firstValueType, $isForceArray); + } + + if ($isForceArray) { + return TypeCombinator::remove($fallbackType, new ObjectWithoutClassType()); + } + + return $fallbackType; + } + + /** + * Is "json_decode(..., true)"? + */ + private function isForceArray(FuncCall $funcCall, Scope $scope): bool + { + $args = $funcCall->getArgs(); + if (!isset($args[1])) { + return false; + } + + $secondArgType = $scope->getType($args[1]->value); + $secondArgValue = $secondArgType instanceof ConstantScalarType ? $secondArgType->getValue() : null; + + if (is_bool($secondArgValue)) { + return $secondArgValue; + } + + if ($secondArgValue !== null || !isset($args[3])) { + return false; + } + + // depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array + return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes(); + } + + private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type + { + $decodedValue = json_decode($constantStringType->getValue(), $isForceArray); + + return ConstantTypeHelper::getTypeFromValue($decodedValue); + } + +} diff --git a/src/Type/Php/JsonThrowTypeExtension.php b/src/Type/Php/JsonThrowTypeExtension.php new file mode 100644 index 00000000..b245e787 --- /dev/null +++ b/src/Type/Php/JsonThrowTypeExtension.php @@ -0,0 +1,65 @@ + 1, + 'json_decode' => 3, + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private BitwiseFlagHelper $bitwiseFlagAnalyser, + ) + { + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + ): bool + { + return in_array( + $functionReflection->getName(), + [ + 'json_encode', + 'json_decode', + ], + true, + ) && $this->reflectionProvider->hasConstant(new Name\FullyQualified('JSON_THROW_ON_ERROR'), null); + } + + public function getThrowTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $argumentPosition = self::ARGUMENTS_POSITIONS[$functionReflection->getName()]; + if (!isset($functionCall->getArgs()[$argumentPosition])) { + return null; + } + + $optionsExpr = $functionCall->getArgs()[$argumentPosition]->value; + if (!$this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($optionsExpr, $scope, 'JSON_THROW_ON_ERROR')->no()) { + return new ObjectType('JsonException'); + } + + return null; + } + +} diff --git a/src/Type/Php/LtrimFunctionReturnTypeExtension.php b/src/Type/Php/LtrimFunctionReturnTypeExtension.php new file mode 100644 index 00000000..bcbe2f75 --- /dev/null +++ b/src/Type/Php/LtrimFunctionReturnTypeExtension.php @@ -0,0 +1,44 @@ +getName() === 'ltrim'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) !== 2) { + return null; + } + + $string = $scope->getType($functionCall->getArgs()[0]->value); + $trimChars = $scope->getType($functionCall->getArgs()[1]->value); + + if ($trimChars instanceof ConstantStringType && $trimChars->getValue() === '\\' && $string->isClassString()->yes()) { + if ($string instanceof ConstantStringType) { + return new ConstantStringType(ltrim($string->getValue(), $trimChars->getValue()), true); + } + + return new ClassStringType(); + } + + return null; + } + +} diff --git a/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php new file mode 100644 index 00000000..85597d5a --- /dev/null +++ b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php @@ -0,0 +1,46 @@ +getName() === 'mb_convert_encoding'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $isString = $argType->isString(); + $isArray = $argType->isArray(); + $compare = $isString->compareTo($isArray); + if ($compare === $isString) { + return new StringType(); + } elseif ($compare === $isArray) { + return new ArrayType(new IntegerType(), new StringType()); + } + + return null; + } + +} diff --git a/src/Type/Php/MbFunctionsReturnTypeExtension.php b/src/Type/Php/MbFunctionsReturnTypeExtension.php new file mode 100644 index 00000000..387cf0e6 --- /dev/null +++ b/src/Type/Php/MbFunctionsReturnTypeExtension.php @@ -0,0 +1,83 @@ + 1, + 'mb_regex_encoding' => 1, + 'mb_internal_encoding' => 1, + 'mb_encoding_aliases' => 1, + 'mb_chr' => 2, + 'mb_ord' => 2, + ]; + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return array_key_exists($functionReflection->getName(), $this->encodingPositionMap); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $returnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + $positionEncodingParam = $this->encodingPositionMap[$functionReflection->getName()]; + + if (count($functionCall->getArgs()) < $positionEncodingParam) { + return TypeCombinator::remove($returnType, new BooleanType()); + } + + $strings = $scope->getType($functionCall->getArgs()[$positionEncodingParam - 1]->value)->getConstantStrings(); + $results = array_unique(array_map(fn (ConstantStringType $encoding): bool => $this->isSupportedEncoding($encoding->getValue()), $strings)); + + if ($returnType->equals(new UnionType([new StringType(), new BooleanType()]))) { + return count($results) === 1 ? new ConstantBooleanType($results[0]) : new BooleanType(); + } + + if (count($results) === 1) { + $invalidEncodingReturn = new ConstantBooleanType(false); + if ($this->phpVersion->throwsOnInvalidMbStringEncoding()) { + $invalidEncodingReturn = new NeverType(); + } + + return $results[0] + ? TypeCombinator::remove($returnType, new ConstantBooleanType(false)) + : $invalidEncodingReturn; + } + + return $returnType; + } + +} diff --git a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php new file mode 100644 index 00000000..5bb18c31 --- /dev/null +++ b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php @@ -0,0 +1,58 @@ +getSupportedEncodings(), true); + } + + /** @return string[] */ + private function getSupportedEncodings(): array + { + if (!is_null($this->supportedEncodings)) { + return $this->supportedEncodings; + } + + $supportedEncodings = []; + if (function_exists('mb_list_encodings')) { + foreach (mb_list_encodings() as $encoding) { + $aliases = @mb_encoding_aliases($encoding); + if ($aliases === false) { + throw new ShouldNotHappenException(); + } + $supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]); + } + } + $this->supportedEncodings = array_map('strtoupper', $supportedEncodings); + + // PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3 + if (!$this->phpVersion->supportsPassNoneEncodings()) { + $this->supportedEncodings = array_filter( + $this->supportedEncodings, + static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true), + ); + } + + return $this->supportedEncodings; + } + +} diff --git a/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php b/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php new file mode 100644 index 00000000..5d3df99d --- /dev/null +++ b/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php @@ -0,0 +1,156 @@ +getName() === 'mb_strlen'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) === 0) { + return null; + } + + $encodings = []; + + if (count($functionCall->getArgs()) === 1) { + // there is a chance to get an unsupported encoding 'pass' or 'none' here on PHP 7.3-7.4 + $encodings = [mb_internal_encoding()]; + } elseif (count($functionCall->getArgs()) === 2) { // custom encoding is specified + $encodings = array_map( + static fn (ConstantStringType $t) => $t->getValue(), + $scope->getType($functionCall->getArgs()[1]->value)->getConstantStrings(), + ); + } + + if (count($encodings) > 0) { + for ($i = 0; $i < count($encodings); $i++) { + if ($this->isSupportedEncoding($encodings[$i])) { + continue; + } + $encodings[$i] = self::UNSUPPORTED_ENCODING; + } + + $encodings = array_unique($encodings); + + if (in_array(self::UNSUPPORTED_ENCODING, $encodings, true) && count($encodings) === 1) { + if ($this->phpVersion->throwsOnInvalidMbStringEncoding()) { + return new NeverType(); + } + return new ConstantBooleanType(false); + } + } else { // if there aren't encoding constants, use all available encodings + $encodings = array_merge($this->getSupportedEncodings(), [self::UNSUPPORTED_ENCODING]); + } + + $argType = $scope->getType($args[0]->value); + $constantScalars = $argType->getConstantScalarTypes(); + + $lengths = []; + foreach ($constantScalars as $constantScalar) { + $stringScalar = $constantScalar->toString(); + if (!($stringScalar instanceof ConstantStringType)) { + $lengths = []; + break; + } + + foreach ($encodings as $encoding) { + if (!$this->isSupportedEncoding($encoding)) { + continue; + } + + $length = @mb_strlen($stringScalar->getValue(), $encoding); + if ($length === false) { + throw new ShouldNotHappenException(sprintf('Got false on a supported encoding %s and value %s', $encoding, var_export($stringScalar->getValue(), true))); + } + $lengths[] = $length; + } + } + + $isNonEmpty = $argType->isNonEmptyString(); + $numeric = TypeCombinator::union(new IntegerType(), new FloatType()); + if (count($lengths) > 0) { + $lengths = array_unique($lengths); + sort($lengths); + if ($lengths === range(min($lengths), max($lengths))) { + $range = IntegerRangeType::fromInterval(min($lengths), max($lengths)); + } else { + $range = TypeCombinator::union(...array_map(static fn ($l) => new ConstantIntegerType($l), $lengths)); + } + } elseif ($argType->isBoolean()->yes()) { + $range = IntegerRangeType::fromInterval(0, 1); + } elseif ( + $isNonEmpty->yes() + || $numeric->isSuperTypeOf($argType)->yes() + || TypeCombinator::remove($argType, $numeric)->isNonEmptyString()->yes() + ) { + $range = IntegerRangeType::fromInterval(1, null); + } elseif ($argType->isString()->yes() && $isNonEmpty->no()) { + $range = new ConstantIntegerType(0); + } else { + $range = TypeCombinator::remove( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(), + new ConstantBooleanType(false), + ); + } + + if (!$this->phpVersion->throwsOnInvalidMbStringEncoding() && in_array(self::UNSUPPORTED_ENCODING, $encodings, true)) { + return TypeCombinator::union($range, new ConstantBooleanType(false)); + } + return $range; + } + +} diff --git a/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php new file mode 100644 index 00000000..385cf1b9 --- /dev/null +++ b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php @@ -0,0 +1,136 @@ +getName() === 'mb_substitute_character'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $minCodePoint = $this->phpVersion->getVersionId() < 80000 ? 1 : 0; + $maxCodePoint = $this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter() ? 0x10FFFF : 0xFFFE; + $ranges = []; + + if ($this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()) { + // Surrogates aren't valid in PHP 7.2+ + $ranges[] = IntegerRangeType::fromInterval($minCodePoint, 0xD7FF); + $ranges[] = IntegerRangeType::fromInterval(0xE000, $maxCodePoint); + } else { + $ranges[] = IntegerRangeType::fromInterval($minCodePoint, $maxCodePoint); + } + + if (!isset($functionCall->getArgs()[0])) { + return TypeCombinator::union( + new ConstantStringType('none'), + new ConstantStringType('long'), + new ConstantStringType('entity'), + ...$ranges, + ); + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $isString = $argType->isString(); + $isNull = $argType->isNull(); + $isInteger = $argType->isInteger(); + + if ($isString->no() && $isNull->no() && $isInteger->no()) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); + } + + return new BooleanType(); + } + + if ($isInteger->yes()) { + $invalidRanges = []; + + foreach ($ranges as $range) { + $isInRange = $range->isSuperTypeOf($argType); + + if ($isInRange->yes()) { + return new ConstantBooleanType(true); + } + + $invalidRanges[] = $isInRange->no(); + } + + if ($argType instanceof ConstantIntegerType || !in_array(false, $invalidRanges, true)) { + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + return new NeverType(); + } + + return new ConstantBooleanType(false); + } + } elseif ($isString->yes()) { + if ($argType->isNonEmptyString()->no()) { + // The empty string was a valid alias for "none" in PHP < 8. + if ($this->phpVersion->isEmptyStringValidAliasForNoneInMbSubstituteCharacter()) { + return new ConstantBooleanType(true); + } + + return new NeverType(); + } + + if (!$this->phpVersion->isNumericStringValidArgInMbSubstituteCharacter() && $argType->isNumericString()->yes()) { + return new NeverType(); + } + + if ($argType instanceof ConstantStringType) { + $value = strtolower($argType->getValue()); + + if (in_array($value, ['none', 'long', 'entity'], true)) { + return new ConstantBooleanType(true); + } + + if ($argType->isNumericString()->yes()) { + $codePoint = (int) $value; + $isValid = $codePoint >= $minCodePoint && $codePoint <= $maxCodePoint; + + if ($this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()) { + $isValid = $isValid && ($codePoint < 0xD800 || $codePoint > 0xDFFF); + } + + return new ConstantBooleanType($isValid); + } + + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + return new NeverType(); + } + + return new ConstantBooleanType(false); + } + } elseif ($isNull->yes()) { + // The $substitute_character arg is nullable in PHP 8+ + return new ConstantBooleanType($this->phpVersion->isNullValidArgInMbSubstituteCharacter()); + } + + return new BooleanType(); + } + +} diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php new file mode 100644 index 00000000..b80389d5 --- /dev/null +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -0,0 +1,86 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return $functionReflection->getName() === 'method_exists' + && $context->true() + && count($node->getArgs()) >= 2; + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $methodNameType = $scope->getType($node->getArgs()[1]->value); + if (!$methodNameType instanceof ConstantStringType) { + return new SpecifiedTypes([], []); + } + + $objectType = $scope->getType($node->getArgs()[0]->value); + if ($objectType->isString()->yes()) { + if ($objectType->isClassString()->yes()) { + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + new IntersectionType([ + $objectType, + new HasMethodType($methodNameType->getValue()), + ]), + $context, + $scope, + ); + } + + return new SpecifiedTypes([], []); + } + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + new UnionType([ + new IntersectionType([ + new ObjectWithoutClassType(), + new HasMethodType($methodNameType->getValue()), + ]), + new ClassStringType(), + ]), + $context, + $scope, + ); + } + +} diff --git a/src/Type/Php/MicrotimeFunctionReturnTypeExtension.php b/src/Type/Php/MicrotimeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..91bbb2bf --- /dev/null +++ b/src/Type/Php/MicrotimeFunctionReturnTypeExtension.php @@ -0,0 +1,50 @@ +getName() === 'microtime'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + if (count($functionCall->getArgs()) < 1) { + return new StringType(); + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + $isTrueType = $argType->isTrue(); + $isFalseType = $argType->isFalse(); + $compareTypes = $isTrueType->compareTo($isFalseType); + if ($compareTypes === $isTrueType) { + return new FloatType(); + } + if ($compareTypes === $isFalseType) { + return new StringType(); + } + + if ($argType instanceof MixedType) { + return new BenevolentUnionType([new StringType(), new FloatType()]); + } + + return new UnionType([new StringType(), new FloatType()]); + } + +} diff --git a/src/Type/Php/MinMaxFunctionReturnTypeExtension.php b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php new file mode 100644 index 00000000..a2eccd38 --- /dev/null +++ b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php @@ -0,0 +1,243 @@ +getName(), ['min', 'max'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + if (count($functionCall->getArgs()) === 1) { + $argType = $scope->getType($functionCall->getArgs()[0]->value); + if ($argType->isArray()->yes()) { + return $this->processArrayType( + $functionReflection->getName(), + $argType, + ); + } + + return new ErrorType(); + } + + // rewrite min($x, $y) as $x < $y ? $x : $y + // we don't handle arrays, which have different semantics + $functionName = $functionReflection->getName(); + $args = $functionCall->getArgs(); + if (count($functionCall->getArgs()) === 2) { + $argType0 = $scope->getType($args[0]->value); + $argType1 = $scope->getType($args[1]->value); + + if ($argType0->isArray()->no() && $argType1->isArray()->no()) { + if ($functionName === 'min') { + return $scope->getType(new Ternary( + new Smaller($args[0]->value, $args[1]->value), + $args[0]->value, + $args[1]->value, + )); + } elseif ($functionName === 'max') { + return $scope->getType(new Ternary( + new Smaller($args[0]->value, $args[1]->value), + $args[1]->value, + $args[0]->value, + )); + } + } + } + + $argumentTypes = []; + foreach ($functionCall->getArgs() as $arg) { + $argType = $scope->getType($arg->value); + if ($arg->unpack) { + $iterableValueType = $argType->getIterableValueType(); + if ($iterableValueType instanceof UnionType) { + foreach ($iterableValueType->getTypes() as $innerType) { + $argumentTypes[] = $innerType; + } + } else { + $argumentTypes[] = $iterableValueType; + } + continue; + } + + $argumentTypes[] = $argType; + } + + return $this->processType( + $functionName, + $argumentTypes, + ); + } + + private function processArrayType(string $functionName, Type $argType): Type + { + $constArrayTypes = $argType->getConstantArrays(); + if (count($constArrayTypes) > 0) { + $resultTypes = []; + foreach ($constArrayTypes as $constArrayType) { + $isIterable = $constArrayType->isIterableAtLeastOnce(); + if ($isIterable->no() && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + $resultTypes[] = new ConstantBooleanType(false); + continue; + } + $argumentTypes = []; + if (!$isIterable->yes() && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + $argumentTypes[] = new ConstantBooleanType(false); + } + + foreach ($constArrayType->getValueTypes() as $innerType) { + $argumentTypes[] = $innerType; + } + + $resultTypes[] = $this->processType($functionName, $argumentTypes); + } + + return TypeCombinator::union(...$resultTypes); + } + + $isIterable = $argType->isIterableAtLeastOnce(); + if ($isIterable->no() && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + return new ConstantBooleanType(false); + } + $iterableValueType = $argType->getIterableValueType(); + $argumentTypes = []; + if (!$isIterable->yes() && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + $argumentTypes[] = new ConstantBooleanType(false); + } + + $argumentTypes[] = $iterableValueType; + + return $this->processType($functionName, $argumentTypes); + } + + /** + * @param Type[] $types + */ + private function processType( + string $functionName, + array $types, + ): Type + { + $resultType = null; + foreach ($types as $type) { + if ($resultType === null) { + $resultType = $type; + continue; + } + + $compareResult = $this->compareTypes($resultType, $type); + if ($compareResult === null) { + return TypeCombinator::union(...$types); + } + + if ($functionName === 'min') { + if ($compareResult === $type) { + $resultType = $type; + } + } elseif ($functionName === 'max') { + if ($compareResult === $resultType) { + $resultType = $type; + } + } + } + + if ($resultType === null) { + return new ErrorType(); + } + + return $resultType; + } + + private function compareTypes( + Type $firstType, + Type $secondType, + ): ?Type + { + if ( + $firstType->isArray()->yes() + && $secondType->isConstantScalarValue()->yes() + ) { + return $secondType; + } + + if ( + $firstType->isConstantScalarValue()->yes() + && $secondType->isArray()->yes() + ) { + return $firstType; + } + + if ( + $firstType instanceof ConstantArrayType + && $secondType instanceof ConstantArrayType + ) { + if ($secondType->getArraySize() < $firstType->getArraySize()) { + return $secondType; + } elseif ($firstType->getArraySize() < $secondType->getArraySize()) { + return $firstType; + } + + foreach ($firstType->getValueTypes() as $i => $firstValueType) { + $secondValueType = $secondType->getValueTypes()[$i]; + $compareResult = $this->compareTypes($firstValueType, $secondValueType); + if ($compareResult === $firstValueType) { + return $firstType; + } + + if ($compareResult === $secondValueType) { + return $secondType; + } + } + + return null; + } + + if ( + $firstType instanceof ConstantScalarType + && $secondType instanceof ConstantScalarType + ) { + if ($secondType->getValue() < $firstType->getValue()) { + return $secondType; + } + + if ($firstType->getValue() < $secondType->getValue()) { + return $firstType; + } + } + + return null; + } + +} diff --git a/src/Type/Php/NonEmptyStringFunctionsReturnTypeExtension.php b/src/Type/Php/NonEmptyStringFunctionsReturnTypeExtension.php new file mode 100644 index 00000000..b3a700b9 --- /dev/null +++ b/src/Type/Php/NonEmptyStringFunctionsReturnTypeExtension.php @@ -0,0 +1,96 @@ +getName(), [ + 'addslashes', + 'addcslashes', + 'escapeshellarg', + 'escapeshellcmd', + 'htmlspecialchars', + 'htmlentities', + 'urlencode', + 'urldecode', + 'preg_quote', + 'rawurlencode', + 'rawurldecode', + ], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) === 0) { + return null; + } + + if (in_array($functionReflection->getName(), [ + 'htmlspecialchars', + 'htmlentities', + ], true)) { + if (!$this->isSubstituteFlagSet($args, $scope)) { + return new StringType(); + } + } + + $argType = $scope->getType($args[0]->value); + if ($argType->isNonFalsyString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]); + } + if ($argType->isNonEmptyString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]); + } + + return new StringType(); + } + + /** + * @param Arg[] $args + */ + private function isSubstituteFlagSet( + array $args, + Scope $scope, + ): bool + { + if (!isset($args[1])) { + return true; + } + $flagsType = $scope->getType($args[1]->value); + if (!$flagsType instanceof ConstantIntegerType) { + return false; + } + return (bool) ($flagsType->getValue() & ENT_SUBSTITUTE); + } + +} diff --git a/src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php b/src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..df0a05b0 --- /dev/null +++ b/src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,50 @@ +getName() === 'number_format'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $stringType = new StringType(); + if (!isset($functionCall->getArgs()[3])) { + return $stringType; + } + + $thousandsType = $scope->getType($functionCall->getArgs()[3]->value); + $decimalType = $scope->getType($functionCall->getArgs()[2]->value); + + if (!$thousandsType instanceof ConstantStringType || $thousandsType->getValue() !== '') { + return $stringType; + } + + if (!$decimalType instanceof ConstantScalarType || !in_array($decimalType->getValue(), [null, '.', ''], true)) { + return $stringType; + } + + return new IntersectionType([ + $stringType, + new AccessoryNumericStringType(), + ]); + } + +} diff --git a/src/Type/Php/OpenSslEncryptParameterOutTypeExtension.php b/src/Type/Php/OpenSslEncryptParameterOutTypeExtension.php new file mode 100644 index 00000000..b3aca177 --- /dev/null +++ b/src/Type/Php/OpenSslEncryptParameterOutTypeExtension.php @@ -0,0 +1,71 @@ +getName() === 'openssl_encrypt' && $parameter->getName() === 'tag'; + } + + public function getParameterOutTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + $cipherArg = $args[1] ?? null; + + if ($cipherArg === null) { + return null; + } + + $tagTypes = []; + + foreach ($scope->getType($cipherArg->value)->getConstantStrings() as $cipherType) { + $cipher = strtolower($cipherType->getValue()); + $mode = substr($cipher, -3); + + if (!in_array($cipher, openssl_get_cipher_methods(), true)) { + $tagTypes[] = new NullType(); + continue; + } + + if (in_array($mode, ['gcm', 'ccm'], true)) { + $tagTypes[] = TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ); + + continue; + } + + $tagTypes[] = new NullType(); + } + + if ($tagTypes === []) { + return TypeCombinator::addNull(TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + )); + } + + return TypeCombinator::union(...$tagTypes); + } + +} diff --git a/src/Type/Php/ParseStrParameterOutTypeExtension.php b/src/Type/Php/ParseStrParameterOutTypeExtension.php new file mode 100644 index 00000000..e98ad1d4 --- /dev/null +++ b/src/Type/Php/ParseStrParameterOutTypeExtension.php @@ -0,0 +1,61 @@ +getName()), ['parse_str', 'mb_parse_str'], true) + && $parameter->getName() === 'result'; + } + + public function getParameterOutTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + if (count($args) < 1) { + return null; + } + + $stringType = $scope->getType($args[0]->value); + $accessory = []; + if ($stringType->isLowercaseString()->yes()) { + $accessory[] = new AccessoryLowercaseStringType(); + } + if ($stringType->isUppercaseString()->yes()) { + $accessory[] = new AccessoryUppercaseStringType(); + } + if (count($accessory) > 0) { + $accessory[] = new StringType(); + $valueType = new IntersectionType($accessory); + } else { + $valueType = new StringType(); + } + + return new ArrayType( + new UnionType([new StringType(), new IntegerType()]), + new UnionType([new ArrayType(new MixedType(), new MixedType(true)), $valueType]), + ); + } + +} diff --git a/src/Type/Php/ParseUrlFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ParseUrlFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..b0549464 --- /dev/null +++ b/src/Type/Php/ParseUrlFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,229 @@ +|null */ + private ?array $componentTypesPairedConstants = null; + + /** @var array|null */ + private ?array $componentTypesPairedStrings = null; + + /** @var array|null */ + private ?array $componentTypesPairedConstantsForLowercaseString = null; + + /** @var array|null */ + private ?array $componentTypesPairedStringsForLowercaseString = null; + + private ?Type $allComponentsTogetherType = null; + + private ?Type $allComponentsTogetherTypeForLowercaseString = null; + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return $functionReflection->getName() === 'parse_url'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + $this->cacheReturnTypes(); + + $urlType = $scope->getType($functionCall->getArgs()[0]->value); + if (count($functionCall->getArgs()) > 1) { + $componentType = $scope->getType($functionCall->getArgs()[1]->value); + + if (!$componentType->isConstantValue()->yes()) { + return $this->createAllComponentsReturnType($urlType->isLowercaseString()->yes()); + } + + $componentType = $componentType->toInteger(); + if (!$componentType instanceof ConstantIntegerType) { + return $this->createAllComponentsReturnType($urlType->isLowercaseString()->yes()); + } + } else { + $componentType = new ConstantIntegerType(-1); + } + + if (count($urlType->getConstantStrings()) > 0) { + $types = []; + foreach ($urlType->getConstantStrings() as $constantString) { + try { + $result = @parse_url($constantString->getValue(), $componentType->getValue()); + } catch (ValueError) { + $types[] = new ConstantBooleanType(false); + continue; + } + + $types[] = $scope->getTypeFromValue($result); + } + + return TypeCombinator::union(...$types); + } + + if ($componentType->getValue() === -1) { + return TypeCombinator::union( + $this->createComponentsArray($urlType->isLowercaseString()->yes()), + new ConstantBooleanType(false), + ); + } + + if ($urlType->isLowercaseString()->yes()) { + return $this->componentTypesPairedConstantsForLowercaseString[$componentType->getValue()] ?? new ConstantBooleanType(false); + } + + return $this->componentTypesPairedConstants[$componentType->getValue()] ?? new ConstantBooleanType(false); + } + + private function createAllComponentsReturnType(bool $urlIsLowercase): Type + { + if ($urlIsLowercase) { + if ($this->allComponentsTogetherTypeForLowercaseString === null) { + $returnTypes = [ + new ConstantBooleanType(false), + new NullType(), + IntegerRangeType::fromInterval(0, 65535), + new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]), + $this->createComponentsArray(true), + ]; + + $this->allComponentsTogetherTypeForLowercaseString = TypeCombinator::union(...$returnTypes); + } + + return $this->allComponentsTogetherTypeForLowercaseString; + } + + if ($this->allComponentsTogetherType === null) { + $returnTypes = [ + new ConstantBooleanType(false), + new NullType(), + IntegerRangeType::fromInterval(0, 65535), + new StringType(), + $this->createComponentsArray(false), + ]; + + $this->allComponentsTogetherType = TypeCombinator::union(...$returnTypes); + } + + return $this->allComponentsTogetherType; + } + + private function createComponentsArray(bool $urlIsLowercase): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + if ($urlIsLowercase) { + if ($this->componentTypesPairedStringsForLowercaseString === null) { + throw new ShouldNotHappenException(); + } + + foreach ($this->componentTypesPairedStringsForLowercaseString as $componentName => $componentValueType) { + $builder->setOffsetValueType(new ConstantStringType($componentName), $componentValueType, true); + } + } else { + if ($this->componentTypesPairedStrings === null) { + throw new ShouldNotHappenException(); + } + + foreach ($this->componentTypesPairedStrings as $componentName => $componentValueType) { + $builder->setOffsetValueType(new ConstantStringType($componentName), $componentValueType, true); + } + } + + return $builder->getArray(); + } + + private function cacheReturnTypes(): void + { + if ($this->componentTypesPairedConstants !== null) { + return; + } + + $string = new StringType(); + $lowercaseString = new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]); + $port = IntegerRangeType::fromInterval(0, 65535); + $false = new ConstantBooleanType(false); + $null = new NullType(); + + $stringOrFalseOrNull = TypeCombinator::union($string, $false, $null); + $lowercaseStringOrFalseOrNull = TypeCombinator::union($lowercaseString, $false, $null); + $portOrFalseOrNull = TypeCombinator::union($port, $false, $null); + + $this->componentTypesPairedConstants = [ + PHP_URL_SCHEME => $stringOrFalseOrNull, + PHP_URL_HOST => $stringOrFalseOrNull, + PHP_URL_PORT => $portOrFalseOrNull, + PHP_URL_USER => $stringOrFalseOrNull, + PHP_URL_PASS => $stringOrFalseOrNull, + PHP_URL_PATH => $stringOrFalseOrNull, + PHP_URL_QUERY => $stringOrFalseOrNull, + PHP_URL_FRAGMENT => $stringOrFalseOrNull, + ]; + $this->componentTypesPairedConstantsForLowercaseString = [ + PHP_URL_SCHEME => $lowercaseStringOrFalseOrNull, + PHP_URL_HOST => $lowercaseStringOrFalseOrNull, + PHP_URL_PORT => $portOrFalseOrNull, + PHP_URL_USER => $lowercaseStringOrFalseOrNull, + PHP_URL_PASS => $lowercaseStringOrFalseOrNull, + PHP_URL_PATH => $lowercaseStringOrFalseOrNull, + PHP_URL_QUERY => $lowercaseStringOrFalseOrNull, + PHP_URL_FRAGMENT => $lowercaseStringOrFalseOrNull, + ]; + + $this->componentTypesPairedStrings = [ + 'scheme' => $string, + 'host' => $string, + 'port' => $port, + 'user' => $string, + 'pass' => $string, + 'path' => $string, + 'query' => $string, + 'fragment' => $string, + ]; + $this->componentTypesPairedStringsForLowercaseString = [ + 'scheme' => $lowercaseString, + 'host' => $lowercaseString, + 'port' => $port, + 'user' => $lowercaseString, + 'pass' => $lowercaseString, + 'path' => $lowercaseString, + 'query' => $lowercaseString, + 'fragment' => $lowercaseString, + ]; + } + +} diff --git a/src/Type/Php/PathinfoFunctionDynamicReturnTypeExtension.php b/src/Type/Php/PathinfoFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..6742f87e --- /dev/null +++ b/src/Type/Php/PathinfoFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,99 @@ +getName() === 'pathinfo'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + Node\Expr\FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $argsCount = count($functionCall->getArgs()); + if ($argsCount === 0) { + return null; + } + + $pathType = $scope->getType($functionCall->getArgs()[0]->value); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(new ConstantStringType('dirname'), new StringType(), !$pathType->isNonEmptyString()->yes()); + $builder->setOffsetValueType(new ConstantStringType('basename'), new StringType()); + $builder->setOffsetValueType(new ConstantStringType('extension'), new StringType(), true); + $builder->setOffsetValueType(new ConstantStringType('filename'), new StringType()); + $arrayType = $builder->getArray(); + + if ($argsCount === 1) { + return $arrayType; + } + + $flagsType = $scope->getType($functionCall->getArgs()[1]->value); + + $scalarValues = $flagsType->getConstantScalarValues(); + if ($scalarValues !== []) { + $pathInfoAll = $this->getConstant('PATHINFO_ALL'); + if ($pathInfoAll === null) { + return null; + } + + $result = []; + foreach ($scalarValues as $scalarValue) { + if ($scalarValue === $pathInfoAll) { + $result[] = $arrayType; + } else { + $result[] = new StringType(); + } + } + + return TypeCombinator::union(...$result); + } + + return TypeCombinator::union($arrayType, new StringType()); + } + + /** + * @param non-empty-string $constantName + */ + private function getConstant(string $constantName): ?int + { + if (!$this->reflectionProvider->hasConstant(new Node\Name($constantName), null)) { + return null; + } + + $constant = $this->reflectionProvider->getConstant(new Node\Name($constantName), null); + $valueType = $constant->getValueType(); + if (!$valueType instanceof ConstantIntegerType) { + throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName)); + } + + return $valueType->getValue(); + } + +} diff --git a/src/Type/Php/PowFunctionReturnTypeExtension.php b/src/Type/Php/PowFunctionReturnTypeExtension.php new file mode 100644 index 00000000..9c263628 --- /dev/null +++ b/src/Type/Php/PowFunctionReturnTypeExtension.php @@ -0,0 +1,31 @@ +getName() === 'pow'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + return $scope->getType(new Pow($functionCall->getArgs()[0]->value, $functionCall->getArgs()[1]->value)); + } + +} diff --git a/src/Type/Php/PregFilterFunctionReturnTypeExtension.php b/src/Type/Php/PregFilterFunctionReturnTypeExtension.php new file mode 100644 index 00000000..8b27e271 --- /dev/null +++ b/src/Type/Php/PregFilterFunctionReturnTypeExtension.php @@ -0,0 +1,52 @@ +getName() === 'preg_filter'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $defaultReturn = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + $argsCount = count($functionCall->getArgs()); + if ($argsCount < 3) { + return $defaultReturn; + } + + $subjectType = $scope->getType($functionCall->getArgs()[2]->value); + + if ($subjectType->isArray()->yes()) { + return new ArrayType(new IntegerType(), new StringType()); + } + if ($subjectType->isString()->yes()) { + return new UnionType([new StringType(), new NullType()]); + } + + return $defaultReturn; + } + +} diff --git a/src/Type/Php/PregMatchParameterOutTypeExtension.php b/src/Type/Php/PregMatchParameterOutTypeExtension.php new file mode 100644 index 00000000..23031e36 --- /dev/null +++ b/src/Type/Php/PregMatchParameterOutTypeExtension.php @@ -0,0 +1,56 @@ +getName()), ['preg_match', 'preg_match_all'], true) + // the parameter is named different, depending on PHP version. + && in_array($parameter->getName(), ['subpatterns', 'matches'], true); + } + + public function getParameterOutTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return null; + } + + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + if ($functionReflection->getName() === 'preg_match') { + return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); + } + return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); + } + +} diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php new file mode 100644 index 00000000..773f1501 --- /dev/null +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -0,0 +1,85 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return in_array(strtolower($functionReflection->getName()), ['preg_match', 'preg_match_all'], true) && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return new SpecifiedTypes(); + } + + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + if ($functionReflection->getName() === 'preg_match') { + $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } else { + $matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } + if ($matchedType === null) { + return new SpecifiedTypes(); + } + + $overwrite = false; + if ($context->false()) { + $overwrite = true; + $context = $context->negate(); + } + + $types = $this->typeSpecifier->create( + $matchesArg->value, + $matchedType, + $context, + $scope, + )->setRootExpr($node); + if ($overwrite) { + $types = $types->setAlwaysOverwriteTypes(); + } + + return $types; + } + +} diff --git a/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php b/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php new file mode 100644 index 00000000..62a3d455 --- /dev/null +++ b/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php @@ -0,0 +1,61 @@ +getName() === 'preg_replace_callback' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + $patternArg = $args[0] ?? null; + $flagsArg = $args[5] ?? null; + + if ( + $patternArg === null + ) { + return null; + } + + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + $matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); + if ($matchesType === null) { + return null; + } + + return new ClosureType( + [ + new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), + ], + new StringType(), + ); + } + +} diff --git a/src/Type/Php/PregSplitDynamicReturnTypeExtension.php b/src/Type/Php/PregSplitDynamicReturnTypeExtension.php new file mode 100644 index 00000000..3c41ad62 --- /dev/null +++ b/src/Type/Php/PregSplitDynamicReturnTypeExtension.php @@ -0,0 +1,53 @@ +getName()) === 'preg_split'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $flagsArg = $functionCall->getArgs()[3] ?? null; + + if ($flagsArg !== null && $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($flagsArg->value, $scope, 'PREG_SPLIT_OFFSET_CAPTURE')->yes()) { + $type = new ArrayType( + new IntegerType(), + new ConstantArrayType([new ConstantIntegerType(0), new ConstantIntegerType(1)], [new StringType(), IntegerRangeType::fromInterval(0, null)], [2], [], TrinaryLogic::createYes()), + ); + return TypeCombinator::union(TypeCombinator::intersect($type, new AccessoryArrayListType()), new ConstantBooleanType(false)); + } + + return null; + } + +} diff --git a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php new file mode 100644 index 00000000..824e2037 --- /dev/null +++ b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php @@ -0,0 +1,90 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return $functionReflection->getName() === 'property_exists' + && $context->true() + && count($node->getArgs()) >= 2; + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $propertyNameType = $scope->getType($node->getArgs()[1]->value); + if (!$propertyNameType instanceof ConstantStringType) { + return new SpecifiedTypes([], []); + } + + $objectType = $scope->getType($node->getArgs()[0]->value); + if ($objectType instanceof ConstantStringType) { + return new SpecifiedTypes([], []); + } elseif ($objectType->isObject()->yes()) { + $propertyNode = new PropertyFetch( + $node->getArgs()[0]->value, + new Identifier($propertyNameType->getValue()), + ); + } else { + return new SpecifiedTypes([], []); + } + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyNode, $scope); + if ($propertyReflection !== null) { + if (!$propertyReflection->isNative()) { + return new SpecifiedTypes([], []); + } + } + + return $this->typeSpecifier->create( + $node->getArgs()[0]->value, + new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($propertyNameType->getValue()), + ]), + $context, + $scope, + ); + } + +} diff --git a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php new file mode 100644 index 00000000..ffb240cf --- /dev/null +++ b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php @@ -0,0 +1,82 @@ +getName(), ['random_int', 'rand', 'mt_rand'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (in_array($functionReflection->getName(), ['rand', 'mt_rand'], true) && count($functionCall->getArgs()) === 0) { + return IntegerRangeType::fromInterval(0, null); + } + + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $minType = $scope->getType($functionCall->getArgs()[0]->value)->toInteger(); + $maxType = $scope->getType($functionCall->getArgs()[1]->value)->toInteger(); + + return $this->createRange($minType, $maxType); + } + + private function createRange(Type $minType, Type $maxType): Type + { + $minValues = array_map( + static function (Type $type): ?int { + if ($type instanceof IntegerRangeType) { + return $type->getMin(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $minType instanceof UnionType ? $minType->getTypes() : [$minType], + ); + + $maxValues = array_map( + static function (Type $type): ?int { + if ($type instanceof IntegerRangeType) { + return $type->getMax(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $maxType instanceof UnionType ? $maxType->getTypes() : [$maxType], + ); + + assert(count($minValues) > 0); + assert(count($maxValues) > 0); + + return IntegerRangeType::fromInterval( + in_array(null, $minValues, true) ? null : min($minValues), + in_array(null, $maxValues, true) ? null : max($maxValues), + ); + } + +} diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..85594dae --- /dev/null +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -0,0 +1,173 @@ +getName() === 'range'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $startType = $scope->getType($functionCall->getArgs()[0]->value); + $endType = $scope->getType($functionCall->getArgs()[1]->value); + $stepType = count($functionCall->getArgs()) >= 3 ? $scope->getType($functionCall->getArgs()[2]->value) : new ConstantIntegerType(1); + + $constantReturnTypes = []; + + $startConstants = $startType->getConstantScalarTypes(); + foreach ($startConstants as $startConstant) { + if (!$startConstant instanceof ConstantIntegerType && !$startConstant instanceof ConstantFloatType && !$startConstant instanceof ConstantStringType) { + continue; + } + + $endConstants = $endType->getConstantScalarTypes(); + foreach ($endConstants as $endConstant) { + if (!$endConstant instanceof ConstantIntegerType && !$endConstant instanceof ConstantFloatType && !$endConstant instanceof ConstantStringType) { + continue; + } + + $stepConstants = $stepType->getConstantScalarTypes(); + foreach ($stepConstants as $stepConstant) { + if (!$stepConstant instanceof ConstantIntegerType && !$stepConstant instanceof ConstantFloatType) { + continue; + } + + try { + $rangeValues = @range($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); + } catch (ValueError) { + continue; + } + + // @phpstan-ignore function.alreadyNarrowedType + if (!is_array($rangeValues)) { + continue; + } + + if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) { + if ( + $startConstant instanceof ConstantIntegerType + && $endConstant instanceof ConstantIntegerType + && $stepConstant instanceof ConstantIntegerType + ) { + if ($startConstant->getValue() > $endConstant->getValue()) { + $tmp = $startConstant; + $startConstant = $endConstant; + $endConstant = $tmp; + } + return TypeCombinator::intersect( + new ArrayType( + new IntegerType(), + IntegerRangeType::fromInterval($startConstant->getValue(), $endConstant->getValue()), + ), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + } + + if ($stepType->isFloat()->yes()) { + return TypeCombinator::intersect( + new ArrayType( + new IntegerType(), + new FloatType(), + ), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + } + + return TypeCombinator::intersect( + new ArrayType( + new IntegerType(), + TypeCombinator::union( + $startConstant->generalize(GeneralizePrecision::moreSpecific()), + $endConstant->generalize(GeneralizePrecision::moreSpecific()), + $stepType->generalize(GeneralizePrecision::moreSpecific()), + ), + ), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($rangeValues as $value) { + $arrayBuilder->setOffsetValueType(null, $scope->getTypeFromValue($value)); + } + + $constantReturnTypes[] = $arrayBuilder->getArray(); + } + } + } + + if (count($constantReturnTypes) > 0) { + return TypeCombinator::union(...$constantReturnTypes); + } + + $argType = TypeCombinator::union($startType, $endType); + $isInteger = $argType->isInteger()->yes(); + $isStepInteger = $stepType->isInteger()->yes(); + + if ($isInteger && $isStepInteger) { + if ($argType instanceof IntegerRangeType) { + return TypeCombinator::intersect(new ArrayType(new IntegerType(), $argType), new AccessoryArrayListType()); + } + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new IntegerType()), new AccessoryArrayListType()); + } + + if ($argType->isFloat()->yes()) { + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new FloatType()), new AccessoryArrayListType()); + } + + $numberType = new UnionType([new IntegerType(), new FloatType()]); + $isNumber = $numberType->isSuperTypeOf($argType)->yes(); + $isNumericString = $argType->isNumericString()->yes(); + if ($isNumber || $isNumericString) { + return TypeCombinator::intersect(new ArrayType(new IntegerType(), $numberType), new AccessoryArrayListType()); + } + + if ($argType->isString()->yes()) { + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new StringType()), new AccessoryArrayListType()); + } + + return TypeCombinator::intersect(new ArrayType( + new IntegerType(), + new BenevolentUnionType([new IntegerType(), new FloatType(), new StringType()]), + ), new AccessoryArrayListType()); + } + +} diff --git a/src/Type/Php/ReflectionClassConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionClassConstructorThrowTypeExtension.php new file mode 100644 index 00000000..8651e659 --- /dev/null +++ b/src/Type/Php/ReflectionClassConstructorThrowTypeExtension.php @@ -0,0 +1,43 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionClass::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 1) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $classOrString = new UnionType([ + new ClassStringType(), + new ObjectWithoutClassType(), + ]); + if ($classOrString->isSuperTypeOf($valueType)->yes()) { + return null; + } + + return $methodReflection->getThrowType(); + } + +} diff --git a/src/Type/Php/ReflectionClassIsSubclassOfTypeSpecifyingExtension.php b/src/Type/Php/ReflectionClassIsSubclassOfTypeSpecifyingExtension.php new file mode 100644 index 00000000..700e5327 --- /dev/null +++ b/src/Type/Php/ReflectionClassIsSubclassOfTypeSpecifyingExtension.php @@ -0,0 +1,72 @@ +typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return ReflectionClass::class; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'isSubclassOf' + && isset($node->getArgs()[0]) + && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $calledOnType = $scope->getType($node->var); + $reflectionType = $calledOnType->getTemplateType(ReflectionClass::class, 'T'); + if (!(new ObjectWithoutClassType())->isSuperTypeOf($reflectionType)->yes()) { + return new SpecifiedTypes(); + } + + $valueType = $scope->getType($node->getArgs()[0]->value); + $objectType = $valueType->getClassStringObjectType(); + + $intersected = TypeCombinator::intersect($reflectionType, $objectType); + $narrowingType = new GenericObjectType(ReflectionClass::class, [$intersected]); + + if ($reflectionType->isSuperTypeOf($objectType)->no()) { + return $this->typeSpecifier->create( + $node->var, + $narrowingType, + $context, + $scope, + ); + } + + return $this->typeSpecifier->create( + $node->var, + $narrowingType, + $context, + $scope, + )->setAlwaysOverwriteTypes(); + } + +} diff --git a/src/Type/Php/ReflectionFunctionConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionFunctionConstructorThrowTypeExtension.php new file mode 100644 index 00000000..b9b7ba1c --- /dev/null +++ b/src/Type/Php/ReflectionFunctionConstructorThrowTypeExtension.php @@ -0,0 +1,56 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionFunction::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 1) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + foreach ($valueType->getConstantStrings() as $constantString) { + if ($constantString->getValue() === '') { + return null; + } + + if (!$this->reflectionProvider->hasFunction(new Name($constantString->getValue()), $scope)) { + return $methodReflection->getThrowType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php new file mode 100644 index 00000000..18f5e44f --- /dev/null +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -0,0 +1,51 @@ +className; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getDeclaringClass()->getName() === $this->className + && $methodReflection->getName() === 'getAttributes'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + $argType = $scope->getType($methodCall->getArgs()[0]->value); + $classType = $argType->getClassStringObjectType(); + + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new GenericObjectType(ReflectionAttribute::class, [$classType])), new AccessoryArrayListType()); + } + +} diff --git a/src/Type/Php/ReflectionMethodConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionMethodConstructorThrowTypeExtension.php new file mode 100644 index 00000000..eebc6861 --- /dev/null +++ b/src/Type/Php/ReflectionMethodConstructorThrowTypeExtension.php @@ -0,0 +1,80 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionMethod::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 2) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $propertyType = $scope->getType($methodCall->getArgs()[1]->value); + foreach (TypeUtils::flattenTypes($valueType) as $type) { + if ($type instanceof GenericClassStringType) { + $classes = $type->getGenericType()->getObjectClassNames(); + } elseif ( + $type instanceof ConstantStringType + && $this->reflectionProvider->hasClass($type->getValue()) + ) { + $classes = [$type->getValue()]; + } else { + return $methodReflection->getThrowType(); + } + + foreach ($classes as $class) { + $classReflection = $this->reflectionProvider->getClass($class); + foreach ($propertyType->getConstantStrings() as $constantPropertyString) { + if (!$classReflection->hasMethod($constantPropertyString->getValue())) { + return $methodReflection->getThrowType(); + } + } + } + + $valueType = TypeCombinator::remove($valueType, $type); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + // Look for non constantStrings value. + foreach ($propertyType->getConstantStrings() as $constantPropertyString) { + $propertyType = TypeCombinator::remove($propertyType, $constantPropertyString); + } + + if (!$propertyType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/ReflectionPropertyConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionPropertyConstructorThrowTypeExtension.php new file mode 100644 index 00000000..4148301d --- /dev/null +++ b/src/Type/Php/ReflectionPropertyConstructorThrowTypeExtension.php @@ -0,0 +1,68 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionProperty::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 2) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $propertyType = $scope->getType($methodCall->getArgs()[1]->value); + foreach ($valueType->getConstantStrings() as $constantString) { + if (!$this->reflectionProvider->hasClass($constantString->getValue())) { + return $methodReflection->getThrowType(); + } + + $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); + foreach ($propertyType->getConstantStrings() as $constantPropertyString) { + if (!$classReflection->hasProperty($constantPropertyString->getValue())) { + return $methodReflection->getThrowType(); + } + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + // Look for non constantStrings value. + foreach ($propertyType->getConstantStrings() as $constantPropertyString) { + $propertyType = TypeCombinator::remove($propertyType, $constantPropertyString); + } + + if (!$propertyType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php new file mode 100644 index 00000000..7611469e --- /dev/null +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -0,0 +1,540 @@ +matchPatternType($this->getPatternType($patternExpr, $scope), $flagsType, $wasMatched, true); + } + + public function matchExpr(Expr $patternExpr, ?Type $flagsType, TrinaryLogic $wasMatched, Scope $scope): ?Type + { + return $this->matchPatternType($this->getPatternType($patternExpr, $scope), $flagsType, $wasMatched, false); + } + + private function matchPatternType(Type $patternType, ?Type $flagsType, TrinaryLogic $wasMatched, bool $matchesAll): ?Type + { + if ($wasMatched->no()) { + return new ConstantArrayType([], []); + } + + $constantStrings = $patternType->getConstantStrings(); + if (count($constantStrings) === 0) { + return null; + } + + $flags = null; + if ($flagsType !== null) { + if (!$flagsType instanceof ConstantIntegerType) { + return null; + } + + /** @var int-mask $flags */ + $flags = $flagsType->getValue() & (PREG_OFFSET_CAPTURE | PREG_PATTERN_ORDER | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL | self::PREG_UNMATCHED_AS_NULL_ON_72_73); + + // some other unsupported/unexpected flag was passed in + if ($flags !== $flagsType->getValue()) { + return null; + } + } + + $matchedTypes = []; + foreach ($constantStrings as $constantString) { + $matched = $this->matchRegex($constantString->getValue(), $flags, $wasMatched, $matchesAll); + if ($matched === null) { + return null; + } + + $matchedTypes[] = $matched; + } + + if (count($matchedTypes) === 1) { + return $matchedTypes[0]; + } + + return TypeCombinator::union(...$matchedTypes); + } + + /** + * @param int-mask|null $flags + */ + private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched, bool $matchesAll): ?Type + { + $parseResult = $this->regexGroupParser->parseGroups($regex); + if ($parseResult === null) { + // regex could not be parsed by Hoa/Regex + return null; + } + [$groupList, $markVerbs] = $parseResult; + + $trailingOptionals = 0; + foreach (array_reverse($groupList) as $captureGroup) { + if (!$captureGroup->isOptional()) { + break; + } + $trailingOptionals++; + } + + $onlyOptionalTopLevelGroup = $this->getOnlyOptionalTopLevelGroup($groupList); + $onlyTopLevelAlternation = $this->getOnlyTopLevelAlternation($groupList); + $flags ??= 0; + + if ( + !$matchesAll + && $wasMatched->yes() + && $onlyOptionalTopLevelGroup !== null + ) { + // if only one top level capturing optional group exists + // we build a more precise tagged union of a empty-match and a match with the group + + $onlyOptionalTopLevelGroup->forceNonOptional(); + + $combiType = $this->buildArrayType( + $groupList, + $wasMatched, + $trailingOptionals, + $flags, + $markVerbs, + $matchesAll, + ); + + if (!$this->containsUnmatchedAsNull($flags, $matchesAll)) { + // positive match has a subject but not any capturing group + $combiType = TypeCombinator::union( + new ConstantArrayType([new ConstantIntegerType(0)], [$this->createSubjectValueType($flags, $matchesAll)], [1], [], TrinaryLogic::createYes()), + $combiType, + ); + } + + $onlyOptionalTopLevelGroup->clearOverrides(); + + return $combiType; + } elseif ( + !$matchesAll + && $onlyOptionalTopLevelGroup === null + && $onlyTopLevelAlternation !== null + && !$wasMatched->no() + ) { + // if only a single top level alternation exist built a more precise tagged union + + $combiTypes = []; + $isOptionalAlternation = false; + foreach ($onlyTopLevelAlternation->getGroupCombinations() as $groupCombo) { + $comboList = $groupList; + + $beforeCurrentCombo = true; + foreach ($comboList as $groupId => $group) { + if (in_array($groupId, $groupCombo, true)) { + $isOptionalAlternation = $group->inOptionalAlternation(); + $group->forceNonOptional(); + $beforeCurrentCombo = false; + } elseif ($beforeCurrentCombo && !$group->resetsGroupCounter()) { + $group->forceNonOptional(); + $group->forceType( + $this->containsUnmatchedAsNull($flags, $matchesAll) ? new NullType() : new ConstantStringType(''), + ); + } elseif ( + $group->getAlternationId() === $onlyTopLevelAlternation->getId() + && !$this->containsUnmatchedAsNull($flags, $matchesAll) + ) { + unset($comboList[$groupId]); + } + } + + $combiType = $this->buildArrayType( + $comboList, + $wasMatched, + $trailingOptionals, + $flags, + $markVerbs, + $matchesAll, + ); + + $combiTypes[] = $combiType; + + foreach ($groupCombo as $groupId) { + $group = $comboList[$groupId]; + $group->clearOverrides(); + } + } + + if ( + !$this->containsUnmatchedAsNull($flags, $matchesAll) + && ( + $onlyTopLevelAlternation->getAlternationsCount() !== count($onlyTopLevelAlternation->getGroupCombinations()) + || $isOptionalAlternation + ) + ) { + // positive match has a subject but not any capturing group + $combiTypes[] = new ConstantArrayType([new ConstantIntegerType(0)], [$this->createSubjectValueType($flags, $matchesAll)], [1], [], TrinaryLogic::createYes()); + } + + return TypeCombinator::union(...$combiTypes); + } + + // the general case, which should work in all cases but does not yield the most + // precise result possible in some cases + return $this->buildArrayType( + $groupList, + $wasMatched, + $trailingOptionals, + $flags, + $markVerbs, + $matchesAll, + ); + } + + /** + * @param array $captureGroups + */ + private function getOnlyOptionalTopLevelGroup(array $captureGroups): ?RegexCapturingGroup + { + $group = null; + foreach ($captureGroups as $captureGroup) { + if (!$captureGroup->isTopLevel()) { + continue; + } + + if (!$captureGroup->isOptional()) { + return null; + } + + if ($group !== null) { + return null; + } + + $group = $captureGroup; + } + + return $group; + } + + /** + * @param array $captureGroups + */ + private function getOnlyTopLevelAlternation(array $captureGroups): ?RegexAlternation + { + $alternation = null; + foreach ($captureGroups as $captureGroup) { + if (!$captureGroup->isTopLevel()) { + continue; + } + + if (!$captureGroup->inAlternation()) { + return null; + } + + if ($captureGroup->inOptionalQuantification()) { + return null; + } + + if ($alternation === null) { + $alternation = $captureGroup->getAlternation(); + } elseif ($alternation->getId() !== $captureGroup->getAlternation()->getId()) { + return null; + } + } + + return $alternation; + } + + /** + * @param array $captureGroups + * @param list $markVerbs + */ + private function buildArrayType( + array $captureGroups, + TrinaryLogic $wasMatched, + int $trailingOptionals, + int $flags, + array $markVerbs, + bool $matchesAll, + ): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + // first item in matches contains the overall match. + $builder->setOffsetValueType( + $this->getKeyType(0), + $this->createSubjectValueType($flags, $matchesAll), + $this->isSubjectOptional($wasMatched, $matchesAll), + ); + + $countGroups = count($captureGroups); + $i = 0; + foreach ($captureGroups as $captureGroup) { + $isTrailingOptional = $i >= $countGroups - $trailingOptionals; + $isLastGroup = $i === $countGroups - 1; + $groupValueType = $this->createGroupValueType($captureGroup, $wasMatched, $flags, $isTrailingOptional, $isLastGroup, $matchesAll); + $optional = $this->isGroupOptional($captureGroup, $wasMatched, $flags, $isTrailingOptional, $matchesAll); + + if ($captureGroup->isNamed()) { + $builder->setOffsetValueType( + $this->getKeyType($captureGroup->getName()), + $groupValueType, + $optional, + ); + } + + $builder->setOffsetValueType( + $this->getKeyType($i + 1), + $groupValueType, + $optional, + ); + + $i++; + } + + if (count($markVerbs) > 0) { + $markTypes = []; + foreach ($markVerbs as $mark) { + $markTypes[] = new ConstantStringType($mark); + } + $builder->setOffsetValueType( + $this->getKeyType('MARK'), + TypeCombinator::union(...$markTypes), + true, + ); + } + + if ($matchesAll && $this->containsSetOrder($flags)) { + $arrayType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $builder->getArray()), new AccessoryArrayListType()); + if (!$wasMatched->yes()) { + $arrayType = TypeCombinator::union( + new ConstantArrayType([], []), + $arrayType, + ); + } + return $arrayType; + } + + return $builder->getArray(); + } + + private function isSubjectOptional(TrinaryLogic $wasMatched, bool $matchesAll): bool + { + if ($matchesAll) { + return false; + } + + return !$wasMatched->yes(); + } + + private function createSubjectValueType(int $flags, bool $matchesAll): Type + { + $subjectValueType = TypeCombinator::removeNull($this->getValueType(new StringType(), $flags, $matchesAll)); + + if ($matchesAll) { + if ($this->containsPatternOrder($flags)) { + $subjectValueType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $subjectValueType), new AccessoryArrayListType()); + } + } + + return $subjectValueType; + } + + private function isGroupOptional(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $matchesAll): bool + { + if ($matchesAll) { + if ($isTrailingOptional && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $this->containsSetOrder($flags)) { + return true; + } + + return false; + } + + if (!$wasMatched->yes()) { + $optional = true; + } else { + if (!$isTrailingOptional) { + $optional = false; + } elseif ($this->containsUnmatchedAsNull($flags, $matchesAll)) { + $optional = false; + } else { + $optional = $captureGroup->isOptional(); + } + } + + return $optional; + } + + private function createGroupValueType(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $isLastGroup, bool $matchesAll): Type + { + if ($matchesAll) { + if ( + ( + !$this->containsSetOrder($flags) + && !$this->containsUnmatchedAsNull($flags, $matchesAll) + && $captureGroup->isOptional() + ) + || + ( + $this->containsSetOrder($flags) + && !$this->containsUnmatchedAsNull($flags, $matchesAll) + && $captureGroup->isOptional() + && !$isTrailingOptional + ) + ) { + $groupValueType = $this->getValueType( + TypeCombinator::union($captureGroup->getType(), new ConstantStringType('')), + $flags, + $matchesAll, + ); + $groupValueType = TypeCombinator::removeNull($groupValueType); + } else { + $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll); + } + + if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) { + $groupValueType = TypeCombinator::removeNull($groupValueType); + } + + if ($this->containsPatternOrder($flags)) { + $groupValueType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $groupValueType), new AccessoryArrayListType()); + } + + return $groupValueType; + } + + if (!$isLastGroup && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $captureGroup->isOptional()) { + $groupValueType = $this->getValueType( + TypeCombinator::union($captureGroup->getType(), new ConstantStringType('')), + $flags, + $matchesAll, + ); + } else { + $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll); + } + + if ($wasMatched->yes()) { + if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) { + $groupValueType = TypeCombinator::removeNull($groupValueType); + } + } + + return $groupValueType; + } + + private function containsOffsetCapture(int $flags): bool + { + return ($flags & PREG_OFFSET_CAPTURE) !== 0; + } + + private function containsPatternOrder(int $flags): bool + { + // If no order flag is given, PREG_PATTERN_ORDER is assumed. + return !$this->containsSetOrder($flags); + } + + private function containsSetOrder(int $flags): bool + { + return ($flags & PREG_SET_ORDER) !== 0; + } + + private function containsUnmatchedAsNull(int $flags, bool $matchesAll): bool + { + if ($matchesAll) { + // preg_match_all() with PREG_UNMATCHED_AS_NULL works consistently across php-versions + // https://3v4l.org/tKmPn + return ($flags & PREG_UNMATCHED_AS_NULL) !== 0; + } + + return ($flags & PREG_UNMATCHED_AS_NULL) !== 0 && (($flags & self::PREG_UNMATCHED_AS_NULL_ON_72_73) !== 0 || $this->phpVersion->supportsPregUnmatchedAsNull()); + } + + private function getKeyType(int|string $key): Type + { + if (is_string($key)) { + return new ConstantStringType($key); + } + + return new ConstantIntegerType($key); + } + + private function getValueType(Type $baseType, int $flags, bool $matchesAll): Type + { + $valueType = $baseType; + + // unmatched groups return -1 as offset + $offsetType = IntegerRangeType::fromInterval(-1, null); + if ($this->containsUnmatchedAsNull($flags, $matchesAll)) { + $valueType = TypeCombinator::addNull($valueType); + } + + if ($this->containsOffsetCapture($flags)) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $builder->setOffsetValueType( + new ConstantIntegerType(0), + $valueType, + ); + $builder->setOffsetValueType( + new ConstantIntegerType(1), + $offsetType, + ); + + return $builder->getArray(); + } + + return $valueType; + } + + private function getPatternType(Expr $patternExpr, Scope $scope): Type + { + if ($patternExpr instanceof Expr\BinaryOp\Concat) { + return $this->regexExpressionHelper->resolvePatternConcat($patternExpr, $scope); + } + + return $scope->getType($patternExpr); + } + +} diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php new file mode 100644 index 00000000..c00355be --- /dev/null +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -0,0 +1,202 @@ + 2, + 'preg_replace_callback' => 2, + 'preg_replace_callback_array' => 1, + 'str_replace' => 2, + 'str_ireplace' => 2, + 'substr_replace' => 0, + 'strtr' => 0, + ]; + + private const FUNCTIONS_REPLACE_POSITION = [ + 'preg_replace' => 1, + 'str_replace' => 1, + 'str_ireplace' => 1, + 'substr_replace' => 1, + 'strtr' => 2, + ]; + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return array_key_exists($functionReflection->getName(), self::FUNCTIONS_SUBJECT_POSITION); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $type = $this->getPreliminarilyResolvedTypeFromFunctionCall($functionReflection, $functionCall, $scope); + + if ($this->canReturnNull($functionReflection, $functionCall, $scope)) { + $type = TypeCombinator::addNull($type); + } + + return $type; + } + + private function getPreliminarilyResolvedTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $subjectArgumentType = $this->getSubjectType($functionReflection, $functionCall, $scope); + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + if ($subjectArgumentType === null) { + return $defaultReturnType; + } + + if ($subjectArgumentType instanceof MixedType) { + return TypeUtils::toBenevolentUnion($defaultReturnType); + } + + if (array_key_exists($functionReflection->getName(), self::FUNCTIONS_REPLACE_POSITION)) { + $replaceArgumentPosition = self::FUNCTIONS_REPLACE_POSITION[$functionReflection->getName()]; + + if (count($functionCall->getArgs()) > $replaceArgumentPosition) { + $replaceArgumentType = $scope->getType($functionCall->getArgs()[$replaceArgumentPosition]->value); + + $accessories = []; + if ($subjectArgumentType->isNonFalsyString()->yes() && $replaceArgumentType->isNonFalsyString()->yes()) { + $accessories[] = new AccessoryNonFalsyStringType(); + } elseif ($subjectArgumentType->isNonEmptyString()->yes() && $replaceArgumentType->isNonEmptyString()->yes()) { + $accessories[] = new AccessoryNonEmptyStringType(); + } + + if ($subjectArgumentType->isLowercaseString()->yes() && $replaceArgumentType->isLowercaseString()->yes()) { + $accessories[] = new AccessoryLowercaseStringType(); + } + + if ($subjectArgumentType->isUppercaseString()->yes() && $replaceArgumentType->isUppercaseString()->yes()) { + $accessories[] = new AccessoryUppercaseStringType(); + } + + if (count($accessories) > 0) { + $accessories[] = new StringType(); + return new IntersectionType($accessories); + } + } + } + + $isStringSuperType = $subjectArgumentType->isString(); + $isArraySuperType = $subjectArgumentType->isArray(); + $compareSuperTypes = $isStringSuperType->compareTo($isArraySuperType); + if ($compareSuperTypes === $isStringSuperType) { + return new StringType(); + } elseif ($compareSuperTypes === $isArraySuperType) { + $subjectArrays = $subjectArgumentType->getArrays(); + if (count($subjectArrays) > 0) { + $result = []; + foreach ($subjectArrays as $arrayType) { + $constantArrays = $arrayType->getConstantArrays(); + + if ( + $constantArrays !== [] + && in_array($functionReflection->getName(), ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], true) + ) { + foreach ($constantArrays as $constantArray) { + $generalizedArray = $constantArray->generalizeValues(); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + // turn all keys optional + foreach ($constantArray->getKeyTypes() as $keyType) { + $builder->setOffsetValueType($keyType, $generalizedArray->getOffsetValueType($keyType), true); + } + $result[] = $builder->getArray(); + } + + continue; + } + + $result[] = $arrayType->generalizeValues(); + } + + return TypeCombinator::union(...$result); + } + return $subjectArgumentType; + } + + return $defaultReturnType; + } + + private function getSubjectType( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $argumentPosition = self::FUNCTIONS_SUBJECT_POSITION[$functionReflection->getName()]; + if (count($functionCall->getArgs()) <= $argumentPosition) { + return null; + } + return $scope->getType($functionCall->getArgs()[$argumentPosition]->value); + } + + private function canReturnNull( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): bool + { + if ( + in_array($functionReflection->getName(), ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], true) + && count($functionCall->getArgs()) > 0 + ) { + $subjectArgumentType = $this->getSubjectType($functionReflection, $functionCall, $scope); + + if ( + $subjectArgumentType !== null + && $subjectArgumentType->isArray()->yes() + ) { + return false; + } + } + + $possibleTypes = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + // resolve conditional return types + $possibleTypes = TypeUtils::resolveLateResolvableTypes($possibleTypes); + + return TypeCombinator::containsNull($possibleTypes); + } + +} diff --git a/src/Type/Php/RoundFunctionReturnTypeExtension.php b/src/Type/Php/RoundFunctionReturnTypeExtension.php new file mode 100644 index 00000000..a8833f16 --- /dev/null +++ b/src/Type/Php/RoundFunctionReturnTypeExtension.php @@ -0,0 +1,100 @@ +getName(), + [ + 'round', + 'ceil', + 'floor', + ], + true, + ); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + // PHP 7 can return either a float or false. + // PHP 8 can either return a float or fatal. + $defaultReturnType = null; + + if ($this->phpVersion->hasStricterRoundFunctions()) { + // PHP 8 fatals with a missing parameter. + $noArgsReturnType = new NeverType(true); + } else { + // PHP 7 returns null with a missing parameter. + $noArgsReturnType = new NullType(); + } + + if (count($functionCall->getArgs()) < 1) { + return $noArgsReturnType; + } + + $firstArgType = $scope->getType($functionCall->getArgs()[0]->value); + + if ($firstArgType instanceof MixedType) { + return $defaultReturnType; + } + + if ($this->phpVersion->hasStricterRoundFunctions()) { + $allowed = TypeCombinator::union( + new IntegerType(), + new FloatType(), + ); + + if (!$scope->isDeclareStrictTypes()) { + $allowed = TypeCombinator::union( + $allowed, + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + new NullType(), + new BooleanType(), + ); + } + + if ($allowed->isSuperTypeOf($firstArgType)->no()) { + // PHP 8 fatals if the parameter is not an integer or float. + return new NeverType(true); + } + } elseif ($firstArgType->isArray()->yes()) { + // PHP 7 returns false if the parameter is an array. + return new ConstantBooleanType(false); + } + + return new FloatType(); + } + +} diff --git a/src/Type/Php/SetTypeFunctionTypeSpecifyingExtension.php b/src/Type/Php/SetTypeFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000..295bac82 --- /dev/null +++ b/src/Type/Php/SetTypeFunctionTypeSpecifyingExtension.php @@ -0,0 +1,91 @@ +getName()) === 'settype' + && count($node->getArgs()) > 1 + && $context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $value = $node->getArgs()[0]->value; + $valueType = $scope->getType($value); + $castType = $scope->getType($node->getArgs()[1]->value); + + $constantStrings = $castType->getConstantStrings(); + if (count($constantStrings) < 1) { + return new SpecifiedTypes(); + } + + $types = []; + + foreach ($constantStrings as $constantString) { + switch ($constantString->getValue()) { + case 'bool': + case 'boolean': + $types[] = $valueType->toBoolean(); + break; + case 'int': + case 'integer': + $types[] = $valueType->toInteger(); + break; + case 'float': + case 'double': + $types[] = $valueType->toFloat(); + break; + case 'string': + $types[] = $valueType->toString(); + break; + case 'array': + $types[] = $valueType->toArray(); + break; + case 'object': + $types[] = new ObjectType(stdClass::class); + break; + case 'null': + $types[] = new NullType(); + break; + default: + $types[] = new ErrorType(); + } + } + + return $this->typeSpecifier->create( + $value, + TypeCombinator::union(...$types), + TypeSpecifierContext::createTruthy(), + $scope, + )->setAlwaysOverwriteTypes(); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/SimpleXMLElementAsXMLMethodReturnTypeExtension.php b/src/Type/Php/SimpleXMLElementAsXMLMethodReturnTypeExtension.php new file mode 100644 index 00000000..987fcf82 --- /dev/null +++ b/src/Type/Php/SimpleXMLElementAsXMLMethodReturnTypeExtension.php @@ -0,0 +1,39 @@ +getName() === 'asXML'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + if (count($methodCall->getArgs()) === 1) { + return new BooleanType(); + } + return new UnionType([new StringType(), new ConstantBooleanType(false)]); + } + +} diff --git a/src/Type/Php/SimpleXMLElementClassPropertyReflectionExtension.php b/src/Type/Php/SimpleXMLElementClassPropertyReflectionExtension.php new file mode 100644 index 00000000..6d3d9e39 --- /dev/null +++ b/src/Type/Php/SimpleXMLElementClassPropertyReflectionExtension.php @@ -0,0 +1,27 @@ +getName() === 'SimpleXMLElement' || $classReflection->isSubclassOf('SimpleXMLElement'); + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return new SimpleXMLElementProperty($classReflection, new BenevolentUnionType([new ObjectType($classReflection->getName()), new NullType()])); + } + +} diff --git a/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php b/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php new file mode 100644 index 00000000..4143e611 --- /dev/null +++ b/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php @@ -0,0 +1,60 @@ +getName() === '__construct' + && $methodReflection->getDeclaringClass()->getName() === SimpleXMLElement::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + $internalErrorsOld = libxml_use_internal_errors(true); + + try { + foreach ($constantStrings as $constantString) { + try { + new SimpleXMLElement($constantString->getValue()); + } catch (\Exception $e) { // phpcs:ignore + return $methodReflection->getThrowType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + } finally { + libxml_use_internal_errors($internalErrorsOld); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/SimpleXMLElementXpathMethodReturnTypeExtension.php b/src/Type/Php/SimpleXMLElementXpathMethodReturnTypeExtension.php new file mode 100644 index 00000000..f6295403 --- /dev/null +++ b/src/Type/Php/SimpleXMLElementXpathMethodReturnTypeExtension.php @@ -0,0 +1,58 @@ +getName() === 'xpath'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (!isset($methodCall->getArgs()[0])) { + return null; + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + $xmlElement = new SimpleXMLElement(''); + + foreach ($argType->getConstantStrings() as $constantString) { + $result = @$xmlElement->xpath($constantString->getValue()); + if ($result === false) { + // We can't be sure since it's maybe a namespaced xpath + return null; + } + + $argType = TypeCombinator::remove($argType, $constantString); + } + + if (!$argType instanceof NeverType) { + return null; + } + + return new ArrayType(new MixedType(), $scope->getType($methodCall->var)); + } + +} diff --git a/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..9b0db38e --- /dev/null +++ b/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,374 @@ +getName(), ['sprintf', 'vsprintf'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) === 0) { + return null; + } + + $constantType = $this->getConstantType($args, $functionReflection, $scope); + if ($constantType !== null) { + return $constantType; + } + + $formatType = $scope->getType($args[0]->value); + $formatStrings = $formatType->getConstantStrings(); + + $isLowercase = $formatType->isLowercaseString()->yes() && $this->allValuesSatisfies( + $functionReflection, + $scope, + $args, + static fn (Type $type): bool => $type->toString()->isLowercaseString()->yes() + ); + + $singlePlaceholderEarlyReturn = []; + $allPatternsNonEmpty = count($formatStrings) !== 0; + $allPatternsNonFalsy = count($formatStrings) !== 0; + foreach ($formatStrings as $constantString) { + $constantParts = $this->getFormatConstantParts( + $constantString->getValue(), + $functionReflection, + $functionCall, + $scope, + ); + if ($constantParts !== null) { + if ($constantParts->isNonFalsyString()->yes()) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf + // keep all bool flags as is + } elseif ($constantParts->isNonEmptyString()->yes()) { + $allPatternsNonFalsy = false; + } else { + $allPatternsNonEmpty = false; + $allPatternsNonFalsy = false; + } + } else { + $allPatternsNonEmpty = false; + $allPatternsNonFalsy = false; + } + + if ( + is_array($singlePlaceholderEarlyReturn) + // The printf format is %[argnum$][flags][width][.precision]specifier. + && preg_match('/^%(?P[0-9]*\$)?(?P[0-9]*)\.?[0-9]*(?P[sbdeEfFgGhHouxX])$/', $constantString->getValue(), $matches) === 1 + ) { + if ($matches['argnum'] !== '') { + // invalid positional argument + if ($matches['argnum'] === '0$') { + return null; + } + $checkArg = intval(substr($matches['argnum'], 0, -1)); + } else { + $checkArg = 1; + } + + $checkArgType = $this->getValueType($functionReflection, $scope, $args, $checkArg); + if ($checkArgType === null) { + return null; + } + + // if the format string is just a placeholder and specified an argument + // of stringy type, then the return value will be of the same type + if ( + $matches['specifier'] === 's' + && ($checkArgType->isString()->yes() || $checkArgType->isInteger()->yes()) + ) { + if ($checkArgType instanceof IntegerRangeType) { + $constArgTypes = $checkArgType->getFiniteTypes(); + } else { + $constArgTypes = $checkArgType->getConstantScalarTypes(); + } + if ($constArgTypes !== []) { + $printfArgs = array_fill(0, count($args) - 1, ''); + foreach ($constArgTypes as $constArgType) { + $printfArgs[$checkArg - 1] = $constArgType->getValue(); + try { + $singlePlaceholderEarlyReturn[] = new ConstantStringType(@sprintf($constantString->getValue(), ...$printfArgs)); + } catch (Throwable) { + continue 2; + } + } + + continue; + } + + $singlePlaceholderEarlyReturn[] = $checkArgType->toString(); + } elseif ($matches['specifier'] !== 's') { + $singlePlaceholderEarlyReturn[] = $this->getStringReturnType( + new AccessoryNumericStringType(), + $isLowercase, + ); + } + + continue; + } + + $singlePlaceholderEarlyReturn = null; + } + + if (is_array($singlePlaceholderEarlyReturn) && count($singlePlaceholderEarlyReturn) > 0) { + return TypeCombinator::union(...$singlePlaceholderEarlyReturn); + } + + if ($allPatternsNonFalsy) { + return $this->getStringReturnType(new AccessoryNonFalsyStringType(), $isLowercase); + } + + $isNonEmpty = $allPatternsNonEmpty; + if (!$isNonEmpty && $formatType->isNonEmptyString()->yes()) { + $isNonEmpty = $this->allValuesSatisfies( + $functionReflection, + $scope, + $args, + static fn (Type $type): bool => $type->toString()->isNonEmptyString()->yes() + ); + } + + if ($isNonEmpty) { + return $this->getStringReturnType(new AccessoryNonEmptyStringType(), $isLowercase); + } + + return $this->getStringReturnType(null, $isLowercase); + } + + /** + * @param array $args + * @param callable(Type): bool $cb + */ + private function allValuesSatisfies(FunctionReflection $functionReflection, Scope $scope, array $args, callable $cb): bool + { + if ($functionReflection->getName() === 'sprintf' && count($args) >= 2) { + foreach ($args as $key => $arg) { + if ($key === 0) { + continue; + } + + if (!$cb($scope->getType($arg->value))) { + return false; + } + } + + return true; + } + + if ($functionReflection->getName() === 'vsprintf' && count($args) >= 2) { + return $cb($scope->getType($args[1]->value)->getIterableValueType()); + } + + return false; + } + + /** + * @param Arg[] $args + */ + private function getValueType(FunctionReflection $functionReflection, Scope $scope, array $args, int $argNumber): ?Type + { + if ($functionReflection->getName() === 'sprintf') { + // constant string specifies a numbered argument that does not exist + if (!array_key_exists($argNumber, $args)) { + return null; + } + + return $scope->getType($args[$argNumber]->value); + } + + if ($functionReflection->getName() === 'vsprintf') { + if (!array_key_exists(1, $args)) { + return null; + } + + $valuesType = $scope->getType($args[1]->value); + $resultTypes = []; + + $valuesConstantArrays = $valuesType->getConstantArrays(); + foreach ($valuesConstantArrays as $valuesConstantArray) { + // vsprintf does not care about the keys of the array, only the order + $types = array_values($valuesConstantArray->getValueTypes()); + if (!array_key_exists($argNumber - 1, $types)) { + return null; + } + + $resultTypes[] = $types[$argNumber - 1]; + } + if (count($resultTypes) === 0) { + return $valuesType->getIterableValueType(); + } + + return TypeCombinator::union(...$resultTypes); + } + + return null; + } + + /** + * Detect constant strings in the format which neither depend on placeholders nor on given value arguments. + */ + private function getFormatConstantParts( + string $format, + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?ConstantStringType + { + $args = $functionCall->getArgs(); + if ($functionReflection->getName() === 'sprintf') { + $valuesCount = count($args) - 1; + } elseif ( + $functionReflection->getName() === 'vsprintf' + && count($args) >= 2 + ) { + $arraySize = $scope->getType($args[1]->value)->getArraySize(); + if (!($arraySize instanceof ConstantIntegerType)) { + return null; + } + + $valuesCount = $arraySize->getValue(); + } else { + return null; + } + + if ($valuesCount <= 0) { + return null; + } + $dummyValues = array_fill(0, $valuesCount, ''); + + try { + $formatted = @vsprintf($format, $dummyValues); + if ($formatted === false) { // @phpstan-ignore identical.alwaysFalse (PHP7.2 compat) + return null; + } + return new ConstantStringType($formatted); + } catch (Throwable) { + return null; + } + } + + /** + * @param Arg[] $args + */ + private function getConstantType(array $args, FunctionReflection $functionReflection, Scope $scope): ?Type + { + $values = []; + $combinationsCount = 1; + foreach ($args as $arg) { + if ($arg->unpack) { + return null; + } + + $argType = $scope->getType($arg->value); + $constantScalarValues = $argType->getConstantScalarValues(); + + if (count($constantScalarValues) === 0) { + if ($argType instanceof IntegerRangeType) { + foreach ($argType->getFiniteTypes() as $finiteType) { + $constantScalarValues[] = $finiteType->getValue(); + } + } + } + + if (count($constantScalarValues) === 0) { + return null; + } + + $values[] = $constantScalarValues; + $combinationsCount *= count($constantScalarValues); + } + + if ($combinationsCount > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return null; + } + + $combinations = CombinationsHelper::combinations($values); + $returnTypes = []; + foreach ($combinations as $combination) { + $format = array_shift($combination); + if (!is_string($format)) { + return null; + } + + try { + if ($functionReflection->getName() === 'sprintf') { + $returnTypes[] = $scope->getTypeFromValue(@sprintf($format, ...$combination)); + } else { + $returnTypes[] = $scope->getTypeFromValue(@vsprintf($format, $combination)); + } + } catch (Throwable) { + return null; + } + } + + if (count($returnTypes) > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return null; + } + + return TypeCombinator::union(...$returnTypes); + } + + private function getStringReturnType(?AccessoryType $accessoryType, bool $isLowercase): Type + { + $accessoryTypes = []; + if ($accessoryType !== null) { + $accessoryTypes[] = $accessoryType; + } + if ($isLowercase) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + + if (count($accessoryTypes) === 0) { + return new StringType(); + } + + $accessoryTypes[] = new StringType(); + + return new IntersectionType($accessoryTypes); + } + +} diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..182cead1 --- /dev/null +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,90 @@ +getName(), ['sscanf', 'fscanf'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) !== 2) { + return null; + } + + $formatType = $scope->getType($args[1]->value); + + if (!$formatType instanceof ConstantStringType) { + return null; + } + + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatType->getValue(), $matches) > 0) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + + for ($i = 0; $i < count($matches[0]); $i++) { + $length = $matches[1][$i]; + $specifier = $matches[2][$i]; + + $type = new StringType(); + if ($length !== '') { + if (((int) $length) > 1) { + $type = new IntersectionType([ + $type, + new AccessoryNonFalsyStringType(), + ]); + } else { + $type = new IntersectionType([ + $type, + new AccessoryNonEmptyStringType(), + ]); + } + } + + if (in_array($specifier, ['d', 'o', 'u', 'x'], true)) { + $type = new IntegerType(); + } + + if (in_array($specifier, ['e', 'E', 'f'], true)) { + $type = new FloatType(); + } + + $type = TypeCombinator::addNull($type); + $arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type); + } + + return TypeCombinator::addNull($arrayBuilder->getArray()); + } + + return null; + } + +} diff --git a/src/Type/Php/StatDynamicReturnTypeExtension.php b/src/Type/Php/StatDynamicReturnTypeExtension.php new file mode 100644 index 00000000..3e4b0961 --- /dev/null +++ b/src/Type/Php/StatDynamicReturnTypeExtension.php @@ -0,0 +1,81 @@ +getName(), ['stat', 'lstat', 'fstat', 'ssh2_sftp_stat'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + return TypeCombinator::union($this->getReturnType(), new ConstantBooleanType(false)); + } + + public function getClass(): string + { + return SplFileObject::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'fstat'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return $this->getReturnType(); + } + + private function getReturnType(): Type + { + $valueType = new IntegerType(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + $keys = [ + 'dev', + 'ino', + 'mode', + 'nlink', + 'uid', + 'gid', + 'rdev', + 'size', + 'atime', + 'mtime', + 'ctime', + 'blksize', + 'blocks', + ]; + + foreach ($keys as $key) { + $builder->setOffsetValueType(null, $valueType); + } + + foreach ($keys as $key) { + $builder->setOffsetValueType(new ConstantStringType($key), $valueType); + } + + return $builder->getArray(); + } + +} diff --git a/src/Type/Php/StrCaseFunctionsReturnTypeExtension.php b/src/Type/Php/StrCaseFunctionsReturnTypeExtension.php new file mode 100644 index 00000000..c07a2c0c --- /dev/null +++ b/src/Type/Php/StrCaseFunctionsReturnTypeExtension.php @@ -0,0 +1,170 @@ + minimum arity] + */ + private const FUNCTIONS = [ + 'strtoupper' => 1, + 'strtolower' => 1, + 'mb_strtoupper' => 1, + 'mb_strtolower' => 1, + 'lcfirst' => 1, + 'ucfirst' => 1, + 'mb_lcfirst' => 1, + 'mb_ucfirst' => 1, + 'ucwords' => 1, + 'mb_convert_case' => 2, + 'mb_convert_kana' => 1, + ]; + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return isset(self::FUNCTIONS[$functionReflection->getName()]); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $fnName = $functionReflection->getName(); + $args = $functionCall->getArgs(); + + if (count($args) < self::FUNCTIONS[$fnName]) { + return null; + } + + $argType = $scope->getType($args[0]->value); + if (!is_callable($fnName)) { + return null; + } + + $modes = []; + $keepLowercase = false; + $forceLowercase = false; + $keepUppercase = false; + $forceUppercase = false; + + if ($fnName === 'mb_convert_case') { + $modeType = $scope->getType($args[1]->value); + $modes = array_map(static fn ($mode) => $mode->getValue(), TypeUtils::getConstantIntegers($modeType)); + if (count($modes) > 0) { + $forceLowercase = count(array_diff($modes, [ + MB_CASE_LOWER, + 5, // MB_CASE_LOWER_SIMPLE + ])) === 0; + $keepLowercase = count(array_diff($modes, [ + MB_CASE_LOWER, + 5, // MB_CASE_LOWER_SIMPLE + 3, // MB_CASE_FOLD, + 7, // MB_CASE_FOLD_SIMPLE + ])) === 0; + $forceUppercase = count(array_diff($modes, [ + MB_CASE_UPPER, + 4, // MB_CASE_UPPER_SIMPLE + ])) === 0; + $keepUppercase = count(array_diff($modes, [ + MB_CASE_UPPER, + 4, // MB_CASE_UPPER_SIMPLE + 3, // MB_CASE_FOLD, + 7, // MB_CASE_FOLD_SIMPLE + ])) === 0; + } + } elseif (in_array($fnName, ['ucwords', 'mb_convert_kana'], true)) { + if (count($args) >= 2) { + $modeType = $scope->getType($args[1]->value); + $modes = array_map(static fn ($mode) => $mode->getValue(), $modeType->getConstantStrings()); + } else { + $modes = $fnName === 'mb_convert_kana' ? ['KV'] : [" \t\r\n\f\v"]; + } + } elseif (in_array($fnName, ['strtolower', 'mb_strtolower'], true)) { + $forceLowercase = true; + } elseif (in_array($fnName, ['lcfirst', 'mb_lcfirst'], true)) { + $keepLowercase = true; + } elseif (in_array($fnName, ['strtoupper', 'mb_strtoupper'], true)) { + $forceUppercase = true; + } elseif (in_array($fnName, ['ucfirst', 'mb_ucfirst'], true)) { + $keepUppercase = true; + } + + $constantStrings = array_map(static fn ($type) => $type->getValue(), $argType->getConstantStrings()); + if (count($constantStrings) > 0 && mb_check_encoding($constantStrings, 'UTF-8')) { + $strings = []; + + $parameters = []; + if (in_array($fnName, ['ucwords', 'mb_convert_case', 'mb_convert_kana'], true)) { + foreach ($modes as $mode) { + foreach ($constantStrings as $constantString) { + $parameters[] = [$constantString, $mode]; + } + } + } else { + $parameters = array_map(static fn ($s) => [$s], $constantStrings); + } + + foreach ($parameters as $parameter) { + $strings[] = $fnName(...$parameter); + } + + if (count($strings) !== 0 && mb_check_encoding($strings, 'UTF-8')) { + return TypeCombinator::union(...array_map(static fn ($s) => new ConstantStringType($s), $strings)); + } + } + + $accessoryTypes = []; + if ($forceLowercase || ($keepLowercase && $argType->isLowercaseString()->yes())) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($forceUppercase || ($keepUppercase && $argType->isUppercaseString()->yes())) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if ($argType->isNumericString()->yes()) { + $accessoryTypes[] = new AccessoryNumericStringType(); + } elseif ($argType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($argType->isNonEmptyString()->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + + return new IntersectionType($accessoryTypes); + } + + return new StringType(); + } + +} diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php new file mode 100644 index 00000000..16359eb0 --- /dev/null +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -0,0 +1,110 @@ + [1, 0], + 'str_contains' => [0, 1], + 'str_starts_with' => [0, 1], + 'str_ends_with' => [0, 1], + 'strpos' => [0, 1], + 'strrpos' => [0, 1], + 'stripos' => [0, 1], + 'strripos' => [0, 1], + 'strstr' => [0, 1], + 'mb_strpos' => [0, 1], + 'mb_strrpos' => [0, 1], + 'mb_stripos' => [0, 1], + 'mb_strripos' => [0, 1], + 'mb_strstr' => [0, 1], + ]; + + private TypeSpecifier $typeSpecifier; + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return array_key_exists(strtolower($functionReflection->getName()), self::STR_CONTAINING_FUNCTIONS) + && $context->true(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + + if (count($args) >= 2) { + [$hackstackArg, $needleArg] = self::STR_CONTAINING_FUNCTIONS[strtolower($functionReflection->getName())]; + + $haystackType = $scope->getType($args[$hackstackArg]->value); + $needleType = $scope->getType($args[$needleArg]->value); + + if ($needleType->isNonEmptyString()->yes() && $haystackType->isString()->yes()) { + $accessories = [ + new StringType(), + ]; + + if ($needleType->isNonFalsyString()->yes()) { + $accessories[] = new AccessoryNonFalsyStringType(); + } else { + $accessories[] = new AccessoryNonEmptyStringType(); + } + + if ($haystackType->isLiteralString()->yes()) { + $accessories[] = new AccessoryLiteralStringType(); + } + if ($haystackType->isNumericString()->yes()) { + $accessories[] = new AccessoryNumericStringType(); + } + + return $this->typeSpecifier->create( + $args[$hackstackArg]->value, + new IntersectionType($accessories), + $context, + $scope, + )->setRootExpr(new BooleanAnd( + new NotIdentical( + $args[$needleArg]->value, + new String_(''), + ), + new FuncCall(new Name('FAUX_FUNCTION'), [ + new Arg($args[$needleArg]->value), + ]), + )); + } + } + + return new SpecifiedTypes(); + } + +} diff --git a/src/Type/Php/StrIncrementDecrementFunctionReturnTypeExtension.php b/src/Type/Php/StrIncrementDecrementFunctionReturnTypeExtension.php new file mode 100644 index 00000000..a9b4c5b4 --- /dev/null +++ b/src/Type/Php/StrIncrementDecrementFunctionReturnTypeExtension.php @@ -0,0 +1,144 @@ +getName(), ['str_increment', 'str_decrement'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $fnName = $functionReflection->getName(); + $args = $functionCall->getArgs(); + + if (count($args) !== 1) { + return null; + } + + $argType = $scope->getType($args[0]->value); + if (count($argType->getConstantScalarValues()) === 0) { + return null; + } + + $types = []; + foreach ($argType->getConstantScalarValues() as $value) { + if (!(is_string($value) || is_int($value) || is_float($value))) { + continue; + } + $string = (string) $value; + + if (preg_match('/\A(?:0|[1-9A-Za-z][0-9A-Za-z]*)+\z/', $string) < 1) { + continue; + } + + $result = null; + if ($fnName === 'str_increment') { + $result = $this->increment($string); + } elseif ($fnName === 'str_decrement') { + $result = $this->decrement($string); + } + + if ($result === null) { + continue; + } + + $types[] = new ConstantStringType($result); + } + + return count($types) === 0 + ? new ErrorType() + : TypeCombinator::union(...$types); + } + + private function increment(string $s): string + { + if (is_numeric($s)) { + $offset = stripos($s, 'e'); + if ($offset !== false) { + // Using increment operator would cast the string to float + // Therefore we manually increment it to convert it to an "f"/"F" that doesn't get affected + $c = $s[$offset]; + $c++; + $s[$offset] = $c; + $s++; + $s[$offset] = [ + 'f' => 'e', + 'F' => 'E', + 'g' => 'f', + 'G' => 'F', + ][$s[$offset]]; + + return $s; + } + } + + return (string) ++$s; + } + + private function decrement(string $s): ?string + { + if (in_array($s, ['a', 'A', '0'], true)) { + return null; + } + + $decremented = str_split($s, 1); + $position = count($decremented) - 1; + $carry = false; + $map = [ + '0' => '9', + 'A' => 'Z', + 'a' => 'z', + ]; + do { + $c = $decremented[$position]; + if (!in_array($c, ['a', 'A', '0'], true)) { + $carry = false; + $decremented[$position] = chr(ord($c) - 1); + } else { + $carry = true; + $decremented[$position] = $map[$c]; + } + } while ($carry && $position-- > 0); + + if ($carry || count($decremented) > 1 && $decremented[0] === '0') { + if (count($decremented) === 1) { + return null; + } + + unset($decremented[0]); + } + + return implode($decremented); + } + +} diff --git a/src/Type/Php/StrPadFunctionReturnTypeExtension.php b/src/Type/Php/StrPadFunctionReturnTypeExtension.php new file mode 100644 index 00000000..c9b5cc1a --- /dev/null +++ b/src/Type/Php/StrPadFunctionReturnTypeExtension.php @@ -0,0 +1,74 @@ +getName() === 'str_pad'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return new StringType(); + } + + $inputType = $scope->getType($args[0]->value); + $lengthType = $scope->getType($args[1]->value); + + $accessoryTypes = []; + if ($inputType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($inputType->isNonEmptyString()->yes() || IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($lengthType)->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + + if (count($args) < 3) { + $padStringType = null; + } else { + $padStringType = $scope->getType($args[2]->value); + } + + if ($inputType->isLiteralString()->yes() && ($padStringType === null || $padStringType->isLiteralString()->yes())) { + $accessoryTypes[] = new AccessoryLiteralStringType(); + } + if ($inputType->isLowercaseString()->yes() && ($padStringType === null || $padStringType->isLowercaseString()->yes())) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($inputType->isUppercaseString()->yes() && ($padStringType === null || $padStringType->isUppercaseString()->yes())) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + return new IntersectionType($accessoryTypes); + } + + return new StringType(); + } + +} diff --git a/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php b/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php new file mode 100644 index 00000000..5782b7cc --- /dev/null +++ b/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php @@ -0,0 +1,114 @@ +getName() === 'str_repeat'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return new StringType(); + } + + $multiplierType = $scope->getType($args[1]->value); + + if ((new ConstantIntegerType(0))->isSuperTypeOf($multiplierType)->yes()) { + return new ConstantStringType(''); + } + + if (IntegerRangeType::fromInterval(null, 0)->isSuperTypeOf($multiplierType)->yes()) { + return new NeverType(); + } + + $inputType = $scope->getType($args[0]->value); + if ( + $inputType instanceof ConstantStringType + && $multiplierType instanceof ConstantIntegerType + // don't generate type too big to avoid hitting memory limit + && strlen($inputType->getValue()) * $multiplierType->getValue() < 100 + ) { + return new ConstantStringType(str_repeat($inputType->getValue(), $multiplierType->getValue())); + } + + $accessoryTypes = []; + if ($inputType->isNonEmptyString()->yes()) { + if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($multiplierType)->yes()) { + if ($inputType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } else { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + } + } + + if ($inputType->isLiteralString()->yes()) { + $accessoryTypes[] = new AccessoryLiteralStringType(); + + if ( + $inputType->isNumericString()->yes() + && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($multiplierType)->yes() + ) { + $onlyNumbers = true; + foreach ($inputType->getConstantStrings() as $constantString) { + if (Strings::match($constantString->getValue(), '#^[0-9]+$#') === null) { + $onlyNumbers = false; + break; + } + } + + if ($onlyNumbers) { + $accessoryTypes[] = new AccessoryNumericStringType(); + } + } + } + + if ($inputType->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + + if ($inputType->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + return new IntersectionType($accessoryTypes); + } + return new StringType(); + } + +} diff --git a/src/Type/Php/StrSplitFunctionReturnTypeExtension.php b/src/Type/Php/StrSplitFunctionReturnTypeExtension.php new file mode 100644 index 00000000..c99e143d --- /dev/null +++ b/src/Type/Php/StrSplitFunctionReturnTypeExtension.php @@ -0,0 +1,140 @@ +getName(), ['str_split', 'mb_str_split'], true); + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 1) { + return null; + } + + if (count($functionCall->getArgs()) >= 2) { + $splitLengthType = $scope->getType($functionCall->getArgs()[1]->value); + if ($splitLengthType instanceof ConstantIntegerType) { + $splitLength = $splitLengthType->getValue(); + if ($splitLength < 1) { + return new ConstantBooleanType(false); + } + } + } else { + $splitLength = 1; + } + + $encoding = null; + if ($functionReflection->getName() === 'mb_str_split') { + if (count($functionCall->getArgs()) >= 3) { + $strings = $scope->getType($functionCall->getArgs()[2]->value)->getConstantStrings(); + $values = array_unique(array_map(static fn (ConstantStringType $encoding): string => $encoding->getValue(), $strings)); + + if (count($values) !== 1) { + return null; + } + + $encoding = $values[0]; + if (!$this->isSupportedEncoding($encoding)) { + return new ConstantBooleanType(false); + } + } else { + $encoding = mb_internal_encoding(); + } + } + + if (!isset($splitLength)) { + return null; + } + + $stringType = $scope->getType($functionCall->getArgs()[0]->value); + + $constantStrings = $stringType->getConstantStrings(); + if (count($constantStrings) > 0) { + $results = []; + foreach ($constantStrings as $constantString) { + $items = $encoding === null + ? str_split($constantString->getValue(), $splitLength) + : @mb_str_split($constantString->getValue(), $splitLength, $encoding); + if ($items === false) { + throw new ShouldNotHappenException(); + } + + $results[] = self::createConstantArrayFrom($items, $scope); + } + + return TypeCombinator::union(...$results); + } + + $returnType = TypeCombinator::intersect(new ArrayType(new IntegerType(), new StringType()), new AccessoryArrayListType()); + + return $encoding === null && !$this->phpVersion->strSplitReturnsEmptyArray() + ? TypeCombinator::intersect($returnType, new NonEmptyArrayType()) + : $returnType; + } + + /** + * @param string[] $constantArray + */ + private static function createConstantArrayFrom(array $constantArray, Scope $scope): ConstantArrayType + { + $keyTypes = []; + $valueTypes = []; + $isList = true; + $i = 0; + + foreach ($constantArray as $key => $value) { + $keyType = $scope->getTypeFromValue($key); + if (!$keyType instanceof ConstantIntegerType) { + throw new ShouldNotHappenException(); + } + $keyTypes[] = $keyType; + + $valueTypes[] = $scope->getTypeFromValue($value); + + $isList = $isList && $key === $i; + $i++; + } + + return new ConstantArrayType($keyTypes, $valueTypes, $isList ? [$i] : [0], [], TrinaryLogic::createFromBoolean(array_is_list($constantArray))); + } + +} diff --git a/src/Type/Php/StrTokFunctionReturnTypeExtension.php b/src/Type/Php/StrTokFunctionReturnTypeExtension.php new file mode 100644 index 00000000..4b8efc5c --- /dev/null +++ b/src/Type/Php/StrTokFunctionReturnTypeExtension.php @@ -0,0 +1,46 @@ +getName() === 'strtok'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) !== 2) { + return null; + } + + $delimiterType = $scope->getType($functionCall->getArgs()[0]->value); + $isEmptyString = (new ConstantStringType(''))->isSuperTypeOf($delimiterType); + if ($isEmptyString->yes()) { + return new ConstantBooleanType(false); + } + + if ($isEmptyString->no()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + return null; + } + +} diff --git a/src/Type/Php/StrWordCountFunctionDynamicReturnTypeExtension.php b/src/Type/Php/StrWordCountFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..31e8be40 --- /dev/null +++ b/src/Type/Php/StrWordCountFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName() === 'str_word_count'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + Node\Expr\FuncCall $functionCall, + Scope $scope, + ): Type + { + $argsCount = count($functionCall->getArgs()); + if ($argsCount === 1) { + return new IntegerType(); + } elseif ($argsCount === 2 || $argsCount === 3) { + $formatType = $scope->getType($functionCall->getArgs()[1]->value); + if ($formatType instanceof ConstantIntegerType) { + $val = $formatType->getValue(); + if ($val === 0) { + // return word count + return new IntegerType(); + } elseif ($val === 1 || $val === 2) { + // return [word] or [offset => word] + return new ArrayType(new IntegerType(), new StringType()); + } + + // return false, invalid format value specified + return new ConstantBooleanType(false); + } + + // Could be invalid format type as well, but parameter type checks will catch that. + + return new UnionType([ + new IntegerType(), + new ArrayType(new IntegerType(), new StringType()), + new ConstantBooleanType(false), + ]); + } + + // else fatal error; too many or too few arguments + return new ErrorType(); + } + +} diff --git a/src/Type/Php/StrlenFunctionReturnTypeExtension.php b/src/Type/Php/StrlenFunctionReturnTypeExtension.php new file mode 100644 index 00000000..3bc44ab6 --- /dev/null +++ b/src/Type/Php/StrlenFunctionReturnTypeExtension.php @@ -0,0 +1,85 @@ +getName() === 'strlen'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) === 0) { + return null; + } + + $argType = $scope->getType($args[0]->value); + $constantScalars = $argType->getConstantScalarTypes(); + + $lengths = []; + foreach ($constantScalars as $constantScalar) { + $stringScalar = $constantScalar->toString(); + if (!($stringScalar instanceof ConstantStringType)) { + $lengths = []; + break; + } + $length = strlen($stringScalar->getValue()); + $lengths[] = $length; + } + + $isNonEmpty = $argType->isNonEmptyString(); + $numeric = TypeCombinator::union(new IntegerType(), new FloatType()); + $range = null; + if (count($lengths) > 0) { + $lengths = array_unique($lengths); + sort($lengths); + if ($lengths === range(min($lengths), max($lengths))) { + $range = IntegerRangeType::fromInterval(min($lengths), max($lengths)); + } else { + $range = TypeCombinator::union(...array_map(static fn ($l) => new ConstantIntegerType($l), $lengths)); + } + } elseif ($argType->isBoolean()->yes()) { + $range = IntegerRangeType::fromInterval(0, 1); + } elseif ( + $isNonEmpty->yes() + || $numeric->isSuperTypeOf($argType)->yes() + || TypeCombinator::remove($argType, $numeric)->isNonEmptyString()->yes() + ) { + $range = IntegerRangeType::fromInterval(1, null); + } elseif ($argType->isString()->yes() && $isNonEmpty->no()) { + $range = new ConstantIntegerType(0); + } + + return $range; + } + +} diff --git a/src/Type/Php/StrtotimeFunctionReturnTypeExtension.php b/src/Type/Php/StrtotimeFunctionReturnTypeExtension.php new file mode 100644 index 00000000..c4f1fdee --- /dev/null +++ b/src/Type/Php/StrtotimeFunctionReturnTypeExtension.php @@ -0,0 +1,71 @@ +getName() === 'strtotime'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + if (count($functionCall->getArgs()) === 0) { + return $defaultReturnType; + } + $argType = $scope->getType($functionCall->getArgs()[0]->value); + if ($argType instanceof MixedType) { + return TypeUtils::toBenevolentUnion($defaultReturnType); + } + $results = array_unique(array_map(static fn (ConstantStringType $string): int|bool => strtotime($string->getValue()), $argType->getConstantStrings())); + $resultTypes = array_unique(array_map(static fn (int|bool $value): string => gettype($value), $results)); + + if (count($resultTypes) !== 1 || count($results) === 0) { + return $defaultReturnType; + } + + if ($results[0] === false) { + return new ConstantBooleanType(false); + } + + // 2nd param $baseTimestamp is too non-deterministic so simply return int + if (count($functionCall->getArgs()) > 1) { + return new IntegerType(); + } + + // if it is positive we can narrow down to positive-int as long as time flows forward + if (min(array_map('intval', $results)) > 0) { + return IntegerRangeType::createAllGreaterThan(0); + } + + return new IntegerType(); + } + +} diff --git a/src/Type/Php/StrvalFamilyFunctionReturnTypeExtension.php b/src/Type/Php/StrvalFamilyFunctionReturnTypeExtension.php new file mode 100644 index 00000000..a9fdeb3b --- /dev/null +++ b/src/Type/Php/StrvalFamilyFunctionReturnTypeExtension.php @@ -0,0 +1,64 @@ +getName(), self::FUNCTIONS, true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + if (count($functionCall->getArgs()) === 0) { + return new NullType(); + } + + $argType = $scope->getType($functionCall->getArgs()[0]->value); + + switch ($functionReflection->getName()) { + case 'strval': + return $argType->toString(); + case 'intval': + $type = $argType->toInteger(); + return $type instanceof ErrorType ? new IntegerType() : $type; + case 'boolval': + return $argType->toBoolean(); + case 'floatval': + case 'doubleval': + $type = $argType->toFloat(); + return $type instanceof ErrorType ? new FloatType() : $type; + default: + throw new ShouldNotHappenException(); + } + } + +} diff --git a/src/Type/Php/SubstrDynamicReturnTypeExtension.php b/src/Type/Php/SubstrDynamicReturnTypeExtension.php new file mode 100644 index 00000000..5f23cebd --- /dev/null +++ b/src/Type/Php/SubstrDynamicReturnTypeExtension.php @@ -0,0 +1,131 @@ +getName(), ['substr', 'mb_substr'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $string = $scope->getType($args[0]->value); + $offset = $scope->getType($args[1]->value); + + $negativeOffset = IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($offset)->yes(); + $zeroOffset = (new ConstantIntegerType(0))->isSuperTypeOf($offset)->yes(); + $length = null; + $positiveLength = false; + $maybeOneLength = false; + + if (count($args) === 3) { + $length = $scope->getType($args[2]->value); + $positiveLength = IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($length)->yes(); + $maybeOneLength = !(new ConstantIntegerType(1))->isSuperTypeOf($length)->no(); + } + + $constantStrings = $string->getConstantStrings(); + if ( + count($constantStrings) > 0 + && $offset instanceof ConstantIntegerType + && ($length === null || $length instanceof ConstantIntegerType) + ) { + $results = []; + foreach ($constantStrings as $constantString) { + if ($length !== null) { + if ($functionReflection->getName() === 'mb_substr') { + $substr = mb_substr($constantString->getValue(), $offset->getValue(), $length->getValue()); + } else { + $substr = substr($constantString->getValue(), $offset->getValue(), $length->getValue()); + } + } else { + if ($functionReflection->getName() === 'mb_substr') { + $substr = mb_substr($constantString->getValue(), $offset->getValue()); + } else { + $substr = substr($constantString->getValue(), $offset->getValue()); + } + } + + if (is_bool($substr)) { + $results[] = new ConstantBooleanType($substr); + } else { + $results[] = new ConstantStringType($substr); + } + } + + return TypeCombinator::union(...$results); + } + + $accessoryTypes = []; + $isNotEmpty = false; + if ($string->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($string->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + if ($string->isNonEmptyString()->yes() && ($negativeOffset || $zeroOffset && $positiveLength)) { + $isNotEmpty = true; + if ($string->isNonFalsyString()->yes() && !$maybeOneLength) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } else { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + } + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + + if (!$isNotEmpty && $this->phpVersion->substrReturnFalseInsteadOfEmptyString()) { + return TypeCombinator::union( + new ConstantBooleanType(false), + new IntersectionType($accessoryTypes), + ); + } + + return new IntersectionType($accessoryTypes); + } + + return null; + } + +} diff --git a/src/Type/Php/ThrowableReturnTypeExtension.php b/src/Type/Php/ThrowableReturnTypeExtension.php new file mode 100644 index 00000000..0deca816 --- /dev/null +++ b/src/Type/Php/ThrowableReturnTypeExtension.php @@ -0,0 +1,78 @@ +getName() === 'getCode'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $type = $scope->getType($methodCall->var); + $types = []; + $pdoException = new ObjectType('PDOException'); + foreach ($type->getObjectClassNames() as $class) { + $classType = new ObjectType($class); + if ($classType->getClassReflection() !== null) { + $classReflection = $classType->getClassReflection(); + foreach ($classReflection->getMethodTags() as $methodName => $methodTag) { + if (strtolower($methodName) !== 'getcode') { + continue; + } + + $types[] = $methodTag->getReturnType(); + continue 2; + } + } + + if ($pdoException->isSuperTypeOf($classType)->yes()) { + $types[] = new BenevolentUnionType([new IntegerType(), new StringType()]); + continue; + } + + if (in_array(strtolower($class), [ + 'throwable', + 'exception', + 'runtimeexception', + ], true)) { + $types[] = new BenevolentUnionType([new IntegerType(), new StringType()]); + continue; + } + + $types[] = new IntegerType(); + } + + if (count($types) === 0) { + return new ErrorType(); + } + + return TypeCombinator::union(...$types); + } + +} diff --git a/src/Type/Php/TriggerErrorDynamicReturnTypeExtension.php b/src/Type/Php/TriggerErrorDynamicReturnTypeExtension.php new file mode 100644 index 00000000..2a5d6c91 --- /dev/null +++ b/src/Type/Php/TriggerErrorDynamicReturnTypeExtension.php @@ -0,0 +1,69 @@ +getName() === 'trigger_error'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + + if (count($args) === 0) { + return null; + } + + if (count($args) === 1) { + return new ConstantBooleanType(true); + } + + $errorType = $scope->getType($args[1]->value); + + if ($errorType instanceof ConstantIntegerType) { + $errorLevel = $errorType->getValue(); + + if ($errorLevel === E_USER_ERROR) { + return new NeverType(true); + } + + if (!in_array($errorLevel, [E_USER_WARNING, E_USER_NOTICE, E_USER_DEPRECATED], true)) { + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + return new NeverType(true); + } + + return new ConstantBooleanType(false); + } + + return new ConstantBooleanType(true); + } + + return null; + } + +} diff --git a/src/Type/Php/TrimFunctionDynamicReturnTypeExtension.php b/src/Type/Php/TrimFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..be86cdfa --- /dev/null +++ b/src/Type/Php/TrimFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,53 @@ +getName(), ['trim', 'rtrim', 'ltrim'], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 1) { + return null; + } + + $stringType = $scope->getType($args[0]->value); + $accessory = []; + if ($stringType->isLowercaseString()->yes()) { + $accessory[] = new AccessoryLowercaseStringType(); + } + if ($stringType->isUppercaseString()->yes()) { + $accessory[] = new AccessoryUppercaseStringType(); + } + if (count($accessory) > 0) { + $accessory[] = new StringType(); + return new IntersectionType($accessory); + } + + return new StringType(); + } + +} diff --git a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php new file mode 100644 index 00000000..c109cdf4 --- /dev/null +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -0,0 +1,78 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return in_array($functionReflection->getName(), [ + 'array_key_exists', + 'key_exists', + 'in_array', + 'is_subclass_of', + ], true); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + if (count($functionCall->getArgs()) === 0) { + return null; + } + + $isAlways = $this->getHelper()->findSpecifiedType( + $scope, + $functionCall, + ); + if ($isAlways === null) { + return null; + } + + return new ConstantBooleanType($isAlways); + } + + private function getHelper(): ImpossibleCheckTypeHelper + { + if ($this->helper === null) { + $this->helper = new ImpossibleCheckTypeHelper($this->reflectionProvider, $this->typeSpecifier, $this->universalObjectCratesClasses, $this->treatPhpDocTypesAsCertain); + } + + return $this->helper; + } + +} diff --git a/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..61c9751b --- /dev/null +++ b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,125 @@ +getName() === 'version_compare'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $version1Strings = $this->getVersionStrings($args[0]->value, $scope); + $version2Strings = $this->getVersionStrings($args[1]->value, $scope); + $counts = [ + count($version1Strings), + count($version2Strings), + ]; + + if (isset($args[2])) { + $operatorStrings = $scope->getType($args[2]->value)->getConstantStrings(); + $counts[] = count($operatorStrings); + $returnType = new BooleanType(); + } else { + $returnType = TypeCombinator::union( + new ConstantIntegerType(-1), + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ); + } + + if (count(array_filter($counts, static fn (int $count): bool => $count === 0)) > 0) { + return $returnType; // one of the arguments is not a constant string + } + + if (count(array_filter($counts, static fn (int $count): bool => $count > 1)) > 1) { + return $returnType; // more than one argument can have multiple possibilities, avoid combinatorial explosion + } + + $types = []; + foreach ($version1Strings as $version1String) { + foreach ($version2Strings as $version2String) { + if (isset($operatorStrings)) { + foreach ($operatorStrings as $operatorString) { + $value = version_compare($version1String->getValue(), $version2String->getValue(), $operatorString->getValue()); + $types[$value] = new ConstantBooleanType($value); + } + } else { + $value = version_compare($version1String->getValue(), $version2String->getValue()); + $types[$value] = new ConstantIntegerType($value); + } + } + } + return TypeCombinator::union(...$types); + } + + /** + * @return ConstantStringType[] + */ + private function getVersionStrings(Expr $expr, Scope $scope): array + { + if ( + $expr instanceof Expr\ConstFetch + && $expr->name->toString() === 'PHP_VERSION' + ) { + if (is_array($this->configPhpVersion)) { + $minVersion = new PhpVersion($this->configPhpVersion['min']); + $maxVersion = new PhpVersion($this->configPhpVersion['max']); + } else { + $minVersion = $this->composerPhpVersionFactory->getMinVersion(); + $maxVersion = $this->composerPhpVersionFactory->getMaxVersion(); + } + + if ($minVersion !== null && $maxVersion !== null) { + return [ + new ConstantStringType($minVersion->getVersionString()), + new ConstantStringType($maxVersion->getVersionString()), + ]; + } + } + + return $scope->getType($expr)->getConstantStrings(); + } + +} diff --git a/src/Type/Php/XMLReaderOpenReturnTypeExtension.php b/src/Type/Php/XMLReaderOpenReturnTypeExtension.php new file mode 100644 index 00000000..eb1afd2d --- /dev/null +++ b/src/Type/Php/XMLReaderOpenReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'open'; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $this->isMethodSupported($methodReflection); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return new BooleanType(); + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + return new UnionType([new ObjectType(self::XML_READER_CLASS), new ConstantBooleanType(false)]); + } + +} diff --git a/src/Type/RecursionGuard.php b/src/Type/RecursionGuard.php new file mode 100644 index 00000000..1c55ddfc --- /dev/null +++ b/src/Type/RecursionGuard.php @@ -0,0 +1,32 @@ +describe(VerbosityLevel::value()); + if (isset(self::$context[$key])) { + return new ErrorType(); + } + + try { + self::$context[$key] = true; + return $callback(); + } finally { + unset(self::$context[$key]); + } + } + +} diff --git a/src/Type/Regex/RegexAlternation.php b/src/Type/Regex/RegexAlternation.php new file mode 100644 index 00000000..fadd6cf8 --- /dev/null +++ b/src/Type/Regex/RegexAlternation.php @@ -0,0 +1,48 @@ +> */ + private array $groupCombinations = []; + + public function __construct( + private readonly int $alternationId, + private readonly int $alternationsCount, + ) + { + } + + public function getId(): int + { + return $this->alternationId; + } + + public function pushGroup(int $combinationIndex, RegexCapturingGroup $group): void + { + if (!array_key_exists($combinationIndex, $this->groupCombinations)) { + $this->groupCombinations[$combinationIndex] = []; + } + + $this->groupCombinations[$combinationIndex][] = $group->getId(); + } + + public function getAlternationsCount(): int + { + return $this->alternationsCount; + } + + /** + * @return array> + */ + public function getGroupCombinations(): array + { + return $this->groupCombinations; + } + +} diff --git a/src/Type/Regex/RegexAstWalkResult.php b/src/Type/Regex/RegexAstWalkResult.php new file mode 100644 index 00000000..d3cccd94 --- /dev/null +++ b/src/Type/Regex/RegexAstWalkResult.php @@ -0,0 +1,106 @@ + $capturingGroups + * @param list $markVerbs + */ + public function __construct( + private int $alternationId, + private int $captureGroupId, + private array $capturingGroups, + private array $markVerbs, + ) + { + } + + public static function createEmpty(): self + { + return new self( + -1, + // use different start-index for groups to make it easier to distinguish groupids from other ids + 100, + [], + [], + ); + } + + public function nextAlternationId(): self + { + return new self( + $this->alternationId + 1, + $this->captureGroupId, + $this->capturingGroups, + $this->markVerbs, + ); + } + + public function nextCaptureGroupId(): self + { + return new self( + $this->alternationId, + $this->captureGroupId + 1, + $this->capturingGroups, + $this->markVerbs, + ); + } + + public function addCapturingGroup(RegexCapturingGroup $group): self + { + $capturingGroups = $this->capturingGroups; + $capturingGroups[$group->getId()] = $group; + + return new self( + $this->alternationId, + $this->captureGroupId, + $capturingGroups, + $this->markVerbs, + ); + } + + public function markVerb(string $markVerb): self + { + $verbs = $this->markVerbs; + $verbs[] = $markVerb; + + return new self( + $this->alternationId, + $this->captureGroupId, + $this->capturingGroups, + $verbs, + ); + } + + public function getAlternationId(): int + { + return $this->alternationId; + } + + public function getCaptureGroupId(): int + { + return $this->captureGroupId; + } + + /** + * @return array + */ + public function getCapturingGroups(): array + { + return $this->capturingGroups; + } + + /** + * @return list + */ + public function getMarkVerbs(): array + { + return $this->markVerbs; + } + +} diff --git a/src/Type/Regex/RegexCapturingGroup.php b/src/Type/Regex/RegexCapturingGroup.php new file mode 100644 index 00000000..86fbb88a --- /dev/null +++ b/src/Type/Regex/RegexCapturingGroup.php @@ -0,0 +1,132 @@ +id; + } + + public function forceNonOptional(): void + { + $this->forceNonOptional = true; + } + + public function forceType(Type $type): void + { + $this->forceType = $type; + } + + public function clearOverrides(): void + { + $this->forceNonOptional = false; + $this->forceType = null; + } + + public function resetsGroupCounter(): bool + { + return $this->parent instanceof RegexNonCapturingGroup && $this->parent->resetsGroupCounter(); + } + + /** + * @phpstan-assert-if-true !null $this->getAlternationId() + * @phpstan-assert-if-true !null $this->getAlternation() + */ + public function inAlternation(): bool + { + return $this->alternation !== null; + } + + public function getAlternation(): ?RegexAlternation + { + return $this->alternation; + } + + public function getAlternationId(): ?int + { + if ($this->alternation === null) { + return null; + } + + return $this->alternation->getId(); + } + + public function isOptional(): bool + { + if ($this->forceNonOptional) { + return false; + } + + return $this->inAlternation() + || $this->inOptionalQuantification + || $this->parent !== null && $this->parent->isOptional(); + } + + public function inOptionalQuantification(): bool + { + return $this->inOptionalQuantification; + } + + public function inOptionalAlternation(): bool + { + if (!$this->inAlternation()) { + return false; + } + + $parent = $this->parent; + while ($parent !== null && $parent->getAlternationId() === $this->getAlternationId()) { + if (!$parent instanceof RegexNonCapturingGroup) { + return false; + } + $parent = $parent->getParent(); + } + return $parent !== null && $parent->isOptional(); + } + + public function isTopLevel(): bool + { + return $this->parent === null + || $this->parent instanceof RegexNonCapturingGroup && $this->parent->isTopLevel(); + } + + /** @phpstan-assert-if-true !null $this->getName() */ + public function isNamed(): bool + { + return $this->name !== null; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getType(): Type + { + if ($this->forceType !== null) { + return $this->forceType; + } + return $this->type; + } + +} diff --git a/src/Type/Regex/RegexExpressionHelper.php b/src/Type/Regex/RegexExpressionHelper.php new file mode 100644 index 00000000..9bd1bf9e --- /dev/null +++ b/src/Type/Regex/RegexExpressionHelper.php @@ -0,0 +1,163 @@ +name instanceof Name + && $expr->name->toLowerString() === 'preg_quote' + ) { + return new ConstantStringType('(?:.*)'); + } + + if ($expr instanceof Concat) { + $left = $this->resolve($expr->left); + $right = $this->resolve($expr->right); + + $strings = []; + foreach ($left->toString()->getConstantStrings() as $leftString) { + foreach ($right->toString()->getConstantStrings() as $rightString) { + $strings[] = new ConstantStringType($leftString->getValue() . $rightString->getValue()); + } + } + + return TypeCombinator::union(...$strings); + } + + return $this->scope->getType($expr); + } + + }; + + return $this->initializerExprTypeResolver->getConcatType($concat->left, $concat->right, static fn (Expr $expr): Type => $resolver->resolve($expr)); + } + + public function getPatternModifiers(string $pattern): ?string + { + $endDelimiterPos = $this->getEndDelimiterPos($pattern); + + if ($endDelimiterPos === false) { + return null; + } + + return substr($pattern, $endDelimiterPos + 1); + } + + public function removeDelimitersAndModifiers(string $pattern): string + { + $pattern = ltrim($pattern); + + $endDelimiterPos = $this->getEndDelimiterPos($pattern); + + if ($endDelimiterPos === false) { + return $pattern; + } + + return substr($pattern, 1, $endDelimiterPos - 1); + } + + private function getEndDelimiterPos(string $pattern): false|int + { + $startDelimiter = $this->getPatternDelimiter($pattern); + if ($startDelimiter === null) { + return false; + } + + // delimiter variants, see https://www.php.net/manual/en/regexp.reference.delimiters.php + $bracketStyleDelimiters = [ + '{' => '}', + '(' => ')', + '[' => ']', + '<' => '>', + ]; + if (array_key_exists($startDelimiter, $bracketStyleDelimiters)) { + $endDelimiterPos = strrpos($pattern, $bracketStyleDelimiters[$startDelimiter]); + } else { + // same start and end delimiter + $endDelimiterPos = strrpos($pattern, $startDelimiter); + } + + return $endDelimiterPos; + } + + /** + * Get delimiters from non-constant patterns, if possible. + * + * @return string[] + */ + public function getPatternDelimiters(Concat $concat, Scope $scope): array + { + if ($concat->left instanceof Concat) { + return $this->getPatternDelimiters($concat->left, $scope); + } + + $left = $scope->getType($concat->left); + + $delimiters = []; + foreach ($left->getConstantStrings() as $leftString) { + $delimiter = $this->getPatternDelimiter($leftString->getValue()); + if ($delimiter === null) { + continue; + } + + $delimiters[] = $delimiter; + } + return $delimiters; + } + + private function getPatternDelimiter(string $regex): ?string + { + $regex = ltrim($regex); + + if ($regex === '') { + return null; + } + + return substr($regex, 0, 1); + } + +} diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php new file mode 100644 index 00000000..e48eb7f5 --- /dev/null +++ b/src/Type/Regex/RegexGroupParser.php @@ -0,0 +1,689 @@ +, list}|null + */ + public function parseGroups(string $regex): ?array + { + if (self::$parser === null) { + /** @throws void */ + self::$parser = Llk::load(new Read(__DIR__ . '/../../../resources/RegexGrammar.pp')); + } + + try { + Strings::match('', $regex); + } catch (RegexpException) { + // pattern is invalid, so let the RegularExpressionPatternRule report it + return null; + } + + $modifiers = $this->regexExpressionHelper->getPatternModifiers($regex) ?? ''; + foreach (self::NOT_SUPPORTED_MODIFIERS as $notSupportedModifier) { + if (str_contains($modifiers, $notSupportedModifier)) { + return null; + } + } + + if (str_contains($modifiers, 'x')) { + // in freespacing mode the # character starts a comment and runs until the end of the line + $regex = preg_replace('/(?regexExpressionHelper->removeDelimitersAndModifiers($regex); + try { + $ast = self::$parser->parse($rawRegex); + } catch (Exception) { + return null; + } + + $this->updateAlternationAstRemoveVerticalBarsAndAddEmptyToken($ast); + $this->updateCapturingAstAddEmptyToken($ast); + + $captureOnlyNamed = false; + if ($this->phpVersion->supportsPregCaptureOnlyNamedGroups()) { + $captureOnlyNamed = str_contains($modifiers, 'n'); + } + + $astWalkResult = $this->walkRegexAst( + $ast, + null, + 0, + false, + null, + $captureOnlyNamed, + false, + $modifiers, + RegexAstWalkResult::createEmpty(), + ); + + return [$astWalkResult->getCapturingGroups(), $astWalkResult->getMarkVerbs()]; + } + + private function createEmptyTokenTreeNode(TreeNode $parentAst): TreeNode + { + return new TreeNode('token', ['token' => 'literal', 'value' => '', 'namespace' => 'default'], [], $parentAst); + } + + private function updateAlternationAstRemoveVerticalBarsAndAddEmptyToken(TreeNode $ast): void + { + $children = $ast->getChildren(); + + foreach ($children as $i => $child) { + $this->updateAlternationAstRemoveVerticalBarsAndAddEmptyToken($child); + + if ($ast->getId() !== '#alternation' || $child->getValueToken() !== 'alternation') { + continue; + } + + unset($children[$i]); + + if ($i !== 0 + && isset($children[$i + 1]) + && $children[$i + 1]->getValueToken() !== 'alternation') { + continue; + } + + $children[$i] = $this->createEmptyTokenTreeNode($ast); + } + + $ast->setChildren(array_values($children)); + } + + private function updateCapturingAstAddEmptyToken(TreeNode $ast): void + { + foreach ($ast->getChildren() as $child) { + $this->updateCapturingAstAddEmptyToken($child); + } + + if ($ast->getId() !== '#capturing' || $ast->getChildren() !== []) { + return; + } + + $emptyAlternationAst = new TreeNode('#alternation', null, [], $ast); + $emptyAlternationAst->setChildren([$this->createEmptyTokenTreeNode($emptyAlternationAst)]); + $ast->setChildren([$emptyAlternationAst]); + } + + private function walkRegexAst( + TreeNode $ast, + ?RegexAlternation $alternation, + int $combinationIndex, + bool $inOptionalQuantification, + RegexCapturingGroup|RegexNonCapturingGroup|null $parentGroup, + bool $captureOnlyNamed, + bool $repeatedMoreThanOnce, + string $patternModifiers, + RegexAstWalkResult $astWalkResult, + ): RegexAstWalkResult + { + $group = null; + if ($ast->getId() === '#capturing') { + $astWalkResult = $astWalkResult->nextCaptureGroupId(); + + $group = new RegexCapturingGroup( + $astWalkResult->getCaptureGroupId(), + null, + $alternation, + $inOptionalQuantification, + $parentGroup, + $this->createGroupType( + $ast, + $this->allowConstantTypes($patternModifiers, $repeatedMoreThanOnce, $parentGroup), + $patternModifiers, + ), + ); + $parentGroup = $group; + } elseif ($ast->getId() === '#namedcapturing') { + $astWalkResult = $astWalkResult->nextCaptureGroupId(); + + $name = $ast->getChild(0)->getValueValue(); + $group = new RegexCapturingGroup( + $astWalkResult->getCaptureGroupId(), + $name, + $alternation, + $inOptionalQuantification, + $parentGroup, + $this->createGroupType( + $ast, + $this->allowConstantTypes($patternModifiers, $repeatedMoreThanOnce, $parentGroup), + $patternModifiers, + ), + ); + $parentGroup = $group; + } elseif ($ast->getId() === '#noncapturing') { + $group = new RegexNonCapturingGroup( + $alternation, + $inOptionalQuantification, + $parentGroup, + false, + ); + $parentGroup = $group; + } elseif ($ast->getId() === '#noncapturingreset') { + $group = new RegexNonCapturingGroup( + $alternation, + $inOptionalQuantification, + $parentGroup, + true, + ); + $parentGroup = $group; + } + + $inOptionalQuantification = false; + if ($ast->getId() === '#quantification') { + [$min, $max] = $this->getQuantificationRange($ast); + + if ($min === 0) { + $inOptionalQuantification = true; + } + + if ($max === null || $max > 1) { + $repeatedMoreThanOnce = true; + } + } + + if ($ast->getId() === '#alternation') { + $astWalkResult = $astWalkResult->nextAlternationId(); + $alternation = new RegexAlternation($astWalkResult->getAlternationId(), count($ast->getChildren())); + } + + if ($ast->getId() === '#mark') { + return $astWalkResult->markVerb($ast->getChild(0)->getValueValue()); + } + + if ( + $group instanceof RegexCapturingGroup && + (!$captureOnlyNamed || $group->isNamed()) + ) { + $astWalkResult = $astWalkResult->addCapturingGroup($group); + + if ($alternation !== null) { + $alternation->pushGroup($combinationIndex, $group); + } + } + + foreach ($ast->getChildren() as $child) { + $astWalkResult = $this->walkRegexAst( + $child, + $alternation, + $combinationIndex, + $inOptionalQuantification, + $parentGroup, + $captureOnlyNamed, + $repeatedMoreThanOnce, + $patternModifiers, + $astWalkResult, + ); + + if ($ast->getId() !== '#alternation') { + continue; + } + + $combinationIndex++; + } + + return $astWalkResult; + } + + private function allowConstantTypes( + string $patternModifiers, + bool $repeatedMoreThanOnce, + RegexCapturingGroup|RegexNonCapturingGroup|null $parentGroup, + ): bool + { + if (str_contains($patternModifiers, 'i')) { + // if caseless, we don't use constant types + // because it likely yields too many combinations + return false; + } + + if ($repeatedMoreThanOnce) { + return false; + } + + if ($parentGroup !== null && $parentGroup->resetsGroupCounter()) { + return false; + } + + return true; + } + + /** @return array{?int, ?int} */ + private function getQuantificationRange(TreeNode $node): array + { + if ($node->getId() !== '#quantification') { + throw new ShouldNotHappenException(); + } + + $min = null; + $max = null; + + $lastChild = $node->getChild($node->getChildrenNumber() - 1); + $value = $lastChild->getValue(); + + // normalize away possessive and lazy quantifier-modifiers + $token = str_replace(['_possessive', '_lazy'], '', $value['token']); + $value = rtrim($value['value'], '+?'); + + if ($token === 'n_to_m') { + if (sscanf($value, '{%d,%d}', $n, $m) !== 2 || !is_int($n) || !is_int($m)) { + throw new ShouldNotHappenException(); + } + + $min = $n; + $max = $m; + } elseif ($token === 'n_or_more') { + if (sscanf($value, '{%d,}', $n) !== 1 || !is_int($n)) { + throw new ShouldNotHappenException(); + } + + $min = $n; + } elseif ($token === 'exactly_n') { + if (sscanf($value, '{%d}', $n) !== 1 || !is_int($n)) { + throw new ShouldNotHappenException(); + } + + $min = $n; + $max = $n; + } elseif ($token === 'zero_or_one') { + $min = 0; + $max = 1; + } elseif ($token === 'zero_or_more') { + $min = 0; + } elseif ($token === 'one_or_more') { + $min = 1; + } + + return [$min, $max]; + } + + private function createGroupType(TreeNode $group, bool $maybeConstant, string $patternModifiers): Type + { + $rootAlternation = $this->getRootAlternation($group); + if ($rootAlternation !== null) { + $types = []; + foreach ($rootAlternation->getChildren() as $alternative) { + $types[] = $this->createGroupType($alternative, $maybeConstant, $patternModifiers); + } + + return TypeCombinator::union(...$types); + } + + $walkResult = $this->walkGroupAst( + $group, + false, + false, + $patternModifiers, + RegexGroupWalkResult::createEmpty(), + ); + + if ($maybeConstant && $walkResult->getOnlyLiterals() !== null && $walkResult->getOnlyLiterals() !== []) { + $result = []; + foreach ($walkResult->getOnlyLiterals() as $literal) { + $result[] = new ConstantStringType($literal); + + } + return TypeCombinator::union(...$result); + } + + if ($walkResult->isNumeric()->yes()) { + if ($walkResult->isNonFalsy()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + new AccessoryNonFalsyStringType(), + ]); + } + + $result = new IntersectionType([new StringType(), new AccessoryNumericStringType()]); + if (!$walkResult->isNonEmpty()->yes()) { + return TypeCombinator::union(new ConstantStringType(''), $result); + } + return $result; + } elseif ($walkResult->isNonFalsy()->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); + } elseif ($walkResult->isNonEmpty()->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + return new StringType(); + } + + private function getRootAlternation(TreeNode $group): ?TreeNode + { + if ( + $group->getId() === '#capturing' + && count($group->getChildren()) === 1 + && $group->getChild(0)->getId() === '#alternation' + ) { + return $group->getChild(0); + } + + // 1st token within a named capturing group is a token holding the group-name + if ( + $group->getId() === '#namedcapturing' + && count($group->getChildren()) === 2 + && $group->getChild(1)->getId() === '#alternation' + ) { + return $group->getChild(1); + } + + return null; + } + + private function walkGroupAst( + TreeNode $ast, + bool $inAlternation, + bool $inClass, + string $patternModifiers, + RegexGroupWalkResult $walkResult, + ): RegexGroupWalkResult + { + $children = $ast->getChildren(); + + if ( + $ast->getId() === '#concatenation' + && count($children) > 0 + && !$walkResult->isInOptionalQuantification() + ) { + $meaningfulTokens = 0; + foreach ($children as $child) { + $nonFalsy = false; + if ($this->isMaybeEmptyNode($child, $patternModifiers, $nonFalsy)) { + continue; + } + + $meaningfulTokens++; + + if (!$nonFalsy || $inAlternation) { + continue; + } + + // a single token non-falsy on its own + $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); + break; + } + + if ($meaningfulTokens > 0) { + $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); + + // two non-empty tokens concatenated results in a non-falsy string + if ($meaningfulTokens > 1 && !$inAlternation) { + $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); + } + } + } elseif ($ast->getId() === '#quantification') { + [$min] = $this->getQuantificationRange($ast); + + if ($min === 0) { + $walkResult = $walkResult->inOptionalQuantification(true); + } + + if (!$walkResult->isInOptionalQuantification()) { + if ($min >= 1) { + $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); + } + if ($min >= 2 && !$inAlternation) { + $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); + } + } + + $walkResult = $walkResult->onlyLiterals(null); + } elseif ($ast->getId() === '#class' && $walkResult->getOnlyLiterals() !== null) { + $inClass = true; + + $newLiterals = []; + foreach ($children as $child) { + $oldLiterals = $walkResult->getOnlyLiterals(); + + $this->getLiteralValue($child, $oldLiterals, true, $patternModifiers, true); + foreach ($oldLiterals ?? [] as $oldLiteral) { + $newLiterals[] = $oldLiteral; + } + } + $walkResult = $walkResult->onlyLiterals($newLiterals); + } elseif ($ast->getId() === 'token') { + $onlyLiterals = $walkResult->getOnlyLiterals(); + $literalValue = $this->getLiteralValue($ast, $onlyLiterals, !$inClass, $patternModifiers, false); + $walkResult = $walkResult->onlyLiterals($onlyLiterals); + + if ($literalValue !== null) { + if (Strings::match($literalValue, '/^\d+$/') === null) { + $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); + } elseif ($walkResult->isNumeric()->maybe()) { + $walkResult = $walkResult->numeric(TrinaryLogic::createYes()); + } + + if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') { + $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); + } + } + } elseif (!in_array($ast->getId(), ['#capturing', '#namedcapturing', '#alternation'], true)) { + $walkResult = $walkResult->onlyLiterals(null); + } + + if ($ast->getId() === '#alternation') { + $newLiterals = []; + foreach ($children as $child) { + $walkResult = $this->walkGroupAst( + $child, + true, + $inClass, + $patternModifiers, + $walkResult->onlyLiterals([]), + ); + + if ($newLiterals === null) { + continue; + } + + if (count($walkResult->getOnlyLiterals() ?? []) > 0) { + foreach ($walkResult->getOnlyLiterals() as $alternationLiterals) { + $newLiterals[] = $alternationLiterals; + } + } else { + $newLiterals = null; + } + } + + return $walkResult->onlyLiterals($newLiterals); + } + + // [^0-9] should not parse as numeric-string, and [^list-everything-but-numbers] is technically + // doable but really silly compared to just \d so we can safely assume the string is not numeric + // for negative classes + if ($ast->getId() === '#negativeclass') { + $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); + } + + foreach ($children as $child) { + $walkResult = $this->walkGroupAst( + $child, + $inAlternation, + $inClass, + $patternModifiers, + $walkResult, + ); + } + + return $walkResult; + } + + private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool + { + if ($node->getId() === '#quantification') { + [$min] = $this->getQuantificationRange($node); + + if ($min > 0) { + return false; + } + + if ($min === 0) { + return true; + } + } + + $literal = $this->getLiteralValue($node, $onlyLiterals, false, $patternModifiers, false); + if ($literal !== null) { + if ($literal !== '' && $literal !== '0') { + $isNonFalsy = true; + } + return $literal === ''; + } + + foreach ($node->getChildren() as $child) { + if (!$this->isMaybeEmptyNode($child, $patternModifiers, $isNonFalsy)) { + return false; + } + } + + return true; + } + + /** + * @param array|null $onlyLiterals + */ + private function getLiteralValue(TreeNode $node, ?array &$onlyLiterals, bool $appendLiterals, string $patternModifiers, bool $inCharacterClass): ?string + { + if ($node->getId() !== 'token') { + return null; + } + + // token is the token name from grammar without the namespace so literal and class:literal are both called literal here + $token = $node->getValueToken(); + $value = $node->getValueValue(); + + if ( + in_array($token, [ + 'literal', + // literal "-" in front/back of a character class like '[-a-z]' or '[abc-]', not forming a range + 'range', + // literal "[" or "]" inside character classes '[[]' or '[]]' + 'class_', '_class', + ], true) + ) { + if (str_contains($patternModifiers, 'x') && trim($value) === '') { + return null; + } + + $isEscaped = false; + if (strlen($value) > 1 && $value[0] === '\\') { + $value = substr($value, 1) ?: ''; + $isEscaped = true; + } + + if ( + $appendLiterals + && $onlyLiterals !== null + ) { + if ( + in_array($value, ['.'], true) + && !($isEscaped || $inCharacterClass) + ) { + $onlyLiterals = null; + } else { + if ($onlyLiterals === []) { + $onlyLiterals = [$value]; + } else { + foreach ($onlyLiterals as &$literal) { + $literal .= $value; + } + } + } + } + + return $value; + } + + if (!in_array($token, ['capturing_name'], true)) { + $onlyLiterals = null; + } + + // character escape sequences, just return a fixed string + if (in_array($token, ['character', 'dynamic_character', 'character_type'], true)) { + if ($token === 'character_type' && $value === '\d') { + return '0'; + } + + return $value; + } + + // [:digit:] and the like, more support coming later + if ($token === 'posix_class') { + if ($value === '[:digit:]') { + return '0'; + } + if (in_array($value, ['[:alpha:]', '[:alnum:]', '[:upper:]', '[:lower:]', '[:word:]', '[:ascii:]', '[:print:]', '[:xdigit:]', '[:graph:]'], true)) { + return 'a'; + } + if ($value === '[:blank:]') { + return " \t"; + } + if ($value === '[:cntrl:]') { + return "\x00\x1F"; + } + if ($value === '[:space:]') { + return " \t\r\n\v\f"; + } + if ($value === '[:punct:]') { + return '!"#$%&\'()*+,\-./:;<=>?@[\]^_`{|}~'; + } + } + + if ($token === 'anchor' || $token === 'match_point_reset') { + return ''; + } + + return null; + } + +} diff --git a/src/Type/Regex/RegexGroupWalkResult.php b/src/Type/Regex/RegexGroupWalkResult.php new file mode 100644 index 00000000..374c2602 --- /dev/null +++ b/src/Type/Regex/RegexGroupWalkResult.php @@ -0,0 +1,122 @@ +|null $onlyLiterals + */ + public function __construct( + private bool $inOptionalQuantification, + private ?array $onlyLiterals, + private TrinaryLogic $isNonEmpty, + private TrinaryLogic $isNonFalsy, + private TrinaryLogic $isNumeric, + ) + { + } + + public static function createEmpty(): self + { + return new self( + false, + [], + TrinaryLogic::createMaybe(), + TrinaryLogic::createMaybe(), + TrinaryLogic::createMaybe(), + ); + } + + public function inOptionalQuantification(bool $inOptionalQuantification): self + { + return new self( + $inOptionalQuantification, + $this->onlyLiterals, + $this->isNonEmpty, + $this->isNonFalsy, + $this->isNumeric, + ); + } + + /** + * @param array|null $onlyLiterals + */ + public function onlyLiterals(?array $onlyLiterals): self + { + return new self( + $this->inOptionalQuantification, + $onlyLiterals, + $this->isNonEmpty, + $this->isNonFalsy, + $this->isNumeric, + ); + } + + public function nonEmpty(TrinaryLogic $nonEmpty): self + { + return new self( + $this->inOptionalQuantification, + $this->onlyLiterals, + $nonEmpty, + $this->isNonFalsy, + $this->isNumeric, + ); + } + + public function nonFalsy(TrinaryLogic $nonFalsy): self + { + return new self( + $this->inOptionalQuantification, + $this->onlyLiterals, + $this->isNonEmpty, + $nonFalsy, + $this->isNumeric, + ); + } + + public function numeric(TrinaryLogic $numeric): self + { + return new self( + $this->inOptionalQuantification, + $this->onlyLiterals, + $this->isNonEmpty, + $this->isNonFalsy, + $numeric, + ); + } + + public function isInOptionalQuantification(): bool + { + return $this->inOptionalQuantification; + } + + /** + * @return array|null + */ + public function getOnlyLiterals(): ?array + { + return $this->onlyLiterals; + } + + public function isNonEmpty(): TrinaryLogic + { + return $this->isNonEmpty; + } + + public function isNonFalsy(): TrinaryLogic + { + return $this->isNonFalsy; + } + + public function isNumeric(): TrinaryLogic + { + return $this->isNumeric; + } + +} diff --git a/src/Type/Regex/RegexNonCapturingGroup.php b/src/Type/Regex/RegexNonCapturingGroup.php new file mode 100644 index 00000000..c9e2f0c8 --- /dev/null +++ b/src/Type/Regex/RegexNonCapturingGroup.php @@ -0,0 +1,56 @@ +getAlternationId() */ + public function inAlternation(): bool + { + return $this->alternation !== null; + } + + public function getAlternationId(): ?int + { + if ($this->alternation === null) { + return null; + } + + return $this->alternation->getId(); + } + + public function isOptional(): bool + { + return $this->inAlternation() + || $this->inOptionalQuantification + || ($this->parent !== null && $this->parent->isOptional()); + } + + public function isTopLevel(): bool + { + return $this->parent === null + || $this->parent instanceof RegexNonCapturingGroup && $this->parent->isTopLevel(); + } + + public function getParent(): RegexCapturingGroup|RegexNonCapturingGroup|null + { + return $this->parent; + } + + public function resetsGroupCounter(): bool + { + return $this->resetGroupCounter; + } + +} diff --git a/src/Type/ResourceType.php b/src/Type/ResourceType.php new file mode 100644 index 00000000..cd984abe --- /dev/null +++ b/src/Type/ResourceType.php @@ -0,0 +1,130 @@ +mapInternal($left, $right); + } + + /** @param callable(Type $left, Type $right, callable(Type, Type): Type $traverse): Type $cb */ + private function __construct(callable $cb) + { + $this->cb = $cb; + } + + /** @internal */ + public function mapInternal(Type $left, Type $right): Type + { + return ($this->cb)($left, $right, [$this, 'traverseInternal']); + } + + /** @internal */ + public function traverseInternal(Type $left, Type $right): Type + { + return $left->traverseSimultaneously($right, [$this, 'mapInternal']); + } + +} diff --git a/src/Type/StaticMethodParameterClosureTypeExtension.php b/src/Type/StaticMethodParameterClosureTypeExtension.php new file mode 100644 index 00000000..d79abddc --- /dev/null +++ b/src/Type/StaticMethodParameterClosureTypeExtension.php @@ -0,0 +1,33 @@ +subtractedType = $subtractedType; + $this->baseClass = $classReflection->getName(); + } + + public function getClassName(): string + { + return $this->baseClass; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getAncestorWithClassName(string $className): ?TypeWithClassName + { + $ancestor = $this->getStaticObjectType()->getAncestorWithClassName($className); + if ($ancestor === null) { + return null; + } + + $classReflection = $ancestor->getClassReflection(); + if ($classReflection !== null) { + return $this->changeBaseClass($classReflection); + } + + return null; + } + + public function getStaticObjectType(): ObjectType + { + if ($this->staticObjectType === null) { + if ($this->classReflection->isGeneric()) { + $typeMap = $this->classReflection->getActiveTemplateTypeMap()->map(static fn (string $name, Type $type): Type => TemplateTypeHelper::toArgument($type)); + $varianceMap = $this->classReflection->getCallSiteVarianceMap(); + return $this->staticObjectType = new GenericObjectType( + $this->classReflection->getName(), + $this->classReflection->typeMapToList($typeMap), + $this->subtractedType, + null, + $this->classReflection->varianceMapToList($varianceMap), + ); + } + + return $this->staticObjectType = new ObjectType($this->classReflection->getName(), $this->subtractedType, $this->classReflection); + } + + return $this->staticObjectType; + } + + public function getReferencedClasses(): array + { + return $this->getStaticObjectType()->getReferencedClasses(); + } + + public function getObjectClassNames(): array + { + return $this->getStaticObjectType()->getObjectClassNames(); + } + + public function getObjectClassReflections(): array + { + return $this->getStaticObjectType()->getObjectClassReflections(); + } + + public function getArrays(): array + { + return $this->getStaticObjectType()->getArrays(); + } + + public function getConstantArrays(): array + { + return $this->getStaticObjectType()->getConstantArrays(); + } + + public function getConstantStrings(): array + { + return $this->getStaticObjectType()->getConstantStrings(); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if (!$type instanceof static) { + return AcceptsResult::createNo(); + } + + return $this->getStaticObjectType()->accepts($type->getStaticObjectType(), $strictTypes); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return $this->getStaticObjectType()->isSuperTypeOf($type); + } + + if ($type instanceof ObjectWithoutClassType) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof ObjectType) { + $result = $this->getStaticObjectType()->isSuperTypeOf($type); + if ($result->yes()) { + $classReflection = $type->getClassReflection(); + if ($classReflection !== null && $classReflection->isFinal()) { + return $result; + } + } + + return $result->and(IsSuperTypeOfResult::createMaybe()); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + if (get_class($type) !== static::class) { + return false; + } + + return $this->getStaticObjectType()->equals($type->getStaticObjectType()); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('static(%s)', $this->getStaticObjectType()->describe($level)); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->getStaticObjectType()->getTemplateType($ancestorClassName, $templateTypeName); + } + + public function isObject(): TrinaryLogic + { + return $this->getStaticObjectType()->isObject(); + } + + public function isEnum(): TrinaryLogic + { + return $this->getStaticObjectType()->isEnum(); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->getStaticObjectType()->canAccessProperties(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->getStaticObjectType()->hasProperty($propertyName); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $staticObject = $this->getStaticObjectType(); + $nakedProperty = $staticObject->getUnresolvedPropertyPrototype($propertyName, $scope)->getNakedProperty(); + + $ancestor = $this->getAncestorWithClassName($nakedProperty->getDeclaringClass()->getName()); + $classReflection = null; + if ($ancestor !== null) { + $classReflection = $ancestor->getClassReflection(); + } + if ($classReflection === null) { + $classReflection = $nakedProperty->getDeclaringClass(); + } + + return new CallbackUnresolvedPropertyPrototypeReflection( + $nakedProperty, + $classReflection, + false, + fn (Type $type): Type => $this->transformStaticType($type, $scope), + ); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->getStaticObjectType()->canCallMethods(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->getStaticObjectType()->hasMethod($methodName); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $staticObject = $this->getStaticObjectType(); + $nakedMethod = $staticObject->getUnresolvedMethodPrototype($methodName, $scope)->getNakedMethod(); + + $ancestor = $this->getAncestorWithClassName($nakedMethod->getDeclaringClass()->getName()); + $classReflection = null; + if ($ancestor !== null) { + $classReflection = $ancestor->getClassReflection(); + } + if ($classReflection === null) { + $classReflection = $nakedMethod->getDeclaringClass(); + } + + return new CallbackUnresolvedMethodPrototypeReflection( + $nakedMethod, + $classReflection, + false, + fn (Type $type): Type => $this->transformStaticType($type, $scope), + ); + } + + private function transformStaticType(Type $type, ClassMemberAccessAnswerer $scope): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($scope): Type { + if ($type instanceof StaticType) { + $classReflection = $this->classReflection; + $isFinal = false; + if ($scope->isInClass()) { + $classReflection = $scope->getClassReflection(); + $isFinal = $classReflection->isFinal(); + } + $type = $type->changeBaseClass($classReflection); + if (!$isFinal || $type instanceof ThisType) { + return $traverse($type); + } + + return $traverse($type->getStaticObjectType()); + } + + return $traverse($type); + }); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->getStaticObjectType()->canAccessConstants(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->getStaticObjectType()->hasConstant($constantName); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->getStaticObjectType()->getConstant($constantName); + } + + public function changeBaseClass(ClassReflection $classReflection): self + { + return new self($classReflection, $this->subtractedType); + } + + public function isIterable(): TrinaryLogic + { + return $this->getStaticObjectType()->isIterable(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->getStaticObjectType()->isIterableAtLeastOnce(); + } + + public function getArraySize(): Type + { + return $this->getStaticObjectType()->getArraySize(); + } + + public function getIterableKeyType(): Type + { + return $this->getStaticObjectType()->getIterableKeyType(); + } + + public function getFirstIterableKeyType(): Type + { + return $this->getStaticObjectType()->getFirstIterableKeyType(); + } + + public function getLastIterableKeyType(): Type + { + return $this->getStaticObjectType()->getLastIterableKeyType(); + } + + public function getIterableValueType(): Type + { + return $this->getStaticObjectType()->getIterableValueType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->getStaticObjectType()->getFirstIterableValueType(); + } + + public function getLastIterableValueType(): Type + { + return $this->getStaticObjectType()->getLastIterableValueType(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->getStaticObjectType()->isOffsetAccessible(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->getStaticObjectType()->isOffsetAccessLegal(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $this->getStaticObjectType()->hasOffsetValueType($offsetType); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return $this->getStaticObjectType()->getOffsetValueType($offsetType); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this->getStaticObjectType()->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this->getStaticObjectType()->setExistingOffsetValueType($offsetType, $valueType); + } + + public function unsetOffset(Type $offsetType): Type + { + return $this->getStaticObjectType()->unsetOffset($offsetType); + } + + public function getKeysArray(): Type + { + return $this->getStaticObjectType()->getKeysArray(); + } + + public function getValuesArray(): Type + { + return $this->getStaticObjectType()->getValuesArray(); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->getStaticObjectType()->chunkArray($lengthType, $preserveKeys); + } + + public function fillKeysArray(Type $valueType): Type + { + return $this->getStaticObjectType()->fillKeysArray($valueType); + } + + public function flipArray(): Type + { + return $this->getStaticObjectType()->flipArray(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this->getStaticObjectType()->intersectKeyArray($otherArraysType); + } + + public function popArray(): Type + { + return $this->getStaticObjectType()->popArray(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this->getStaticObjectType()->reverseArray($preserveKeys); + } + + public function searchArray(Type $needleType): Type + { + return $this->getStaticObjectType()->searchArray($needleType); + } + + public function shiftArray(): Type + { + return $this->getStaticObjectType()->shiftArray(); + } + + public function shuffleArray(): Type + { + return $this->getStaticObjectType()->shuffleArray(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->getStaticObjectType()->sliceArray($offsetType, $lengthType, $preserveKeys); + } + + public function isCallable(): TrinaryLogic + { + return $this->getStaticObjectType()->isCallable(); + } + + public function getEnumCases(): array + { + return $this->getStaticObjectType()->getEnumCases(); + } + + public function isArray(): TrinaryLogic + { + return $this->getStaticObjectType()->isArray(); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->getStaticObjectType()->isConstantArray(); + } + + public function isOversizedArray(): TrinaryLogic + { + return $this->getStaticObjectType()->isOversizedArray(); + } + + public function isList(): TrinaryLogic + { + return $this->getStaticObjectType()->isList(); + } + + public function isNull(): TrinaryLogic + { + return $this->getStaticObjectType()->isNull(); + } + + public function isConstantValue(): TrinaryLogic + { + return $this->getStaticObjectType()->isConstantValue(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return $this->getStaticObjectType()->isConstantScalarValue(); + } + + public function getConstantScalarTypes(): array + { + return $this->getStaticObjectType()->getConstantScalarTypes(); + } + + public function getConstantScalarValues(): array + { + return $this->getStaticObjectType()->getConstantScalarValues(); + } + + public function isTrue(): TrinaryLogic + { + return $this->getStaticObjectType()->isTrue(); + } + + public function isFalse(): TrinaryLogic + { + return $this->getStaticObjectType()->isFalse(); + } + + public function isBoolean(): TrinaryLogic + { + return $this->getStaticObjectType()->isBoolean(); + } + + public function isFloat(): TrinaryLogic + { + return $this->getStaticObjectType()->isFloat(); + } + + public function isInteger(): TrinaryLogic + { + return $this->getStaticObjectType()->isInteger(); + } + + public function isString(): TrinaryLogic + { + return $this->getStaticObjectType()->isString(); + } + + public function isNumericString(): TrinaryLogic + { + return $this->getStaticObjectType()->isNumericString(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->getStaticObjectType()->isNonEmptyString(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return $this->getStaticObjectType()->isNonFalsyString(); + } + + public function isLiteralString(): TrinaryLogic + { + return $this->getStaticObjectType()->isLiteralString(); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->getStaticObjectType()->isLowercaseString(); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->getStaticObjectType()->isUppercaseString(); + } + + public function isClassString(): TrinaryLogic + { + return $this->getStaticObjectType()->isClassString(); + } + + public function getClassStringObjectType(): Type + { + return $this->getStaticObjectType()->getClassStringObjectType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this; + } + + public function isVoid(): TrinaryLogic + { + return $this->getStaticObjectType()->isVoid(); + } + + public function isScalar(): TrinaryLogic + { + return $this->getStaticObjectType()->isScalar(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return $this->getStaticObjectType()->getCallableParametersAcceptors($scope); + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return $this->getStaticObjectType()->toString(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return $this->getStaticObjectType()->toArray(); + } + + public function toArrayKey(): Type + { + return $this->getStaticObjectType()->toArrayKey(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this->getStaticObjectType()->toCoercedArgumentType($strictTypes); + } + + public function toBoolean(): BooleanType + { + return $this->getStaticObjectType()->toBoolean(); + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->subtractedType !== null ? $cb($this->subtractedType) : null; + + if ($subtractedType !== $this->subtractedType) { + return new self( + $this->classReflection, + $subtractedType, + ); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->subtractedType === null) { + return $this; + } + + return new self($this->classReflection); + } + + public function subtract(Type $type): Type + { + if ($this->subtractedType !== null) { + $type = TypeCombinator::union($this->subtractedType, $type); + } + + return $this->changeSubtractedType($type); + } + + public function getTypeWithoutSubtractedType(): Type + { + return $this->changeSubtractedType(null); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + if ($subtractedType !== null) { + $classReflection = $this->getClassReflection(); + if ($classReflection->getAllowedSubTypes() !== null) { + $objectType = $this->getStaticObjectType()->changeSubtractedType($subtractedType); + if ($objectType instanceof NeverType) { + return $objectType; + } + + if ($objectType instanceof ObjectType && $objectType->getSubtractedType() !== null) { + return new self($classReflection, $objectType->getSubtractedType()); + } + + return TypeCombinator::intersect($this, $objectType); + } + } + + return new self($this->classReflection, $subtractedType); + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($this->getStaticObjectType()->isSuperTypeOf($typeToRemove)->yes()) { + return $this->subtract($typeToRemove); + } + + return null; + } + + public function exponentiate(Type $exponent): Type + { + return $this->getStaticObjectType()->exponentiate($exponent); + } + + public function getFiniteTypes(): array + { + return $this->getStaticObjectType()->getFiniteTypes(); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('static'); + } + +} diff --git a/src/Type/StaticTypeFactory.php b/src/Type/StaticTypeFactory.php new file mode 100644 index 00000000..706580ed --- /dev/null +++ b/src/Type/StaticTypeFactory.php @@ -0,0 +1,45 @@ +handle( + static fn () => 'mixed', + static fn () => 'mixed', + static fn () => 'mixed', + static fn () => 'strict-mixed', + ); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return new ErrorType(); + } + + public function isObject(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isEnum(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function canAccessProperties(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + throw new ShouldNotHappenException(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + throw new ShouldNotHappenException(); + } + + public function canCallMethods(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + throw new ShouldNotHappenException(); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + throw new ShouldNotHappenException(); + } + + public function isIterable(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getIterableKeyType(): Type + { + return $this; + } + + public function getIterableValueType(): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return new ErrorType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return new ErrorType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return new ErrorType(); + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function isCallable(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return []; + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function toBoolean(): BooleanType + { + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ErrorType(); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return TemplateTypeMap::createEmpty(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return []; + } + + public function getEnumCases(): array + { + return []; + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('mixed'); + } + +} diff --git a/src/Type/StringAlwaysAcceptingObjectWithToStringType.php b/src/Type/StringAlwaysAcceptingObjectWithToStringType.php new file mode 100644 index 00000000..74324d38 --- /dev/null +++ b/src/Type/StringAlwaysAcceptingObjectWithToStringType.php @@ -0,0 +1,57 @@ +isSubTypeOf($this); + } + + $thatClassNames = $type->getObjectClassNames(); + if ($thatClassNames === []) { + return parent::isSuperTypeOf($type); + } + + $result = IsSuperTypeOfResult::createNo(); + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + foreach ($thatClassNames as $thatClassName) { + if (!$reflectionProvider->hasClass($thatClassName)) { + return IsSuperTypeOfResult::createNo(); + } + + $typeClass = $reflectionProvider->getClass($thatClassName); + $result = $result->or(IsSuperTypeOfResult::createFromBoolean($typeClass->hasNativeMethod('__toString'))); + } + + return $result; + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + $thatClassNames = $type->getObjectClassNames(); + if ($thatClassNames === []) { + return parent::accepts($type, $strictTypes); + } + + $result = AcceptsResult::createNo(); + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + foreach ($thatClassNames as $thatClassName) { + if (!$reflectionProvider->hasClass($thatClassName)) { + return AcceptsResult::createNo(); + } + + $typeClass = $reflectionProvider->getClass($thatClassName); + $result = $result->or(AcceptsResult::createFromBoolean($typeClass->hasNativeMethod('__toString'))); + } + + return $result; + } + +} diff --git a/src/Type/StringType.php b/src/Type/StringType.php new file mode 100644 index 00000000..a9e75f36 --- /dev/null +++ b/src/Type/StringType.php @@ -0,0 +1,320 @@ +isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + if ($offsetType === null) { + return new ErrorType(); + } + + $valueStringType = $valueType->toString(); + if ($valueStringType instanceof ErrorType) { + return new ErrorType(); + } + + if ($offsetType->isInteger()->yes() || $offsetType instanceof MixedType) { + return new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]); + } + + return new ErrorType(); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof self) { + return AcceptsResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + $thatClassNames = $type->getObjectClassNames(); + if (count($thatClassNames) > 1) { + throw new ShouldNotHappenException(); + } + + if ($thatClassNames === [] || $strictTypes) { + return AcceptsResult::createNo(); + } + + $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); + if (!$reflectionProvider->hasClass($thatClassNames[0])) { + return AcceptsResult::createNo(); + } + + $typeClass = $reflectionProvider->getClass($thatClassNames[0]); + return AcceptsResult::createFromBoolean( + $typeClass->hasNativeMethod('__toString'), + ); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + [], + TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + return $this; + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ObjectWithoutClassType(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isArray()->yes()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + if ($this->isClassString()->yes()) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createNo(); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + if ($typeToRemove instanceof ConstantStringType && $typeToRemove->getValue() === '') { + return TypeCombinator::intersect($this, new AccessoryNonEmptyStringType()); + } + + if ($typeToRemove instanceof AccessoryNonEmptyStringType) { + return new ConstantStringType(''); + } + + return null; + } + + public function getFiniteTypes(): array + { + return []; + } + + public function exponentiate(Type $exponent): Type + { + return ExponentiateHelper::exponentiate($this, $exponent); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('string'); + } + +} diff --git a/src/Type/SubtractableType.php b/src/Type/SubtractableType.php new file mode 100644 index 00000000..0ce6f83b --- /dev/null +++ b/src/Type/SubtractableType.php @@ -0,0 +1,17 @@ +getSubtractedType()); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('$this(%s)', $this->getStaticObjectType()->describe($level)); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return $this->getStaticObjectType()->isSuperTypeOf($type); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + $parent = new parent($this->getClassReflection(), $this->getSubtractedType()); + + return $parent->isSuperTypeOf($type)->and(IsSuperTypeOfResult::createMaybe()); + } + + public function changeSubtractedType(?Type $subtractedType): Type + { + $type = parent::changeSubtractedType($subtractedType); + if ($type instanceof parent) { + return new self($type->getClassReflection(), $subtractedType); + } + + return $type; + } + + public function traverse(callable $cb): Type + { + $subtractedType = $this->getSubtractedType() !== null ? $cb($this->getSubtractedType()) : null; + + if ($subtractedType !== $this->getSubtractedType()) { + return new self( + $this->getClassReflection(), + $subtractedType, + ); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->getSubtractedType() === null) { + return $this; + } + + return new self($this->getClassReflection()); + } + + public function toPhpDocNode(): TypeNode + { + return new ThisTypeNode(); + } + +} diff --git a/src/Type/Traits/ArrayTypeTrait.php b/src/Type/Traits/ArrayTypeTrait.php new file mode 100644 index 00000000..98bae5aa --- /dev/null +++ b/src/Type/Traits/ArrayTypeTrait.php @@ -0,0 +1,212 @@ +yes() + ? $this + : TypeCombinator::intersect(new ArrayType(new IntegerType(), $this->getIterableValueType()), new AccessoryArrayListType()); + $chunkType = TypeCombinator::intersect($chunkType, new NonEmptyArrayType()); + + $arrayType = TypeCombinator::intersect(new ArrayType(new IntegerType(), $chunkType), new AccessoryArrayListType()); + + return $this->isIterableAtLeastOnce()->yes() + ? TypeCombinator::intersect($arrayType, new NonEmptyArrayType()) + : $arrayType; + } + +} diff --git a/src/Type/Traits/ConstantNumericComparisonTypeTrait.php b/src/Type/Traits/ConstantNumericComparisonTypeTrait.php new file mode 100644 index 00000000..6dd3652d --- /dev/null +++ b/src/Type/Traits/ConstantNumericComparisonTypeTrait.php @@ -0,0 +1,79 @@ +value), + ]; + + if (!(bool) $this->value) { + $subtractedTypes[] = new NullType(); + $subtractedTypes[] = new ConstantBooleanType(false); + $subtractedTypes[] = new ConstantFloatType(0.0); // subtract range when we support float-ranges + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + IntegerRangeType::createAllGreaterThan($this->value), + // subtract range when we support float-ranges + ]; + + if (!(bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(true); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + new NullType(), + new ConstantBooleanType(false), + new ConstantFloatType(0.0), // subtract range when we support float-ranges + IntegerRangeType::createAllSmallerThanOrEqualTo($this->value), + ]; + + if ((bool) $this->value) { + $subtractedTypes[] = new ConstantBooleanType(true); + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + $subtractedTypes = [ + IntegerRangeType::createAllSmallerThan($this->value), + ]; + + if ((bool) $this->value) { + $subtractedTypes[] = new NullType(); + $subtractedTypes[] = new ConstantBooleanType(false); + $subtractedTypes[] = new ConstantFloatType(0.0); // subtract range when we support float-ranges + } + + return TypeCombinator::remove(new MixedType(), TypeCombinator::union(...$subtractedTypes)); + } + +} diff --git a/src/Type/Traits/ConstantScalarTypeTrait.php b/src/Type/Traits/ConstantScalarTypeTrait.php new file mode 100644 index 00000000..ec6f201f --- /dev/null +++ b/src/Type/Traits/ConstantScalarTypeTrait.php @@ -0,0 +1,129 @@ +equals($type)); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return parent::accepts($type, $strictTypes)->and(AcceptsResult::createMaybe()); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createFromBoolean($this->equals($type)); + } + + if ($type instanceof parent) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if (!$this instanceof ConstantScalarType) { + throw new ShouldNotHappenException(); + } + + if ($type instanceof ConstantScalarType) { + return LooseComparisonHelper::compareConstantScalars($this, $type, $phpVersion); + } + + if ($type->isConstantArray()->yes() && $type->isIterableAtLeastOnce()->no()) { + // @phpstan-ignore equal.notAllowed, equal.invalid, equal.alwaysFalse + return new ConstantBooleanType($this->getValue() == []); // phpcs:ignore + } + + if ($type instanceof CompoundType) { + return $type->looseCompare($this, $phpVersion); + } + + return parent::looseCompare($type, $phpVersion); + } + + public function equals(Type $type): bool + { + return $type instanceof self && $this->value === $type->value; + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($otherType instanceof ConstantScalarType) { + return TrinaryLogic::createFromBoolean($this->value < $otherType->getValue()); + } + + if ($otherType instanceof CompoundType) { + return $otherType->isGreaterThan($this, $phpVersion); + } + + return TrinaryLogic::createMaybe(); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + if ($otherType instanceof ConstantScalarType) { + return TrinaryLogic::createFromBoolean($this->value <= $otherType->getValue()); + } + + if ($otherType instanceof CompoundType) { + return $otherType->isGreaterThanOrEqual($this, $phpVersion); + } + + return TrinaryLogic::createMaybe(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function getConstantScalarTypes(): array + { + return [$this]; + } + + public function getConstantScalarValues(): array + { + return [$this->getValue()]; + } + + public function getFiniteTypes(): array + { + return [$this]; + } + +} diff --git a/src/Type/Traits/FalseyBooleanTypeTrait.php b/src/Type/Traits/FalseyBooleanTypeTrait.php new file mode 100644 index 00000000..7ccf9d05 --- /dev/null +++ b/src/Type/Traits/FalseyBooleanTypeTrait.php @@ -0,0 +1,17 @@ +resolve()->getObjectClassNames(); + } + + public function getObjectClassReflections(): array + { + return $this->resolve()->getObjectClassReflections(); + } + + public function getArrays(): array + { + return $this->resolve()->getArrays(); + } + + public function getConstantArrays(): array + { + return $this->resolve()->getConstantArrays(); + } + + public function getConstantStrings(): array + { + return $this->resolve()->getConstantStrings(); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return $this->resolve()->accepts($type, $strictTypes); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + return $this->isSuperTypeOfDefault($type); + } + + private function isSuperTypeOfDefault(Type $type): IsSuperTypeOfResult + { + if ($type instanceof NeverType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof LateResolvableType) { + $type = $type->resolve(); + } + + $isSuperType = $this->resolve()->isSuperTypeOf($type); + + if (!$this->isResolvable()) { + $isSuperType = $isSuperType->and(IsSuperTypeOfResult::createMaybe()); + } + + return $isSuperType; + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->resolve()->getTemplateType($ancestorClassName, $templateTypeName); + } + + public function isObject(): TrinaryLogic + { + return $this->resolve()->isObject(); + } + + public function isEnum(): TrinaryLogic + { + return $this->resolve()->isEnum(); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->resolve()->canAccessProperties(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->resolve()->hasProperty($propertyName); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->resolve()->getProperty($propertyName, $scope); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + return $this->resolve()->getUnresolvedPropertyPrototype($propertyName, $scope); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->resolve()->canCallMethods(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->resolve()->hasMethod($methodName); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->resolve()->getMethod($methodName, $scope); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + return $this->resolve()->getUnresolvedMethodPrototype($methodName, $scope); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->resolve()->canAccessConstants(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->resolve()->hasConstant($constantName); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->resolve()->getConstant($constantName); + } + + public function isIterable(): TrinaryLogic + { + return $this->resolve()->isIterable(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->resolve()->isIterableAtLeastOnce(); + } + + public function getArraySize(): Type + { + return $this->resolve()->getArraySize(); + } + + public function getIterableKeyType(): Type + { + return $this->resolve()->getIterableKeyType(); + } + + public function getFirstIterableKeyType(): Type + { + return $this->resolve()->getFirstIterableKeyType(); + } + + public function getLastIterableKeyType(): Type + { + return $this->resolve()->getLastIterableKeyType(); + } + + public function getIterableValueType(): Type + { + return $this->resolve()->getIterableValueType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->resolve()->getFirstIterableValueType(); + } + + public function getLastIterableValueType(): Type + { + return $this->resolve()->getLastIterableValueType(); + } + + public function isArray(): TrinaryLogic + { + return $this->resolve()->isArray(); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->resolve()->isConstantArray(); + } + + public function isOversizedArray(): TrinaryLogic + { + return $this->resolve()->isOversizedArray(); + } + + public function isList(): TrinaryLogic + { + return $this->resolve()->isList(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->resolve()->isOffsetAccessible(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->resolve()->isOffsetAccessLegal(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $this->resolve()->hasOffsetValueType($offsetType); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return $this->resolve()->getOffsetValueType($offsetType); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this->resolve()->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this->resolve()->setExistingOffsetValueType($offsetType, $valueType); + } + + public function unsetOffset(Type $offsetType): Type + { + return $this->resolve()->unsetOffset($offsetType); + } + + public function getKeysArray(): Type + { + return $this->resolve()->getKeysArray(); + } + + public function getValuesArray(): Type + { + return $this->resolve()->getValuesArray(); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->resolve()->chunkArray($lengthType, $preserveKeys); + } + + public function fillKeysArray(Type $valueType): Type + { + return $this->resolve()->fillKeysArray($valueType); + } + + public function flipArray(): Type + { + return $this->resolve()->flipArray(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this->resolve()->intersectKeyArray($otherArraysType); + } + + public function popArray(): Type + { + return $this->resolve()->popArray(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this->resolve()->reverseArray($preserveKeys); + } + + public function searchArray(Type $needleType): Type + { + return $this->resolve()->searchArray($needleType); + } + + public function shiftArray(): Type + { + return $this->resolve()->shiftArray(); + } + + public function shuffleArray(): Type + { + return $this->resolve()->shuffleArray(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->resolve()->sliceArray($offsetType, $lengthType, $preserveKeys); + } + + public function isCallable(): TrinaryLogic + { + return $this->resolve()->isCallable(); + } + + public function getEnumCases(): array + { + return $this->resolve()->getEnumCases(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return $this->resolve()->getCallableParametersAcceptors($scope); + } + + public function isCloneable(): TrinaryLogic + { + return $this->resolve()->isCloneable(); + } + + public function toBoolean(): BooleanType + { + return $this->resolve()->toBoolean(); + } + + public function toNumber(): Type + { + return $this->resolve()->toNumber(); + } + + public function toAbsoluteNumber(): Type + { + return $this->resolve()->toAbsoluteNumber(); + } + + public function toInteger(): Type + { + return $this->resolve()->toInteger(); + } + + public function toFloat(): Type + { + return $this->resolve()->toFloat(); + } + + public function toString(): Type + { + return $this->resolve()->toString(); + } + + public function toArray(): Type + { + return $this->resolve()->toArray(); + } + + public function toArrayKey(): Type + { + return $this->resolve()->toArrayKey(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this->resolve()->toCoercedArgumentType($strictTypes); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->resolve()->isSmallerThan($otherType, $phpVersion); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->resolve()->isSmallerThanOrEqual($otherType, $phpVersion); + } + + public function isNull(): TrinaryLogic + { + return $this->resolve()->isNull(); + } + + public function isConstantValue(): TrinaryLogic + { + return $this->resolve()->isConstantValue(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return $this->resolve()->isConstantScalarValue(); + } + + public function getConstantScalarTypes(): array + { + return $this->resolve()->getConstantScalarTypes(); + } + + public function getConstantScalarValues(): array + { + return $this->resolve()->getConstantScalarValues(); + } + + public function isTrue(): TrinaryLogic + { + return $this->resolve()->isTrue(); + } + + public function isFalse(): TrinaryLogic + { + return $this->resolve()->isFalse(); + } + + public function isBoolean(): TrinaryLogic + { + return $this->resolve()->isBoolean(); + } + + public function isFloat(): TrinaryLogic + { + return $this->resolve()->isFloat(); + } + + public function isInteger(): TrinaryLogic + { + return $this->resolve()->isInteger(); + } + + public function isString(): TrinaryLogic + { + return $this->resolve()->isString(); + } + + public function isNumericString(): TrinaryLogic + { + return $this->resolve()->isNumericString(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->resolve()->isNonEmptyString(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return $this->resolve()->isNonFalsyString(); + } + + public function isLiteralString(): TrinaryLogic + { + return $this->resolve()->isLiteralString(); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->resolve()->isLowercaseString(); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->resolve()->isUppercaseString(); + } + + public function isClassString(): TrinaryLogic + { + return $this->resolve()->isClassString(); + } + + public function getClassStringObjectType(): Type + { + return $this->resolve()->getClassStringObjectType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->resolve()->getObjectTypeOrClassStringObjectType(); + } + + public function isVoid(): TrinaryLogic + { + return $this->resolve()->isVoid(); + } + + public function isScalar(): TrinaryLogic + { + return $this->resolve()->isScalar(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + return $this->resolve()->getSmallerType($phpVersion); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + return $this->resolve()->getSmallerOrEqualType($phpVersion); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + return $this->resolve()->getGreaterType($phpVersion); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + return $this->resolve()->getGreaterOrEqualType($phpVersion); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return $this->resolve()->inferTemplateTypes($receivedType); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + return $this->resolve()->tryRemove($typeToRemove); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + $result = $this->resolve(); + + if ($result instanceof CompoundType) { + return $result->isSubTypeOf($otherType); + } + + return $otherType->isSuperTypeOf($result); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + $result = $this->resolve(); + + if ($result instanceof CompoundType) { + return $result->isAcceptedBy($acceptingType, $strictTypes); + } + + return $acceptingType->accepts($result, $strictTypes); + } + + public function isGreaterThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + $result = $this->resolve(); + + if ($result instanceof CompoundType) { + return $result->isGreaterThan($otherType, $phpVersion); + } + + return $otherType->isSmallerThan($result, $phpVersion); + } + + public function isGreaterThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + $result = $this->resolve(); + + if ($result instanceof CompoundType) { + return $result->isGreaterThanOrEqual($otherType, $phpVersion); + } + + return $otherType->isSmallerThanOrEqual($result, $phpVersion); + } + + public function exponentiate(Type $exponent): Type + { + return $this->resolve()->exponentiate($exponent); + } + + public function getFiniteTypes(): array + { + return $this->resolve()->getFiniteTypes(); + } + + public function resolve(): Type + { + if ($this->result === null) { + return $this->result = $this->getResult(); + } + + return $this->result; + } + + abstract protected function getResult(): Type; + +} diff --git a/src/Type/Traits/MaybeArrayTypeTrait.php b/src/Type/Traits/MaybeArrayTypeTrait.php new file mode 100644 index 00000000..e60ec959 --- /dev/null +++ b/src/Type/Traits/MaybeArrayTypeTrait.php @@ -0,0 +1,103 @@ +isIterable()->no()) { + return new ErrorType(); + } + + if ($this->isIterableAtLeastOnce()->yes()) { + return IntegerRangeType::fromInterval(1, null); + } + + return IntegerRangeType::fromInterval(0, null); + } + + public function getIterableKeyType(): Type + { + return new MixedType(); + } + + public function getFirstIterableKeyType(): Type + { + return new MixedType(); + } + + public function getLastIterableKeyType(): Type + { + return new MixedType(); + } + + public function getIterableValueType(): Type + { + return new MixedType(); + } + + public function getFirstIterableValueType(): Type + { + return new MixedType(); + } + + public function getLastIterableValueType(): Type + { + return new MixedType(); + } + +} diff --git a/src/Type/Traits/MaybeObjectTypeTrait.php b/src/Type/Traits/MaybeObjectTypeTrait.php new file mode 100644 index 00000000..6cebecc4 --- /dev/null +++ b/src/Type/Traits/MaybeObjectTypeTrait.php @@ -0,0 +1,111 @@ +getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $property = new DummyPropertyReflection(); + return new CallbackUnresolvedPropertyPrototypeReflection( + $property, + $property->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canCallMethods(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $method = new DummyMethodReflection($methodName); + return new CallbackUnresolvedMethodPrototypeReflection( + $method, + $method->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return new DummyClassConstantReflection($constantName); + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + +} diff --git a/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php b/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php new file mode 100644 index 00000000..4ae6e292 --- /dev/null +++ b/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php @@ -0,0 +1,43 @@ +traverse(static fn (Type $type) => $type->generalize($precision)); + } + +} diff --git a/src/Type/Traits/NonGenericTypeTrait.php b/src/Type/Traits/NonGenericTypeTrait.php new file mode 100644 index 00000000..0281aff8 --- /dev/null +++ b/src/Type/Traits/NonGenericTypeTrait.php @@ -0,0 +1,23 @@ +getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $property = new DummyPropertyReflection(); + return new CallbackUnresolvedPropertyPrototypeReflection( + $property, + $property->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canCallMethods(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $method = new DummyMethodReflection($methodName); + return new CallbackUnresolvedMethodPrototypeReflection( + $method, + $method->getDeclaringClass(), + false, + static fn (Type $type): Type => $type, + ); + } + + public function canAccessConstants(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return new DummyClassConstantReflection($constantName); + } + + public function getConstantStrings(): array + { + return []; + } + + public function isCloneable(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this; + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ArrayType(new MixedType(), new MixedType()); + } + + public function toArrayKey(): Type + { + return new StringType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this; + } + +} diff --git a/src/Type/Traits/TruthyBooleanTypeTrait.php b/src/Type/Traits/TruthyBooleanTypeTrait.php new file mode 100644 index 00000000..c0ea57ac --- /dev/null +++ b/src/Type/Traits/TruthyBooleanTypeTrait.php @@ -0,0 +1,17 @@ + + */ + public function getReferencedClasses(): array; + + /** @return list */ + public function getObjectClassNames(): array; + + /** + * @return list + */ + public function getObjectClassReflections(): array; + + /** + * Returns object type Foo for class-string and 'Foo' (if Foo is a valid class). + */ + public function getClassStringObjectType(): Type; + + /** + * Returns object type Foo for class-string, 'Foo' (if Foo is a valid class), + * and object type Foo. + */ + public function getObjectTypeOrClassStringObjectType(): Type; + + public function isObject(): TrinaryLogic; + + public function isEnum(): TrinaryLogic; + + /** @return list */ + public function getArrays(): array; + + /** @return list */ + public function getConstantArrays(): array; + + /** @return list */ + public function getConstantStrings(): array; + + public function accepts(Type $type, bool $strictTypes): AcceptsResult; + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult; + + public function equals(Type $type): bool; + + public function describe(VerbosityLevel $level): string; + + public function canAccessProperties(): TrinaryLogic; + + public function hasProperty(string $propertyName): TrinaryLogic; + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection; + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection; + + public function canCallMethods(): TrinaryLogic; + + public function hasMethod(string $methodName): TrinaryLogic; + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection; + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection; + + public function canAccessConstants(): TrinaryLogic; + + public function hasConstant(string $constantName): TrinaryLogic; + + public function getConstant(string $constantName): ClassConstantReflection; + + public function isIterable(): TrinaryLogic; + + public function isIterableAtLeastOnce(): TrinaryLogic; + + public function getArraySize(): Type; + + public function getIterableKeyType(): Type; + + public function getFirstIterableKeyType(): Type; + + public function getLastIterableKeyType(): Type; + + public function getIterableValueType(): Type; + + public function getFirstIterableValueType(): Type; + + public function getLastIterableValueType(): Type; + + public function isArray(): TrinaryLogic; + + public function isConstantArray(): TrinaryLogic; + + public function isOversizedArray(): TrinaryLogic; + + public function isList(): TrinaryLogic; + + public function isOffsetAccessible(): TrinaryLogic; + + public function isOffsetAccessLegal(): TrinaryLogic; + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic; + + public function getOffsetValueType(Type $offsetType): Type; + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type; + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type; + + public function unsetOffset(Type $offsetType): Type; + + public function getKeysArray(): Type; + + public function getValuesArray(): Type; + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type; + + public function fillKeysArray(Type $valueType): Type; + + public function flipArray(): Type; + + public function intersectKeyArray(Type $otherArraysType): Type; + + public function popArray(): Type; + + public function reverseArray(TrinaryLogic $preserveKeys): Type; + + public function searchArray(Type $needleType): Type; + + public function shiftArray(): Type; + + public function shuffleArray(): Type; + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type; + + /** + * @return list + */ + public function getEnumCases(): array; + + /** + * Returns a list of finite values. + * + * Examples: + * + * - for bool: [true, false] + * - for int<0, 3>: [0, 1, 2, 3] + * - for enums: list of enum cases + * - for scalars: the scalar itself + * + * For infinite types it returns an empty array. + * + * @return list + */ + public function getFiniteTypes(): array; + + public function exponentiate(Type $exponent): Type; + + public function isCallable(): TrinaryLogic; + + /** + * @return CallableParametersAcceptor[] + */ + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array; + + public function isCloneable(): TrinaryLogic; + + public function toBoolean(): BooleanType; + + public function toNumber(): Type; + + public function toInteger(): Type; + + public function toFloat(): Type; + + public function toString(): Type; + + public function toArray(): Type; + + public function toArrayKey(): Type; + + /** + * Tells how a type might change when passed to an argument + * or assigned to a typed property. + * + * Example: int is accepted by int|float with strict_types = 1 + * Stringable is accepted by string|Stringable even without strict_types. + * + * Note: Logic with $strictTypes=false is mostly not implemented in Type subclasses. + */ + public function toCoercedArgumentType(bool $strictTypes): self; + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic; + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic; + + /** + * Is Type of a known constant value? Includes literal strings, integers, floats, true, false, null, and array shapes. + */ + public function isConstantValue(): TrinaryLogic; + + /** + * Is Type of a known constant scalar value? Includes literal strings, integers, floats, true, false, and null. + */ + public function isConstantScalarValue(): TrinaryLogic; + + /** + * @return list + */ + public function getConstantScalarTypes(): array; + + /** + * @return list + */ + public function getConstantScalarValues(): array; + + public function isNull(): TrinaryLogic; + + public function isTrue(): TrinaryLogic; + + public function isFalse(): TrinaryLogic; + + public function isBoolean(): TrinaryLogic; + + public function isFloat(): TrinaryLogic; + + public function isInteger(): TrinaryLogic; + + public function isString(): TrinaryLogic; + + public function isNumericString(): TrinaryLogic; + + public function isNonEmptyString(): TrinaryLogic; + + public function isNonFalsyString(): TrinaryLogic; + + public function isLiteralString(): TrinaryLogic; + + public function isLowercaseString(): TrinaryLogic; + + public function isUppercaseString(): TrinaryLogic; + + public function isClassString(): TrinaryLogic; + + public function isVoid(): TrinaryLogic; + + public function isScalar(): TrinaryLogic; + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType; + + public function getSmallerType(PhpVersion $phpVersion): Type; + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type; + + public function getGreaterType(PhpVersion $phpVersion): Type; + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type; + + /** + * Returns actual template type for a given object. + * + * Example: + * + * @-template T + * class Foo {} + * + * // $fooType is Foo + * $t = $fooType->getTemplateType(Foo::class, 'T'); + * $t->isInteger(); // yes + * + * Returns ErrorType in case of a missing type. + * + * @param class-string $ancestorClassName + */ + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type; + + /** + * Infers template types + * + * Infers the real Type of the TemplateTypes found in $this, based on + * the received Type. + */ + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap; + + /** + * Returns the template types referenced by this Type, recursively + * + * The return value is a list of TemplateTypeReferences, who contain the + * referenced template type as well as the variance position in which it was + * found. + * + * For example, calling this on array,Bar> (with T a template type) + * will return one TemplateTypeReference for the type T. + * + * @param TemplateTypeVariance $positionVariance The variance position in + * which the receiver type was + * found. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array; + + public function toAbsoluteNumber(): Type; + + /** + * Traverses inner types + * + * Returns a new instance with all inner types mapped through $cb. Might + * return the same instance if inner types did not change. + * + * @param callable(Type):Type $cb + */ + public function traverse(callable $cb): Type; + + /** + * Traverses inner types while keeping the same context in another type. + * + * @param callable(Type $left, Type $right): Type $cb + */ + public function traverseSimultaneously(Type $right, callable $cb): Type; + + public function toPhpDocNode(): TypeNode; + + /** + * Return the difference with another type, or null if it cannot be represented. + * + * @see TypeCombinator::remove() + */ + public function tryRemove(Type $typeToRemove): ?Type; + + public function generalize(GeneralizePrecision $precision): Type; + +} diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php new file mode 100644 index 00000000..99248001 --- /dev/null +++ b/src/Type/TypeAlias.php @@ -0,0 +1,42 @@ +resolvedType = new CircularTypeAliasErrorType(); + return $self; + } + + public function resolve(TypeNodeResolver $typeNodeResolver): Type + { + if ($this->resolvedType === null) { + $this->resolvedType = $typeNodeResolver->resolve( + $this->typeNode, + $this->nameScope, + ); + } + + return $this->resolvedType; + } + +} diff --git a/src/Type/TypeAliasResolver.php b/src/Type/TypeAliasResolver.php new file mode 100644 index 00000000..03774266 --- /dev/null +++ b/src/Type/TypeAliasResolver.php @@ -0,0 +1,15 @@ +isSuperTypeOf($type)->no()) { + return self::union($type, $nullType); + } + + return $type; + } + + public static function remove(Type $fromType, Type $typeToRemove): Type + { + if ($typeToRemove instanceof UnionType) { + foreach ($typeToRemove->getTypes() as $unionTypeToRemove) { + $fromType = self::remove($fromType, $unionTypeToRemove); + } + return $fromType; + } + + $isSuperType = $typeToRemove->isSuperTypeOf($fromType); + if ($isSuperType->yes()) { + return new NeverType(); + } + if ($isSuperType->no()) { + return $fromType; + } + + if ($typeToRemove instanceof MixedType) { + $typeToRemoveSubtractedType = $typeToRemove->getSubtractedType(); + if ($typeToRemoveSubtractedType !== null) { + return self::intersect($fromType, $typeToRemoveSubtractedType); + } + } + + $removed = $fromType->tryRemove($typeToRemove); + if ($removed !== null) { + return $removed; + } + + $fromFiniteTypes = $fromType->getFiniteTypes(); + if (count($fromFiniteTypes) > 0) { + $finiteTypesToRemove = $typeToRemove->getFiniteTypes(); + if (count($finiteTypesToRemove) === 1) { + $result = []; + foreach ($fromFiniteTypes as $finiteType) { + if ($finiteType->equals($finiteTypesToRemove[0])) { + continue; + } + + $result[] = $finiteType; + } + + if (count($result) === count($fromFiniteTypes)) { + return $fromType; + } + + if (count($result) === 0) { + return new NeverType(); + } + + if (count($result) === 1) { + return $result[0]; + } + + return new UnionType($result); + } + } + + return $fromType; + } + + public static function removeNull(Type $type): Type + { + if (self::containsNull($type)) { + return self::remove($type, new NullType()); + } + + return $type; + } + + public static function containsNull(Type $type): bool + { + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof NullType) { + return true; + } + } + + return false; + } + + return $type instanceof NullType; + } + + public static function union(Type ...$types): Type + { + $typesCount = count($types); + if ($typesCount === 0) { + return new NeverType(); + } + + $benevolentTypes = []; + $benevolentUnionObject = null; + // transform A | (B | C) to A | B | C + for ($i = 0; $i < $typesCount; $i++) { + if ($types[$i] instanceof BenevolentUnionType) { + if ($types[$i] instanceof TemplateBenevolentUnionType && $benevolentUnionObject === null) { + $benevolentUnionObject = $types[$i]; + } + $benevolentTypesCount = 0; + $typesInner = $types[$i]->getTypes(); + foreach ($typesInner as $benevolentInnerType) { + $benevolentTypesCount++; + $benevolentTypes[$benevolentInnerType->describe(VerbosityLevel::value())] = $benevolentInnerType; + } + array_splice($types, $i, 1, $typesInner); + $typesCount += $benevolentTypesCount - 1; + continue; + } + if (!($types[$i] instanceof UnionType)) { + continue; + } + if ($types[$i] instanceof TemplateType) { + continue; + } + + $typesInner = $types[$i]->getTypes(); + array_splice($types, $i, 1, $typesInner); + $typesCount += count($typesInner) - 1; + } + + if ($typesCount === 1) { + return $types[0]; + } + + $arrayTypes = []; + $scalarTypes = []; + $hasGenericScalarTypes = []; + $enumCaseTypes = []; + for ($i = 0; $i < $typesCount; $i++) { + if ($types[$i] instanceof ConstantScalarType) { + $type = $types[$i]; + $scalarTypes[get_class($type)][md5($type->describe(VerbosityLevel::cache()))] = $type; + unset($types[$i]); + continue; + } + if ($types[$i] instanceof BooleanType) { + $hasGenericScalarTypes[ConstantBooleanType::class] = true; + } + if ($types[$i] instanceof FloatType) { + $hasGenericScalarTypes[ConstantFloatType::class] = true; + } + if ($types[$i] instanceof IntegerType && !$types[$i] instanceof IntegerRangeType) { + $hasGenericScalarTypes[ConstantIntegerType::class] = true; + } + if ($types[$i] instanceof StringType && !$types[$i] instanceof ClassStringType) { + $hasGenericScalarTypes[ConstantStringType::class] = true; + } + $enumCases = $types[$i]->getEnumCases(); + if (count($enumCases) === 1) { + $enumCaseTypes[$types[$i]->describe(VerbosityLevel::cache())] = $types[$i]; + + unset($types[$i]); + continue; + } + + if (!$types[$i]->isArray()->yes()) { + continue; + } + + $arrayTypes[] = $types[$i]; + unset($types[$i]); + } + + foreach ($scalarTypes as $classType => $scalarTypeItems) { + $scalarTypes[$classType] = array_values($scalarTypeItems); + } + + $enumCaseTypes = array_values($enumCaseTypes); + $types = array_values($types); + $typesCount = count($types); + + foreach ($scalarTypes as $classType => $scalarTypeItems) { + if (isset($hasGenericScalarTypes[$classType])) { + unset($scalarTypes[$classType]); + continue; + } + if ($classType === ConstantBooleanType::class && count($scalarTypeItems) === 2) { + $types[] = new BooleanType(); + $typesCount++; + unset($scalarTypes[$classType]); + continue; + } + + $scalarTypeItemsCount = count($scalarTypeItems); + for ($i = 0; $i < $typesCount; $i++) { + for ($j = 0; $j < $scalarTypeItemsCount; $j++) { + $compareResult = self::compareTypesInUnion($types[$i], $scalarTypeItems[$j]); + if ($compareResult === null) { + continue; + } + + [$a, $b] = $compareResult; + if ($a !== null) { + $types[$i] = $a; + array_splice($scalarTypeItems, $j--, 1); + $scalarTypeItemsCount--; + continue 1; + } + if ($b !== null) { + $scalarTypeItems[$j] = $b; + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + } + + $scalarTypes[$classType] = $scalarTypeItems; + } + + if (count($types) > 16) { + $newTypes = []; + foreach ($types as $type) { + $newTypes[$type->describe(VerbosityLevel::cache())] = $type; + } + $types = array_values($newTypes); + } + + $types = array_merge( + $types, + self::processArrayTypes($arrayTypes), + ); + $typesCount = count($types); + + // transform A | A to A + // transform A | never to A + for ($i = 0; $i < $typesCount; $i++) { + for ($j = $i + 1; $j < $typesCount; $j++) { + $compareResult = self::compareTypesInUnion($types[$i], $types[$j]); + if ($compareResult === null) { + continue; + } + + [$a, $b] = $compareResult; + if ($a !== null) { + $types[$i] = $a; + array_splice($types, $j--, 1); + $typesCount--; + continue 1; + } + if ($b !== null) { + $types[$j] = $b; + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + } + + $enumCasesCount = count($enumCaseTypes); + for ($i = 0; $i < $typesCount; $i++) { + for ($j = 0; $j < $enumCasesCount; $j++) { + $compareResult = self::compareTypesInUnion($types[$i], $enumCaseTypes[$j]); + if ($compareResult === null) { + continue; + } + + [$a, $b] = $compareResult; + if ($a !== null) { + $types[$i] = $a; + array_splice($enumCaseTypes, $j--, 1); + $enumCasesCount--; + continue 1; + } + if ($b !== null) { + $enumCaseTypes[$j] = $b; + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + } + + foreach ($enumCaseTypes as $enumCaseType) { + $types[] = $enumCaseType; + $typesCount++; + } + + foreach ($scalarTypes as $scalarTypeItems) { + foreach ($scalarTypeItems as $scalarType) { + $types[] = $scalarType; + $typesCount++; + } + } + + if ($typesCount === 0) { + return new NeverType(); + } + if ($typesCount === 1) { + return $types[0]; + } + + if ($benevolentTypes !== []) { + $tempTypes = $types; + foreach ($tempTypes as $i => $type) { + if (!isset($benevolentTypes[$type->describe(VerbosityLevel::value())])) { + break; + } + + unset($tempTypes[$i]); + } + + if ($tempTypes === []) { + if ($benevolentUnionObject instanceof TemplateBenevolentUnionType) { + return $benevolentUnionObject->withTypes($types); + } + + return new BenevolentUnionType($types, true); + } + } + + return new UnionType($types, true); + } + + /** + * @return array{Type, null}|array{null, Type}|null + */ + private static function compareTypesInUnion(Type $a, Type $b): ?array + { + if ($a instanceof IntegerRangeType) { + $type = $a->tryUnion($b); + if ($type !== null) { + $a = $type; + return [$a, null]; + } + } + if ($b instanceof IntegerRangeType) { + $type = $b->tryUnion($a); + if ($type !== null) { + $b = $type; + return [null, $b]; + } + } + if ($a instanceof IntegerRangeType && $b instanceof IntegerRangeType) { + return null; + } + if ($a instanceof HasOffsetValueType && $b instanceof HasOffsetValueType) { + if ($a->getOffsetType()->equals($b->getOffsetType())) { + return [new HasOffsetValueType($a->getOffsetType(), self::union($a->getValueType(), $b->getValueType())), null]; + } + } + if ($a->isConstantArray()->yes() && $b->isConstantArray()->yes()) { + return null; + } + + // simplify string[] | int[] to (string|int)[] + if ($a instanceof IterableType && $b instanceof IterableType) { + return [ + new IterableType( + self::union($a->getIterableKeyType(), $b->getIterableKeyType()), + self::union($a->getIterableValueType(), $b->getIterableValueType()), + ), + null, + ]; + } + + if ($a instanceof SubtractableType) { + $typeWithoutSubtractedTypeA = $a->getTypeWithoutSubtractedType(); + if ($typeWithoutSubtractedTypeA instanceof MixedType && $b instanceof MixedType) { + $isSuperType = $typeWithoutSubtractedTypeA->isSuperTypeOfMixed($b); + } else { + $isSuperType = $typeWithoutSubtractedTypeA->isSuperTypeOf($b); + } + if ($isSuperType->yes()) { + $a = self::intersectWithSubtractedType($a, $b); + return [$a, null]; + } + } + + if ($b instanceof SubtractableType) { + $typeWithoutSubtractedTypeB = $b->getTypeWithoutSubtractedType(); + if ($typeWithoutSubtractedTypeB instanceof MixedType && $a instanceof MixedType) { + $isSuperType = $typeWithoutSubtractedTypeB->isSuperTypeOfMixed($a); + } else { + $isSuperType = $typeWithoutSubtractedTypeB->isSuperTypeOf($a); + } + if ($isSuperType->yes()) { + $b = self::intersectWithSubtractedType($b, $a); + return [null, $b]; + } + } + + if ($b->isSuperTypeOf($a)->yes()) { + return [null, $b]; + } + + if ($a->isSuperTypeOf($b)->yes()) { + return [$a, null]; + } + + if ( + $a instanceof ConstantStringType + && $a->getValue() === '' + && ($b->describe(VerbosityLevel::value()) === 'non-empty-string' + || $b->describe(VerbosityLevel::value()) === 'non-falsy-string') + ) { + return [null, self::intersect( + new StringType(), + ...self::getAccessoryCaseStringTypes($b), + )]; + } + + if ( + $b instanceof ConstantStringType + && $b->getValue() === '' + && ($a->describe(VerbosityLevel::value()) === 'non-empty-string' + || $a->describe(VerbosityLevel::value()) === 'non-falsy-string') + ) { + return [self::intersect( + new StringType(), + ...self::getAccessoryCaseStringTypes($a), + ), null]; + } + + if ( + $a instanceof ConstantStringType + && $a->getValue() === '0' + && $b->describe(VerbosityLevel::value()) === 'non-falsy-string' + ) { + return [null, self::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ...self::getAccessoryCaseStringTypes($b), + )]; + } + + if ( + $b instanceof ConstantStringType + && $b->getValue() === '0' + && $a->describe(VerbosityLevel::value()) === 'non-falsy-string' + ) { + return [self::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + ...self::getAccessoryCaseStringTypes($a), + ), null]; + } + + return null; + } + + /** + * @return array + */ + private static function getAccessoryCaseStringTypes(Type $type): array + { + $accessory = []; + if ($type->isLowercaseString()->yes()) { + $accessory[] = new AccessoryLowercaseStringType(); + } + if ($type->isUppercaseString()->yes()) { + $accessory[] = new AccessoryUppercaseStringType(); + } + + return $accessory; + } + + private static function unionWithSubtractedType( + Type $type, + ?Type $subtractedType, + ): Type + { + if ($subtractedType === null) { + return $type; + } + + if ($type instanceof SubtractableType) { + $subtractedType = $type->getSubtractedType() === null + ? $subtractedType + : self::union($type->getSubtractedType(), $subtractedType); + if ($subtractedType instanceof NeverType) { + $subtractedType = null; + } + + return $type->changeSubtractedType($subtractedType); + } + + if ($subtractedType->isSuperTypeOf($type)->yes()) { + return new NeverType(); + } + + return self::remove($type, $subtractedType); + } + + private static function intersectWithSubtractedType( + SubtractableType $a, + Type $b, + ): Type + { + if ($a->getSubtractedType() === null) { + return $a; + } + + if ($b instanceof IntersectionType) { + $subtractableTypes = []; + foreach ($b->getTypes() as $innerType) { + if (!$innerType instanceof SubtractableType) { + continue; + } + + $subtractableTypes[] = $innerType; + } + + if (count($subtractableTypes) === 0) { + return $a->getTypeWithoutSubtractedType(); + } + + $subtractedTypes = []; + foreach ($subtractableTypes as $subtractableType) { + if ($subtractableType->getSubtractedType() === null) { + continue; + } + + $subtractedTypes[] = $subtractableType->getSubtractedType(); + } + + if (count($subtractedTypes) === 0) { + return $a->getTypeWithoutSubtractedType(); + + } + + $subtractedType = self::union(...$subtractedTypes); + } elseif ($b instanceof SubtractableType) { + $subtractedType = $b->getSubtractedType(); + if ($subtractedType === null) { + return $a->getTypeWithoutSubtractedType(); + } + } else { + $subtractedTypeTmp = self::intersect($a->getTypeWithoutSubtractedType(), $a->getSubtractedType()); + if ($b->isSuperTypeOf($subtractedTypeTmp)->yes()) { + return $a->getTypeWithoutSubtractedType(); + } + $subtractedType = new MixedType(false, $b); + } + + $subtractedType = self::intersect( + $a->getSubtractedType(), + $subtractedType, + ); + if ($subtractedType instanceof NeverType) { + $subtractedType = null; + } + + return $a->changeSubtractedType($subtractedType); + } + + /** + * @param Type[] $arrayTypes + * @return Type[] + */ + private static function processArrayAccessoryTypes(array $arrayTypes): array + { + $accessoryTypes = []; + foreach ($arrayTypes as $i => $arrayType) { + if ($arrayType instanceof IntersectionType) { + foreach ($arrayType->getTypes() as $innerType) { + if ($innerType instanceof TemplateType) { + break; + } + if (!($innerType instanceof AccessoryType) && !($innerType instanceof CallableType)) { + continue; + } + if ($innerType instanceof HasOffsetType) { + $offset = $innerType->getOffsetType(); + if ($offset instanceof ConstantStringType || $offset instanceof ConstantIntegerType) { + $innerType = new HasOffsetValueType($offset, $arrayType->getIterableValueType()); + } + } + if ($innerType instanceof HasOffsetValueType) { + $accessoryTypes[sprintf('hasOffsetValue(%s)', $innerType->getOffsetType()->describe(VerbosityLevel::cache()))][$i] = $innerType; + continue; + } + + $accessoryTypes[$innerType->describe(VerbosityLevel::cache())][$i] = $innerType; + } + } + + if (!$arrayType->isConstantArray()->yes()) { + continue; + } + $constantArrays = $arrayType->getConstantArrays(); + + foreach ($constantArrays as $constantArray) { + if ($constantArray->isList()->yes()) { + $list = new AccessoryArrayListType(); + $accessoryTypes[$list->describe(VerbosityLevel::cache())][$i] = $list; + } + + if (!$constantArray->isIterableAtLeastOnce()->yes()) { + continue; + } + + $nonEmpty = new NonEmptyArrayType(); + $accessoryTypes[$nonEmpty->describe(VerbosityLevel::cache())][$i] = $nonEmpty; + } + } + + $commonAccessoryTypes = []; + $arrayTypeCount = count($arrayTypes); + foreach ($accessoryTypes as $accessoryType) { + if (count($accessoryType) !== $arrayTypeCount) { + $firstKey = array_key_first($accessoryType); + if ($accessoryType[$firstKey] instanceof OversizedArrayType) { + $commonAccessoryTypes[] = $accessoryType[$firstKey]; + } + continue; + } + + if ($accessoryType[0] instanceof HasOffsetValueType) { + $commonAccessoryTypes[] = self::union(...$accessoryType); + continue; + } + + $commonAccessoryTypes[] = $accessoryType[0]; + } + + return $commonAccessoryTypes; + } + + /** + * @param list $arrayTypes + * @return Type[] + */ + private static function processArrayTypes(array $arrayTypes): array + { + if ($arrayTypes === []) { + return []; + } + + $accessoryTypes = self::processArrayAccessoryTypes($arrayTypes); + + if (count($arrayTypes) === 1) { + return [ + self::intersect(...$arrayTypes, ...$accessoryTypes), + ]; + } + + $keyTypesForGeneralArray = []; + $valueTypesForGeneralArray = []; + $generalArrayOccurred = false; + $constantKeyTypesNumbered = []; + $filledArrays = 0; + $overflowed = false; + + /** @var int|float $nextConstantKeyTypeIndex */ + $nextConstantKeyTypeIndex = 1; + $constantArraysMap = array_map( + static fn (Type $t) => $t->getConstantArrays(), + $arrayTypes, + ); + + foreach ($arrayTypes as $arrayIdx => $arrayType) { + $constantArrays = $constantArraysMap[$arrayIdx]; + $isConstantArray = $constantArrays !== []; + if (!$isConstantArray || !$arrayType->isIterableAtLeastOnce()->no()) { + $filledArrays++; + } + + if ($generalArrayOccurred || !$isConstantArray) { + foreach ($arrayType->getArrays() as $type) { + $keyTypesForGeneralArray[] = $type->getIterableKeyType(); + $valueTypesForGeneralArray[] = $type->getItemType(); + $generalArrayOccurred = true; + } + continue; + } + + $constantArrays = $arrayType->getConstantArrays(); + foreach ($constantArrays as $constantArray) { + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $keyTypesForGeneralArray[] = $keyType; + $valueTypesForGeneralArray[] = $constantArray->getValueTypes()[$i]; + + $keyTypeValue = $keyType->getValue(); + if (array_key_exists($keyTypeValue, $constantKeyTypesNumbered)) { + continue; + } + + $constantKeyTypesNumbered[$keyTypeValue] = $nextConstantKeyTypeIndex; + $nextConstantKeyTypeIndex *= 2; + if (!is_int($nextConstantKeyTypeIndex)) { + $generalArrayOccurred = true; + $overflowed = true; + continue 2; + } + } + } + } + + if ($generalArrayOccurred && (!$overflowed || $filledArrays > 1)) { + $reducedArrayTypes = self::reduceArrays($arrayTypes, false); + if (count($reducedArrayTypes) === 1) { + return [self::intersect($reducedArrayTypes[0], ...$accessoryTypes)]; + } + $scopes = []; + $useTemplateArray = true; + foreach ($arrayTypes as $arrayType) { + if (!$arrayType instanceof TemplateArrayType) { + $useTemplateArray = false; + break; + } + + $scopes[$arrayType->getScope()->describe()] = $arrayType; + } + + $arrayType = new ArrayType( + self::union(...$keyTypesForGeneralArray), + self::union(...self::optimizeConstantArrays($valueTypesForGeneralArray)), + ); + + if ($useTemplateArray && count($scopes) === 1) { + $templateArray = array_values($scopes)[0]; + $arrayType = new TemplateArrayType( + $templateArray->getScope(), + $templateArray->getStrategy(), + $templateArray->getVariance(), + $templateArray->getName(), + $arrayType, + $templateArray->getDefault(), + ); + } + + return [ + self::intersect($arrayType, ...$accessoryTypes), + ]; + } + + $reducedArrayTypes = self::reduceArrays($arrayTypes, true); + + return array_map( + static fn (Type $arrayType) => self::intersect($arrayType, ...$accessoryTypes), + self::optimizeConstantArrays($reducedArrayTypes), + ); + } + + /** + * @param Type[] $types + * @return Type[] + */ + private static function optimizeConstantArrays(array $types): array + { + $constantArrayValuesCount = self::countConstantArrayValueTypes($types); + + if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return $types; + } + + $results = []; + $eachIsOversized = true; + foreach ($types as $type) { + $isOversized = false; + $result = TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$isOversized): Type { + if (!$type instanceof ConstantArrayType) { + return $traverse($type); + } + + if ($type->isIterableAtLeastOnce()->no()) { + return $type; + } + + $isOversized = true; + + $isList = true; + $valueTypes = []; + $keyTypes = []; + $nextAutoIndex = 0; + foreach ($type->getKeyTypes() as $i => $innerKeyType) { + if (!$innerKeyType instanceof ConstantIntegerType) { + $isList = false; + } elseif ($innerKeyType->getValue() !== $nextAutoIndex) { + $isList = false; + $nextAutoIndex = $innerKeyType->getValue() + 1; + } else { + $nextAutoIndex++; + } + + $generalizedKeyType = $innerKeyType->generalize(GeneralizePrecision::moreSpecific()); + $keyTypes[$generalizedKeyType->describe(VerbosityLevel::precise())] = $generalizedKeyType; + + $innerValueType = $type->getValueTypes()[$i]; + $generalizedValueType = TypeTraverser::map($innerValueType, static function (Type $type) use ($traverse): Type { + if ($type instanceof ArrayType || $type instanceof ConstantArrayType) { + return TypeCombinator::intersect($type, new OversizedArrayType()); + } + + return $traverse($type); + }); + $valueTypes[$generalizedValueType->describe(VerbosityLevel::precise())] = $generalizedValueType; + } + + $keyType = TypeCombinator::union(...array_values($keyTypes)); + $valueType = TypeCombinator::union(...array_values($valueTypes)); + + $arrayType = new ArrayType($keyType, $valueType); + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType(), new OversizedArrayType()); + }); + + if (!$isOversized) { + $eachIsOversized = false; + } + + $results[] = $result; + } + + if ($eachIsOversized) { + $eachIsList = true; + $keyTypes = []; + $valueTypes = []; + foreach ($results as $result) { + $keyTypes[] = $result->getIterableKeyType(); + $valueTypes[] = $result->getLastIterableValueType(); + if ($result->isList()->yes()) { + continue; + } + $eachIsList = false; + } + + $keyType = self::union(...$keyTypes); + $valueType = self::union(...$valueTypes); + + $arrayType = new ArrayType($keyType, $valueType); + if ($eachIsList) { + $arrayType = self::intersect($arrayType, new AccessoryArrayListType()); + } + + return [self::intersect($arrayType, new NonEmptyArrayType(), new OversizedArrayType())]; + } + + return $results; + } + + /** + * @param Type[] $types + */ + public static function countConstantArrayValueTypes(array $types): int + { + $constantArrayValuesCount = 0; + foreach ($types as $type) { + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$constantArrayValuesCount): Type { + if ($type instanceof ConstantArrayType) { + $constantArrayValuesCount += count($type->getValueTypes()); + } + + return $traverse($type); + }); + } + return $constantArrayValuesCount; + } + + /** + * @param list $constantArrays + * @return list + */ + private static function reduceArrays(array $constantArrays, bool $preserveTaggedUnions): array + { + $newArrays = []; + $arraysToProcess = []; + $emptyArray = null; + foreach ($constantArrays as $constantArray) { + if (!$constantArray->isConstantArray()->yes()) { + // This is an optimization for current use-case of $preserveTaggedUnions=false, where we need + // one constant array as a result, or we generalize the $constantArrays. + if (!$preserveTaggedUnions) { + return $constantArrays; + } + $newArrays[] = $constantArray; + continue; + } + + if ($constantArray->isIterableAtLeastOnce()->no()) { + $emptyArray = $constantArray; + continue; + } + + $arraysToProcess = array_merge($arraysToProcess, $constantArray->getConstantArrays()); + } + + if ($emptyArray !== null) { + $newArrays[] = $emptyArray; + } + + $arraysToProcessPerKey = []; + foreach ($arraysToProcess as $i => $arrayToProcess) { + foreach ($arrayToProcess->getKeyTypes() as $keyType) { + $arraysToProcessPerKey[$keyType->getValue()][] = $i; + } + } + + $eligibleCombinations = []; + + foreach ($arraysToProcessPerKey as $arrays) { + for ($i = 0, $arraysCount = count($arrays); $i < $arraysCount - 1; $i++) { + for ($j = $i + 1; $j < $arraysCount; $j++) { + $eligibleCombinations[$arrays[$i]][$arrays[$j]] ??= 0; + $eligibleCombinations[$arrays[$i]][$arrays[$j]]++; + } + } + } + + foreach ($eligibleCombinations as $i => $other) { + if (!array_key_exists($i, $arraysToProcess)) { + continue; + } + + foreach ($other as $j => $overlappingKeysCount) { + if (!array_key_exists($j, $arraysToProcess)) { + continue; + } + + if ( + $preserveTaggedUnions + && $overlappingKeysCount === count($arraysToProcess[$i]->getKeyTypes()) + && $arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i]) + ) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + + if ( + $preserveTaggedUnions + && $overlappingKeysCount === count($arraysToProcess[$j]->getKeyTypes()) + && $arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j]) + ) { + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + continue 1; + } + + if ( + !$preserveTaggedUnions + // both arrays have same keys + && $overlappingKeysCount === count($arraysToProcess[$i]->getKeyTypes()) + && $overlappingKeysCount === count($arraysToProcess[$j]->getKeyTypes()) + ) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + } + } + + return array_merge($newArrays, $arraysToProcess); + } + + public static function intersect(Type ...$types): Type + { + $types = array_values($types); + + $typesCount = count($types); + if ($typesCount === 0) { + return new NeverType(); + } + if ($typesCount === 1) { + return $types[0]; + } + + $sortTypes = static function (Type $a, Type $b): int { + if (!$a instanceof UnionType || !$b instanceof UnionType) { + return 0; + } + + if ($a instanceof TemplateType) { + return -1; + } + if ($b instanceof TemplateType) { + return 1; + } + + if ($a instanceof BenevolentUnionType) { + return -1; + } + if ($b instanceof BenevolentUnionType) { + return 1; + } + + return 0; + }; + usort($types, $sortTypes); + // transform A & (B | C) to (A & B) | (A & C) + foreach ($types as $i => $type) { + if (!$type instanceof UnionType) { + continue; + } + + $topLevelUnionSubTypes = []; + $innerTypes = $type->getTypes(); + usort($innerTypes, $sortTypes); + $slice1 = array_slice($types, 0, $i); + $slice2 = array_slice($types, $i + 1); + foreach ($innerTypes as $innerUnionSubType) { + $topLevelUnionSubTypes[] = self::intersect( + $innerUnionSubType, + ...$slice1, + ...$slice2, + ); + } + + $union = self::union(...$topLevelUnionSubTypes); + if ($union instanceof NeverType) { + return $union; + } + + if ($type instanceof BenevolentUnionType) { + $union = TypeUtils::toBenevolentUnion($union); + } + + if ($type instanceof TemplateUnionType || $type instanceof TemplateBenevolentUnionType) { + $union = TemplateTypeFactory::create( + $type->getScope(), + $type->getName(), + $union, + $type->getVariance(), + $type->getStrategy(), + $type->getDefault(), + ); + } + + return $union; + } + $typesCount = count($types); + + // transform A & (B & C) to A & B & C + for ($i = 0; $i < $typesCount; $i++) { + $type = $types[$i]; + + if (!($type instanceof IntersectionType)) { + continue; + } + + array_splice($types, $i--, 1, $type->getTypes()); + $typesCount = count($types); + } + + $hasOffsetValueTypeCount = 0; + $newTypes = []; + foreach ($types as $type) { + if (!$type instanceof HasOffsetValueType) { + $newTypes[] = $type; + continue; + } + + $hasOffsetValueTypeCount++; + } + + if ($hasOffsetValueTypeCount > 32) { + $newTypes[] = new OversizedArrayType(); + $types = $newTypes; + $typesCount = count($types); + } + + usort($types, static function (Type $a, Type $b): int { + // move subtractables with subtracts before those without to avoid loosing them in the union logic + if ($a instanceof SubtractableType && $a->getSubtractedType() !== null) { + return -1; + } + if ($b instanceof SubtractableType && $b->getSubtractedType() !== null) { + return 1; + } + + if ($a instanceof ConstantArrayType && !$b instanceof ConstantArrayType) { + return -1; + } + if ($b instanceof ConstantArrayType && !$a instanceof ConstantArrayType) { + return 1; + } + + return 0; + }); + + // transform IntegerType & ConstantIntegerType to ConstantIntegerType + // transform Child & Parent to Child + // transform Object & ~null to Object + // transform A & A to A + // transform int[] & string to never + // transform callable & int to never + // transform A & ~A to never + // transform int & string to never + for ($i = 0; $i < $typesCount; $i++) { + for ($j = $i + 1; $j < $typesCount; $j++) { + if ($types[$j] instanceof SubtractableType) { + $typeWithoutSubtractedTypeA = $types[$j]->getTypeWithoutSubtractedType(); + + if ($typeWithoutSubtractedTypeA instanceof MixedType && $types[$i] instanceof MixedType) { + $isSuperTypeSubtractableA = $typeWithoutSubtractedTypeA->isSuperTypeOfMixed($types[$i]); + } else { + $isSuperTypeSubtractableA = $typeWithoutSubtractedTypeA->isSuperTypeOf($types[$i]); + } + if ($isSuperTypeSubtractableA->yes()) { + $types[$i] = self::unionWithSubtractedType($types[$i], $types[$j]->getSubtractedType()); + array_splice($types, $j--, 1); + $typesCount--; + continue 1; + } + } + + if ($types[$i] instanceof SubtractableType) { + $typeWithoutSubtractedTypeB = $types[$i]->getTypeWithoutSubtractedType(); + + if ($typeWithoutSubtractedTypeB instanceof MixedType && $types[$j] instanceof MixedType) { + $isSuperTypeSubtractableB = $typeWithoutSubtractedTypeB->isSuperTypeOfMixed($types[$j]); + } else { + $isSuperTypeSubtractableB = $typeWithoutSubtractedTypeB->isSuperTypeOf($types[$j]); + } + if ($isSuperTypeSubtractableB->yes()) { + $types[$j] = self::unionWithSubtractedType($types[$j], $types[$i]->getSubtractedType()); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + + if ($types[$i] instanceof IntegerRangeType) { + $intersectionType = $types[$i]->tryIntersect($types[$j]); + if ($intersectionType !== null) { + $types[$j] = $intersectionType; + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + + if ($types[$j] instanceof IterableType) { + $isSuperTypeA = $types[$j]->isSuperTypeOfMixed($types[$i]); + } else { + $isSuperTypeA = $types[$j]->isSuperTypeOf($types[$i]); + } + + if ($isSuperTypeA->yes()) { + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$i] instanceof IterableType) { + $isSuperTypeB = $types[$i]->isSuperTypeOfMixed($types[$j]); + } else { + $isSuperTypeB = $types[$i]->isSuperTypeOf($types[$j]); + } + + if ($isSuperTypeB->maybe()) { + if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof HasOffsetType) { + $types[$i] = $types[$i]->makeOffsetRequired($types[$j]->getOffsetType()); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof HasOffsetType) { + $types[$j] = $types[$j]->makeOffsetRequired($types[$i]->getOffsetType()); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ( + $types[$i] instanceof ConstantArrayType + && count($types[$i]->getKeyTypes()) === 1 + && $types[$i]->isOptionalKey(0) + && $types[$j] instanceof NonEmptyArrayType + ) { + $types[$i] = $types[$i]->makeOffsetRequired($types[$i]->getKeyTypes()[0]); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ( + $types[$j] instanceof ConstantArrayType + && count($types[$j]->getKeyTypes()) === 1 + && $types[$j]->isOptionalKey(0) + && $types[$i] instanceof NonEmptyArrayType + ) { + $types[$j] = $types[$j]->makeOffsetRequired($types[$j]->getKeyTypes()[0]); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof HasOffsetValueType) { + $offsetType = $types[$j]->getOffsetType(); + $valueType = $types[$j]->getValueType(); + $newValueType = self::intersect($types[$i]->getOffsetValueType($offsetType), $valueType); + if ($newValueType instanceof NeverType) { + return $newValueType; + } + $types[$i] = $types[$i]->setOffsetValueType($offsetType, $newValueType); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof HasOffsetValueType) { + $offsetType = $types[$i]->getOffsetType(); + $valueType = $types[$i]->getValueType(); + $newValueType = self::intersect($types[$j]->getOffsetValueType($offsetType), $valueType); + if ($newValueType instanceof NeverType) { + return $newValueType; + } + + $types[$j] = $types[$j]->setOffsetValueType($offsetType, $newValueType); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($types[$i] instanceof OversizedArrayType && $types[$j] instanceof HasOffsetValueType) { + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof OversizedArrayType && $types[$i] instanceof HasOffsetValueType) { + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { + $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { + $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $types[$i]->getValueTypes(); + foreach ($types[$i]->getKeyTypes() as $k => $keyType) { + $newArray->setOffsetValueType( + self::intersect($keyType, $types[$j]->getIterableKeyType()), + self::intersect($valueTypes[$k], $types[$j]->getIterableValueType()), + $types[$i]->isOptionalKey($k) && !$types[$j]->hasOffsetValueType($keyType)->yes(), + ); + } + $types[$i] = $newArray->getArray(); + array_splice($types, $j--, 1); + $typesCount--; + continue 2; + } + + if ($types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType)) { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $types[$j]->getValueTypes(); + foreach ($types[$j]->getKeyTypes() as $k => $keyType) { + $newArray->setOffsetValueType( + self::intersect($keyType, $types[$i]->getIterableKeyType()), + self::intersect($valueTypes[$k], $types[$i]->getIterableValueType()), + $types[$j]->isOptionalKey($k) && !$types[$i]->hasOffsetValueType($keyType)->yes(), + ); + } + $types[$j] = $newArray->getArray(); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ( + ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType || $types[$i] instanceof IterableType) && + ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType || $types[$j] instanceof IterableType) + ) { + $keyType = self::intersect($types[$i]->getIterableKeyType(), $types[$j]->getKeyType()); + $itemType = self::intersect($types[$i]->getItemType(), $types[$j]->getItemType()); + if ($types[$i] instanceof IterableType && $types[$j] instanceof IterableType) { + $types[$j] = new IterableType($keyType, $itemType); + } else { + $types[$j] = new ArrayType($keyType, $itemType); + } + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($types[$i] instanceof GenericClassStringType && $types[$j] instanceof GenericClassStringType) { + $genericType = self::intersect($types[$i]->getGenericType(), $types[$j]->getGenericType()); + $types[$i] = new GenericClassStringType($genericType); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ( + $types[$i] instanceof ArrayType + && get_class($types[$i]) === ArrayType::class + && $types[$j] instanceof AccessoryArrayListType + && !$types[$j]->getIterableKeyType()->isSuperTypeOf($types[$i]->getIterableKeyType())->yes() + ) { + $keyType = self::intersect($types[$i]->getIterableKeyType(), $types[$j]->getIterableKeyType()); + if ($keyType instanceof NeverType) { + return $keyType; + } + $types[$i] = new ArrayType($keyType, $types[$i]->getItemType()); + continue; + } + + continue; + } + + if ($isSuperTypeB->yes()) { + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + + if ($isSuperTypeA->no()) { + return new NeverType(); + } + } + } + + if ($typesCount === 1) { + return $types[0]; + } + + return new IntersectionType($types); + } + + public static function removeFalsey(Type $type): Type + { + return self::remove($type, StaticTypeFactory::falsey()); + } + + public static function removeTruthy(Type $type): Type + { + return self::remove($type, StaticTypeFactory::truthy()); + } + +} diff --git a/src/Type/TypeResult.php b/src/Type/TypeResult.php new file mode 100644 index 00000000..b83eab3f --- /dev/null +++ b/src/Type/TypeResult.php @@ -0,0 +1,30 @@ + */ + public readonly array $reasons; + + /** + * @param T $type + * @param list $reasons + */ + public function __construct( + Type $type, + array $reasons, + ) + { + $this->type = $type; + $this->reasons = $reasons; + } + +} diff --git a/src/Type/TypeTraverser.php b/src/Type/TypeTraverser.php new file mode 100644 index 00000000..707720a9 --- /dev/null +++ b/src/Type/TypeTraverser.php @@ -0,0 +1,62 @@ +getValue()); + * } + * // Replaces the current type, and don't traverse + * return new MixedType(); + * }); + * + * @api + * @param callable(Type $type, callable(Type): Type $traverse): Type $cb + */ + public static function map(Type $type, callable $cb): Type + { + $self = new self($cb); + + return $self->mapInternal($type); + } + + /** @param callable(Type $type, callable(Type): Type $traverse): Type $cb */ + private function __construct(callable $cb) + { + $this->cb = $cb; + } + + /** @internal */ + public function mapInternal(Type $type): Type + { + return ($this->cb)($type, [$this, 'traverseInternal']); + } + + /** @internal */ + public function traverseInternal(Type $type): Type + { + return $type->traverse([$this, 'mapInternal']); + } + +} diff --git a/src/Type/TypeUtils.php b/src/Type/TypeUtils.php new file mode 100644 index 00000000..c335d6e0 --- /dev/null +++ b/src/Type/TypeUtils.php @@ -0,0 +1,239 @@ + + */ + public static function getConstantIntegers(Type $type): array + { + return self::map(ConstantIntegerType::class, $type, false); + } + + /** + * @return list + */ + public static function getIntegerRanges(Type $type): array + { + return self::map(IntegerRangeType::class, $type, false); + } + + /** + * @return list + */ + private static function map( + string $typeClass, + Type $type, + bool $inspectIntersections, + bool $stopOnUnmatched = true, + ): array + { + if ($type instanceof $typeClass) { + return [$type]; + } + + if ($type instanceof UnionType) { + $matchingTypes = []; + foreach ($type->getTypes() as $innerType) { + $matchingInner = self::map($typeClass, $innerType, $inspectIntersections, $stopOnUnmatched); + + if ($matchingInner === []) { + if ($stopOnUnmatched) { + return []; + } + + continue; + } + + foreach ($matchingInner as $innerMapped) { + $matchingTypes[] = $innerMapped; + } + } + + return $matchingTypes; + } + + if ($inspectIntersections && $type instanceof IntersectionType) { + $matchingTypes = []; + foreach ($type->getTypes() as $innerType) { + if (!$innerType instanceof $typeClass) { + if ($stopOnUnmatched) { + return []; + } + + continue; + } + + $matchingTypes[] = $innerType; + } + + return $matchingTypes; + } + + return []; + } + + public static function toBenevolentUnion(Type $type): Type + { + if ($type instanceof BenevolentUnionType) { + return $type; + } + + if ($type instanceof UnionType) { + return new BenevolentUnionType($type->getTypes()); + } + + return $type; + } + + /** + * @return ($type is UnionType ? UnionType : Type) + */ + public static function toStrictUnion(Type $type): Type + { + if ($type instanceof TemplateBenevolentUnionType) { + return new TemplateUnionType( + $type->getScope(), + $type->getStrategy(), + $type->getVariance(), + $type->getName(), + static::toStrictUnion($type->getBound()), + $type->getDefault(), + ); + } + + if ($type instanceof BenevolentUnionType) { + return new UnionType($type->getTypes()); + } + + return $type; + } + + /** + * @return Type[] + */ + public static function flattenTypes(Type $type): array + { + if ($type instanceof ConstantArrayType) { + return $type->getAllArrays(); + } + + if ($type instanceof UnionType) { + $types = []; + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof ConstantArrayType) { + foreach ($innerType->getAllArrays() as $array) { + $types[] = $array; + } + continue; + } + + $types[] = $innerType; + } + + return $types; + } + + return [$type]; + } + + public static function findThisType(Type $type): ?ThisType + { + if ($type instanceof ThisType) { + return $type; + } + + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->getTypes() as $innerType) { + $thisType = self::findThisType($innerType); + if ($thisType !== null) { + return $thisType; + } + } + } + + return null; + } + + /** + * @return HasPropertyType[] + */ + public static function getHasPropertyTypes(Type $type): array + { + if ($type instanceof HasPropertyType) { + return [$type]; + } + + if ($type instanceof UnionType || $type instanceof IntersectionType) { + $hasPropertyTypes = [[]]; + foreach ($type->getTypes() as $innerType) { + $hasPropertyTypes[] = self::getHasPropertyTypes($innerType); + } + + return array_merge(...$hasPropertyTypes); + } + + return []; + } + + /** + * @return AccessoryType[] + */ + public static function getAccessoryTypes(Type $type): array + { + return self::map(AccessoryType::class, $type, true, false); + } + + public static function containsTemplateType(Type $type): bool + { + $containsTemplateType = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$containsTemplateType): Type { + if ($type instanceof TemplateType) { + $containsTemplateType = true; + } + + return $containsTemplateType ? $type : $traverse($type); + }); + + return $containsTemplateType; + } + + public static function resolveLateResolvableTypes(Type $type, bool $resolveUnresolvableTypes = true): Type + { + /** @var int $ignoreResolveUnresolvableTypesLevel */ + $ignoreResolveUnresolvableTypesLevel = 0; + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($resolveUnresolvableTypes, &$ignoreResolveUnresolvableTypesLevel): Type { + while ($type instanceof LateResolvableType && (($resolveUnresolvableTypes && $ignoreResolveUnresolvableTypesLevel === 0) || $type->isResolvable())) { + $type = $type->resolve(); + } + + if ($type instanceof CallableType || $type instanceof ClosureType) { + $ignoreResolveUnresolvableTypesLevel++; + $result = $traverse($type); + $ignoreResolveUnresolvableTypesLevel--; + + return $result; + } + + return $traverse($type); + }); + } + +} diff --git a/src/Type/TypeWithClassName.php b/src/Type/TypeWithClassName.php new file mode 100644 index 00000000..2fc3f9c6 --- /dev/null +++ b/src/Type/TypeWithClassName.php @@ -0,0 +1,18 @@ +getItemType(); + } + return $phpDocType ?? new MixedType(); + } + + if ($reflectionType instanceof ReflectionUnionType) { + $type = TypeCombinator::union(...array_map(static fn (ReflectionType $type): Type => self::decideTypeFromReflection($type, null, $selfClass, false), $reflectionType->getTypes())); + + return self::decideType($type, $phpDocType); + } + + if ($reflectionType instanceof ReflectionIntersectionType) { + $types = []; + foreach ($reflectionType->getTypes() as $innerReflectionType) { + $innerType = self::decideTypeFromReflection($innerReflectionType, null, $selfClass, false); + if (!$innerType->isObject()->yes()) { + return new NeverType(); + } + + $types[] = $innerType; + } + + return self::decideType(TypeCombinator::intersect(...$types), $phpDocType); + } + + if (!$reflectionType instanceof ReflectionNamedType) { + throw new ShouldNotHappenException(sprintf('Unexpected type: %s', get_class($reflectionType))); + } + + if ($reflectionType->isIdentifier()) { + $typeNode = new Identifier($reflectionType->getName()); + } else { + $typeNode = new FullyQualified($reflectionType->getName()); + } + + $type = ParserNodeTypeToPHPStanType::resolve($typeNode, $selfClass); + if ($reflectionType->allowsNull()) { + $type = TypeCombinator::addNull($type); + } elseif ($phpDocType !== null) { + $phpDocType = TypeCombinator::removeNull($phpDocType); + } + + return self::decideType($type, $phpDocType); + } + + public static function decideType( + Type $type, + ?Type $phpDocType = null, + ): Type + { + if ($type instanceof BenevolentUnionType) { + return $type; + } + + if ($phpDocType !== null && !$phpDocType instanceof ErrorType) { + if ($phpDocType instanceof NeverType && $phpDocType->isExplicit()) { + return $phpDocType; + } + if ( + $type instanceof MixedType + && !$type->isExplicitMixed() + && $phpDocType->isVoid()->yes() + ) { + return $phpDocType; + } + + if (TypeCombinator::removeNull($type) instanceof IterableType) { + if ($phpDocType instanceof UnionType) { + $innerTypes = []; + foreach ($phpDocType->getTypes() as $innerType) { + if ($innerType instanceof ArrayType || $innerType instanceof ConstantArrayType) { + $innerTypes[] = new IterableType( + $innerType->getIterableKeyType(), + $innerType->getItemType(), + ); + } else { + $innerTypes[] = $innerType; + } + } + $phpDocType = new UnionType($innerTypes); + } elseif ($phpDocType instanceof ArrayType || $phpDocType instanceof ConstantArrayType) { + $phpDocType = new IterableType( + $phpDocType->getKeyType(), + $phpDocType->getItemType(), + ); + } + } + + if ( + (!$phpDocType instanceof NeverType || ($type instanceof MixedType && !$type->isExplicitMixed())) + && $type->isSuperTypeOf(TemplateTypeHelper::resolveToBounds($phpDocType))->yes() + ) { + $resultType = $phpDocType; + } else { + $resultType = $type; + } + + if ($type instanceof UnionType) { + $addToUnionTypes = []; + foreach ($type->getTypes() as $innerType) { + if (!$innerType->isSuperTypeOf($resultType)->no()) { + continue; + } + + $addToUnionTypes[] = $innerType; + } + + if (count($addToUnionTypes) > 0) { + $type = TypeCombinator::union($resultType, ...$addToUnionTypes); + } else { + $type = $resultType; + } + } elseif (TypeCombinator::containsNull($type)) { + $type = TypeCombinator::addNull($resultType); + } else { + $type = $resultType; + } + } + + return $type; + } + +} diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php new file mode 100644 index 00000000..498b48c7 --- /dev/null +++ b/src/Type/UnionType.php @@ -0,0 +1,1198 @@ + [DateTimeImmutable::class, DateTime::class], + Throwable::class => [Error::class, Exception::class], // phpcs:ignore SlevomatCodingStandard.Exceptions.ReferenceThrowableOnly.ReferencedGeneralException + ]; + + private bool $sortedTypes = false; + + /** @var array */ + private array $cachedDescriptions = []; + + /** + * @api + * @param Type[] $types + */ + public function __construct(private array $types, private bool $normalized = false) + { + $throwException = static function () use ($types): void { + throw new ShouldNotHappenException(sprintf( + 'Cannot create %s with: %s', + self::class, + implode(', ', array_map(static fn (Type $type): string => $type->describe(VerbosityLevel::value()), $types)), + )); + }; + if (count($types) < 2) { + $throwException(); + } + foreach ($types as $type) { + if (!($type instanceof UnionType)) { + continue; + } + if ($type instanceof TemplateType) { + continue; + } + + $throwException(); + } + } + + /** + * @return Type[] + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param callable(Type $type): bool $filterCb + */ + public function filterTypes(callable $filterCb): Type + { + $newTypes = []; + $changed = false; + foreach ($this->getTypes() as $innerType) { + if (!$filterCb($innerType)) { + $changed = true; + continue; + } + + $newTypes[] = $innerType; + } + + if (!$changed) { + return $this; + } + + return TypeCombinator::union(...$newTypes); + } + + public function isNormalized(): bool + { + return $this->normalized; + } + + /** + * @return Type[] + */ + protected function getSortedTypes(): array + { + if ($this->sortedTypes) { + return $this->types; + } + + $this->types = UnionTypeHelper::sortTypes($this->types); + $this->sortedTypes = true; + + return $this->types; + } + + public function getReferencedClasses(): array + { + $classes = []; + foreach ($this->types as $type) { + foreach ($type->getReferencedClasses() as $className) { + $classes[] = $className; + } + } + + return $classes; + } + + public function getObjectClassNames(): array + { + return array_values(array_unique($this->pickFromTypes( + static fn (Type $type) => $type->getObjectClassNames(), + static fn (Type $type) => $type->isObject()->yes(), + ))); + } + + public function getObjectClassReflections(): array + { + return $this->pickFromTypes( + static fn (Type $type) => $type->getObjectClassReflections(), + static fn (Type $type) => $type->isObject()->yes(), + ); + } + + public function getArrays(): array + { + return $this->pickFromTypes( + static fn (Type $type) => $type->getArrays(), + static fn (Type $type) => $type->isArray()->yes(), + ); + } + + public function getConstantArrays(): array + { + return $this->pickFromTypes( + static fn (Type $type) => $type->getConstantArrays(), + static fn (Type $type) => $type->isArray()->yes(), + ); + } + + public function getConstantStrings(): array + { + return $this->pickFromTypes( + static fn (Type $type) => $type->getConstantStrings(), + static fn (Type $type) => $type->isString()->yes(), + ); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + foreach (self::EQUAL_UNION_CLASSES as $baseClass => $classes) { + if (!$type->equals(new ObjectType($baseClass))) { + continue; + } + + $union = TypeCombinator::union( + ...array_map(static fn (string $objectClass): Type => new ObjectType($objectClass), $classes), + ); + if ($this->accepts($union, $strictTypes)->yes()) { + return AcceptsResult::createYes(); + } + break; + } + + $result = AcceptsResult::createNo(); + foreach ($this->getSortedTypes() as $i => $innerType) { + $result = $result->or($innerType->accepts($type, $strictTypes)->decorateReasons(static fn (string $reason) => sprintf('Type #%d from the union: %s', $i + 1, $reason))); + } + if ($result->yes()) { + return $result; + } + + if ($type instanceof CompoundType && !$type instanceof CallableType && !$type instanceof TemplateType && !$type instanceof IntersectionType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if ($type instanceof TemplateUnionType) { + return $result->or($type->isAcceptedBy($this, $strictTypes)); + } + + if ($type->isEnum()->yes() && !$this->isEnum()->no()) { + $enumCasesUnion = TypeCombinator::union(...$type->getEnumCases()); + if (!$type->equals($enumCasesUnion)) { + return $this->accepts($enumCasesUnion, $strictTypes); + } + } + + return $result; + } + + public function isSuperTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ( + ($otherType instanceof self && !$otherType instanceof TemplateUnionType) + || $otherType instanceof IterableType + || $otherType instanceof NeverType + || $otherType instanceof ConditionalType + || $otherType instanceof ConditionalTypeForParameter + || $otherType instanceof IntegerRangeType + ) { + return $otherType->isSubTypeOf($this); + } + + $results = []; + foreach ($this->types as $innerType) { + $result = $innerType->isSuperTypeOf($otherType); + if ($result->yes()) { + return $result; + } + $results[] = $result; + } + $result = IsSuperTypeOfResult::createNo()->or(...$results); + + if ($otherType instanceof TemplateUnionType) { + return $result->or($otherType->isSubTypeOf($this)); + } + + return $result; + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::extremeIdentity(...array_map(static fn (Type $innerType) => $otherType->isSuperTypeOf($innerType), $this->types)); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return AcceptsResult::extremeIdentity(...array_map(static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), $this->types)); + } + + public function equals(Type $type): bool + { + if (!$type instanceof static) { + return false; + } + + if (count($this->types) !== count($type->types)) { + return false; + } + + $otherTypes = $type->types; + foreach ($this->types as $innerType) { + $match = false; + foreach ($otherTypes as $i => $otherType) { + if (!$innerType->equals($otherType)) { + continue; + } + + $match = true; + unset($otherTypes[$i]); + break; + } + + if (!$match) { + return false; + } + } + + return count($otherTypes) === 0; + } + + public function describe(VerbosityLevel $level): string + { + if (isset($this->cachedDescriptions[$level->getLevelValue()])) { + return $this->cachedDescriptions[$level->getLevelValue()]; + } + $joinTypes = static function (array $types) use ($level): string { + $typeNames = []; + foreach ($types as $i => $type) { + if ($type instanceof ClosureType || $type instanceof CallableType || $type instanceof TemplateUnionType) { + $typeNames[] = sprintf('(%s)', $type->describe($level)); + } elseif ($type instanceof TemplateType) { + $isLast = $i >= count($types) - 1; + $bound = $type->getBound(); + if ( + !$isLast + && ($level->isTypeOnly() || $level->isValue()) + && !($bound instanceof MixedType && $bound->getSubtractedType() === null && !$bound instanceof TemplateMixedType) + ) { + $typeNames[] = sprintf('(%s)', $type->describe($level)); + } else { + $typeNames[] = $type->describe($level); + } + } elseif ($type instanceof IntersectionType) { + $intersectionDescription = $type->describe($level); + if (str_contains($intersectionDescription, '&')) { + $typeNames[] = sprintf('(%s)', $type->describe($level)); + } else { + $typeNames[] = $intersectionDescription; + } + } else { + $typeNames[] = $type->describe($level); + } + } + + if ($level->isPrecise()) { + $duplicates = array_diff_assoc($typeNames, array_unique($typeNames)); + if (count($duplicates) > 0) { + $indexByDuplicate = array_fill_keys($duplicates, 0); + foreach ($typeNames as $key => $typeName) { + if (!isset($indexByDuplicate[$typeName])) { + continue; + } + + $typeNames[$key] = $typeName . '#' . ++$indexByDuplicate[$typeName]; + } + } + } else { + $typeNames = array_unique($typeNames); + } + + if (count($typeNames) > 1024) { + return implode('|', array_slice($typeNames, 0, 1024)) . "|\u{2026}"; + } + + return implode('|', $typeNames); + }; + + return $this->cachedDescriptions[$level->getLevelValue()] = $level->handle( + function () use ($joinTypes): string { + $types = TypeCombinator::union(...array_map(static function (Type $type): Type { + if ( + $type->isConstantValue()->yes() + && $type->isTrue()->or($type->isFalse())->no() + ) { + return $type->generalize(GeneralizePrecision::lessSpecific()); + } + + return $type; + }, $this->getSortedTypes())); + + if ($types instanceof UnionType) { + return $joinTypes($types->getSortedTypes()); + } + + return $joinTypes([$types]); + }, + fn (): string => $joinTypes($this->getSortedTypes()), + ); + } + + /** + * @param callable(Type $type): TrinaryLogic $canCallback + * @param callable(Type $type): TrinaryLogic $hasCallback + */ + private function hasInternal( + callable $canCallback, + callable $hasCallback, + ): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->types, static function (Type $type) use ($canCallback, $hasCallback): TrinaryLogic { + if ($canCallback($type)->no()) { + return TrinaryLogic::createNo(); + } + + return $hasCallback($type); + }); + } + + /** + * @template TObject of object + * @param callable(Type $type): TrinaryLogic $hasCallback + * @param callable(Type $type): TObject $getCallback + * @return TObject + */ + private function getInternal( + callable $hasCallback, + callable $getCallback, + ): object + { + /** @var TrinaryLogic|null $result */ + $result = null; + + /** @var TObject|null $object */ + $object = null; + foreach ($this->types as $type) { + $has = $hasCallback($type); + if (!$has->yes()) { + continue; + } + if ($result !== null && $result->compareTo($has) !== $has) { + continue; + } + + $get = $getCallback($type); + $result = $has; + $object = $get; + } + + if ($object === null) { + throw new ShouldNotHappenException(); + } + + return $object; + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getTemplateType($ancestorClassName, $templateTypeName)); + } + + public function isObject(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isObject()); + } + + public function isEnum(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isEnum()); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->canAccessProperties()); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getUnresolvedPropertyPrototype($propertyName, $scope)->getTransformedProperty(); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + $propertyPrototypes = []; + foreach ($this->types as $type) { + if (!$type->hasProperty($propertyName)->yes()) { + continue; + } + + $propertyPrototypes[] = $type->getUnresolvedPropertyPrototype($propertyName, $scope)->withFechedOnType($this); + } + + $propertiesCount = count($propertyPrototypes); + if ($propertiesCount === 0) { + throw new ShouldNotHappenException(); + } + + if ($propertiesCount === 1) { + return $propertyPrototypes[0]; + } + + return new UnionTypeUnresolvedPropertyPrototypeReflection($propertyName, $propertyPrototypes); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->canCallMethods()); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getUnresolvedMethodPrototype($methodName, $scope)->getTransformedMethod(); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + $methodPrototypes = []; + foreach ($this->types as $type) { + if (!$type->hasMethod($methodName)->yes()) { + continue; + } + + $methodPrototypes[] = $type->getUnresolvedMethodPrototype($methodName, $scope)->withCalledOnType($this); + } + + $methodsCount = count($methodPrototypes); + if ($methodsCount === 0) { + throw new ShouldNotHappenException(); + } + + if ($methodsCount === 1) { + return $methodPrototypes[0]; + } + + return new UnionTypeUnresolvedMethodPrototypeReflection($methodName, $methodPrototypes); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->canAccessConstants()); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->hasInternal( + static fn (Type $type): TrinaryLogic => $type->canAccessConstants(), + static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName), + ); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->getInternal( + static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName), + static fn (Type $type): ClassConstantReflection => $type->getConstant($constantName), + ); + } + + public function isIterable(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isIterable()); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce()); + } + + public function getArraySize(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getArraySize()); + } + + public function getIterableKeyType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getIterableKeyType()); + } + + public function getFirstIterableKeyType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getFirstIterableKeyType()); + } + + public function getLastIterableKeyType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getLastIterableKeyType()); + } + + public function getIterableValueType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getIterableValueType()); + } + + public function getFirstIterableValueType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getFirstIterableValueType()); + } + + public function getLastIterableValueType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getLastIterableValueType()); + } + + public function isArray(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isArray()); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); + } + + public function isOversizedArray(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isOversizedArray()); + } + + public function isList(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isList()); + } + + public function isString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isString()); + } + + public function isNumericString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNumericString()); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNonEmptyString()); + } + + public function isNonFalsyString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNonFalsyString()); + } + + public function isLiteralString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isLiteralString()); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isLowercaseString()); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isUppercaseString()); + } + + public function isClassString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isClassString()); + } + + public function getClassStringObjectType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getClassStringObjectType()); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getObjectTypeOrClassStringObjectType()); + } + + public function isVoid(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isVoid()); + } + + public function isScalar(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isScalar()); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return $this->unionResults( + static fn (Type $innerType): TrinaryLogic => $innerType->looseCompare($type, $phpVersion)->toTrinaryLogic() + )->toBooleanType(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isOffsetAccessible()); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isOffsetAccessLegal()); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); + } + + public function getOffsetValueType(Type $offsetType): Type + { + $types = []; + foreach ($this->types as $innerType) { + $valueType = $innerType->getOffsetValueType($offsetType); + if ($valueType instanceof ErrorType) { + continue; + } + + $types[] = $valueType; + } + + if (count($types) === 0) { + return new ErrorType(); + } + + return TypeCombinator::union(...$types); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->setOffsetValueType($offsetType, $valueType, $unionValues)); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->setExistingOffsetValueType($offsetType, $valueType)); + } + + public function unsetOffset(Type $offsetType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->unsetOffset($offsetType)); + } + + public function getKeysArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getKeysArray()); + } + + public function getValuesArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getValuesArray()); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->chunkArray($lengthType, $preserveKeys)); + } + + public function fillKeysArray(Type $valueType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->fillKeysArray($valueType)); + } + + public function flipArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->flipArray()); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->intersectKeyArray($otherArraysType)); + } + + public function popArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->popArray()); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->reverseArray($preserveKeys)); + } + + public function searchArray(Type $needleType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->searchArray($needleType)); + } + + public function shiftArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->shiftArray()); + } + + public function shuffleArray(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->shuffleArray()); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->sliceArray($offsetType, $lengthType, $preserveKeys)); + } + + public function getEnumCases(): array + { + return $this->pickFromTypes( + static fn (Type $type) => $type->getEnumCases(), + static fn (Type $type) => $type->isObject()->yes(), + ); + } + + public function isCallable(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isCallable()); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + $acceptors = []; + + foreach ($this->types as $type) { + if ($type->isCallable()->no()) { + continue; + } + + $acceptors = array_merge($acceptors, $type->getCallableParametersAcceptors($scope)); + } + + if (count($acceptors) === 0) { + throw new ShouldNotHappenException(); + } + + return $acceptors; + } + + public function isCloneable(): TrinaryLogic + { + return $this->unionResults(static fn (Type $type): TrinaryLogic => $type->isCloneable()); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isSmallerThan($otherType, $phpVersion)); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isSmallerThanOrEqual($otherType, $phpVersion)); + } + + public function isNull(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNull()); + } + + public function isConstantValue(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isConstantValue()); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isConstantScalarValue()); + } + + public function getConstantScalarTypes(): array + { + return $this->notBenevolentPickFromTypes(static fn (Type $type) => $type->getConstantScalarTypes()); + } + + public function getConstantScalarValues(): array + { + return $this->notBenevolentPickFromTypes(static fn (Type $type) => $type->getConstantScalarValues()); + } + + public function isTrue(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isTrue()); + } + + public function isFalse(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isFalse()); + } + + public function isBoolean(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isBoolean()); + } + + public function isFloat(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isFloat()); + } + + public function isInteger(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isInteger()); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getSmallerType($phpVersion)); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getSmallerOrEqualType($phpVersion)); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getGreaterType($phpVersion)); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->getGreaterOrEqualType($phpVersion)); + } + + public function isGreaterThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $otherType->isSmallerThan($type, $phpVersion)); + } + + public function isGreaterThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $otherType->isSmallerThanOrEqual($type, $phpVersion)); + } + + public function toBoolean(): BooleanType + { + /** @var BooleanType $type */ + $type = $this->unionTypes(static fn (Type $type): BooleanType => $type->toBoolean()); + + return $type; + } + + public function toNumber(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toNumber()); + + return $type; + } + + public function toAbsoluteNumber(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); + + return $type; + } + + public function toString(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toString()); + + return $type; + } + + public function toInteger(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toInteger()); + + return $type; + } + + public function toFloat(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toFloat()); + + return $type; + } + + public function toArray(): Type + { + $type = $this->unionTypes(static fn (Type $type): Type => $type->toArray()); + + return $type; + } + + public function toArrayKey(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->toArrayKey()); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->toCoercedArgumentType($strictTypes)); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + if ($receivedType instanceof UnionType) { + $myTypes = []; + $remainingReceivedTypes = []; + foreach ($receivedType->getTypes() as $receivedInnerType) { + foreach ($this->types as $type) { + if ($type->isSuperTypeOf($receivedInnerType)->yes()) { + $types = $types->union($type->inferTemplateTypes($receivedInnerType)); + continue 2; + } + $myTypes[] = $type; + } + $remainingReceivedTypes[] = $receivedInnerType; + } + if (count($remainingReceivedTypes) === 0) { + return $types; + } + $receivedType = TypeCombinator::union(...$remainingReceivedTypes); + } else { + $myTypes = $this->types; + } + + foreach ($myTypes as $type) { + if ($type instanceof TemplateType || ($type instanceof GenericClassStringType && $type->getGenericType() instanceof TemplateType)) { + continue; + } + $types = $types->union($type->inferTemplateTypes($receivedType)); + } + + if (!$types->isEmpty()) { + return $types; + } + + foreach ($myTypes as $type) { + $types = $types->union($type->inferTemplateTypes($receivedType)); + } + + return $types; + } + + public function inferTemplateTypesOn(Type $templateType): TemplateTypeMap + { + $types = TemplateTypeMap::createEmpty(); + + foreach ($this->types as $type) { + $types = $types->union($templateType->inferTemplateTypes($type)); + } + + return $types; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $references = []; + + foreach ($this->types as $type) { + foreach ($type->getReferencedTemplateTypes($positionVariance) as $reference) { + $references[] = $reference; + } + } + + return $references; + } + + public function traverse(callable $cb): Type + { + $types = []; + $changed = false; + + foreach ($this->types as $type) { + $newType = $cb($type); + if ($type !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeCombinator::union(...$types); + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + $types = []; + $changed = false; + + if (!$right instanceof self) { + return $this; + } + + if (count($this->getTypes()) !== count($right->getTypes())) { + return $this; + } + + foreach ($this->getSortedTypes() as $i => $leftType) { + $rightType = $right->getSortedTypes()[$i]; + $newType = $cb($leftType, $rightType); + if ($leftType !== $newType) { + $changed = true; + } + $types[] = $newType; + } + + if ($changed) { + return TypeCombinator::union(...$types); + } + + return $this; + } + + public function tryRemove(Type $typeToRemove): ?Type + { + return $this->unionTypes(static fn (Type $type): Type => TypeCombinator::remove($type, $typeToRemove)); + } + + public function exponentiate(Type $exponent): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->exponentiate($exponent)); + } + + public function getFiniteTypes(): array + { + $types = $this->notBenevolentPickFromTypes(static fn (Type $type) => $type->getFiniteTypes()); + $uniquedTypes = []; + foreach ($types as $type) { + $uniquedTypes[md5($type->describe(VerbosityLevel::cache()))] = $type; + } + + if (count($uniquedTypes) > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) { + return []; + } + + return array_values($uniquedTypes); + } + + /** + * @param callable(Type $type): TrinaryLogic $getResult + */ + protected function unionResults(callable $getResult): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->types, $getResult); + } + + /** + * @param callable(Type $type): TrinaryLogic $getResult + */ + private function notBenevolentUnionResults(callable $getResult): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->types, $getResult); + } + + /** + * @param callable(Type $type): Type $getType + */ + protected function unionTypes(callable $getType): Type + { + return TypeCombinator::union(...array_map($getType, $this->types)); + } + + /** + * @template T + * @param callable(Type $type): list $getValues + * @param callable(Type $type): bool $criteria + * @return list + */ + protected function pickFromTypes( + callable $getValues, + callable $criteria, + ): array + { + $values = []; + foreach ($this->types as $type) { + $innerValues = $getValues($type); + if ($innerValues === []) { + return []; + } + + foreach ($innerValues as $innerType) { + $values[] = $innerType; + } + } + + return $values; + } + + public function toPhpDocNode(): TypeNode + { + return new UnionTypeNode(array_map(static fn (Type $type) => $type->toPhpDocNode(), $this->getSortedTypes())); + } + + /** + * @template T + * @param callable(Type $type): list $getValues + * @return list + */ + private function notBenevolentPickFromTypes(callable $getValues): array + { + $values = []; + foreach ($this->types as $type) { + $innerValues = $getValues($type); + if ($innerValues === []) { + return []; + } + + foreach ($innerValues as $innerType) { + $values[] = $innerType; + } + } + + return $values; + } + +} diff --git a/src/Type/UnionTypeHelper.php b/src/Type/UnionTypeHelper.php new file mode 100644 index 00000000..4d1b4d32 --- /dev/null +++ b/src/Type/UnionTypeHelper.php @@ -0,0 +1,139 @@ + 1024) { + return $types; + } + + usort($types, static function (Type $a, Type $b): int { + if ($a instanceof NullType) { + return 1; + } elseif ($b instanceof NullType) { + return -1; + } + + if ($a instanceof AccessoryType) { + if ($b instanceof AccessoryType) { + return self::compareStrings($a->describe(VerbosityLevel::value()), $b->describe(VerbosityLevel::value())); + } + + return 1; + } + if ($b instanceof AccessoryType) { + return -1; + } + + $aIsBool = $a instanceof ConstantBooleanType; + $bIsBool = $b instanceof ConstantBooleanType; + if ($aIsBool && !$bIsBool) { + return 1; + } elseif ($bIsBool && !$aIsBool) { + return -1; + } + if ($a instanceof ConstantScalarType && !$b instanceof ConstantScalarType) { + return -1; + } elseif (!$a instanceof ConstantScalarType && $b instanceof ConstantScalarType) { + return 1; + } + + if ( + ( + $a instanceof ConstantIntegerType + || $a instanceof ConstantFloatType + ) + && ( + $b instanceof ConstantIntegerType + || $b instanceof ConstantFloatType + ) + ) { + $cmp = $a->getValue() <=> $b->getValue(); + if ($cmp !== 0) { + return $cmp; + } + if ($a instanceof ConstantIntegerType && $b instanceof ConstantFloatType) { + return -1; + } + if ($b instanceof ConstantIntegerType && $a instanceof ConstantFloatType) { + return 1; + } + return 0; + } + + if ($a instanceof IntegerRangeType && $b instanceof IntegerRangeType) { + return ($a->getMin() ?? PHP_INT_MIN) <=> ($b->getMin() ?? PHP_INT_MIN); + } + + if ($a instanceof IntegerRangeType && $b instanceof IntegerType) { + return 1; + } + + if ($b instanceof IntegerRangeType && $a instanceof IntegerType) { + return -1; + } + + if ($a instanceof ConstantStringType && $b instanceof ConstantStringType) { + return self::compareStrings($a->getValue(), $b->getValue()); + } + + if ($a->isConstantArray()->yes() && $b->isConstantArray()->yes()) { + if ($a->isIterableAtLeastOnce()->no()) { + if ($b->isIterableAtLeastOnce()->no()) { + return 0; + } + + return -1; + } elseif ($b->isIterableAtLeastOnce()->no()) { + return 1; + } + + return self::compareStrings($a->describe(VerbosityLevel::value()), $b->describe(VerbosityLevel::value())); + } + + if ( + ($a instanceof CallableType || $a instanceof ClosureType) + && ($b instanceof CallableType || $b instanceof ClosureType) + ) { + return self::compareStrings($a->describe(VerbosityLevel::value()), $b->describe(VerbosityLevel::value())); + } + + if ($a->isString()->yes() && $b->isString()->yes()) { + return self::compareStrings($a->describe(VerbosityLevel::precise()), $b->describe(VerbosityLevel::precise())); + } + + return self::compareStrings($a->describe(VerbosityLevel::typeOnly()), $b->describe(VerbosityLevel::typeOnly())); + }); + return $types; + } + + private static function compareStrings(string $a, string $b): int + { + $cmp = strcasecmp($a, $b); + if ($cmp !== 0) { + return $cmp; + } + + return $a <=> $b; + } + +} diff --git a/src/Type/UsefulTypeAliasResolver.php b/src/Type/UsefulTypeAliasResolver.php new file mode 100644 index 00000000..71c047ec --- /dev/null +++ b/src/Type/UsefulTypeAliasResolver.php @@ -0,0 +1,154 @@ + */ + private array $resolvedGlobalTypeAliases = []; + + /** @var array */ + private array $resolvedLocalTypeAliases = []; + + /** @var array */ + private array $resolvingClassTypeAliases = []; + + /** @var array */ + private array $inProcess = []; + + /** + * @param array $globalTypeAliases + */ + public function __construct( + private array $globalTypeAliases, + private TypeStringResolver $typeStringResolver, + private TypeNodeResolver $typeNodeResolver, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function hasTypeAlias(string $aliasName, ?string $classNameScope): bool + { + $hasGlobalTypeAlias = array_key_exists($aliasName, $this->globalTypeAliases); + if ($hasGlobalTypeAlias) { + return true; + } + + if ($classNameScope === null || !$this->reflectionProvider->hasClass($classNameScope)) { + return false; + } + + $classReflection = $this->reflectionProvider->getClass($classNameScope); + $localTypeAliases = $classReflection->getTypeAliases(); + return array_key_exists($aliasName, $localTypeAliases); + } + + public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type + { + return $this->resolveLocalTypeAlias($aliasName, $nameScope) + ?? $this->resolveGlobalTypeAlias($aliasName, $nameScope); + } + + private function resolveLocalTypeAlias(string $aliasName, NameScope $nameScope): ?Type + { + if (array_key_exists($aliasName, $this->globalTypeAliases)) { + return null; + } + + if (!$nameScope->hasTypeAlias($aliasName)) { + return null; + } + + $className = $nameScope->getClassNameForTypeAlias(); + if ($className === null) { + return null; + } + + $aliasNameInClassScope = $className . '::' . $aliasName; + + if (array_key_exists($aliasNameInClassScope, $this->resolvedLocalTypeAliases)) { + return $this->resolvedLocalTypeAliases[$aliasNameInClassScope]; + } + + // prevent infinite recursion + if (array_key_exists($className, $this->resolvingClassTypeAliases)) { + return null; + } + + $this->resolvingClassTypeAliases[$className] = true; + + if (!$this->reflectionProvider->hasClass($className)) { + unset($this->resolvingClassTypeAliases[$className]); + return null; + } + + $classReflection = $this->reflectionProvider->getClass($className); + $localTypeAliases = $classReflection->getTypeAliases(); + + unset($this->resolvingClassTypeAliases[$className]); + + if (!array_key_exists($aliasName, $localTypeAliases)) { + return null; + } + + if (array_key_exists($aliasNameInClassScope, $this->inProcess)) { + // resolve circular reference as ErrorType to make it easier to detect + throw new CircularTypeAliasDefinitionException(); + } + + $this->inProcess[$aliasNameInClassScope] = true; + + try { + $unresolvedAlias = $localTypeAliases[$aliasName]; + $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); + } catch (CircularTypeAliasDefinitionException) { + $resolvedAliasType = new CircularTypeAliasErrorType(); + } + + $this->resolvedLocalTypeAliases[$aliasNameInClassScope] = $resolvedAliasType; + unset($this->inProcess[$aliasNameInClassScope]); + + return $resolvedAliasType; + } + + private function resolveGlobalTypeAlias(string $aliasName, NameScope $nameScope): ?Type + { + if (!array_key_exists($aliasName, $this->globalTypeAliases)) { + return null; + } + + if (array_key_exists($aliasName, $this->resolvedGlobalTypeAliases)) { + return $this->resolvedGlobalTypeAliases[$aliasName]; + } + + if ($this->reflectionProvider->hasClass($nameScope->resolveStringName($aliasName))) { + throw new ShouldNotHappenException(sprintf('Type alias %s already exists as a class.', $aliasName)); + } + + if (array_key_exists($aliasName, $this->inProcess)) { + throw new ShouldNotHappenException(sprintf('Circular definition for type alias %s.', $aliasName)); + } + + $this->inProcess[$aliasName] = true; + + $aliasTypeString = $this->globalTypeAliases[$aliasName]; + $aliasType = $this->typeStringResolver->resolve($aliasTypeString); + $this->resolvedGlobalTypeAliases[$aliasName] = $aliasType; + + unset($this->inProcess[$aliasName]); + + return $aliasType; + } + +} diff --git a/src/Type/ValueOfType.php b/src/Type/ValueOfType.php new file mode 100644 index 00000000..46592a12 --- /dev/null +++ b/src/Type/ValueOfType.php @@ -0,0 +1,104 @@ +type->getReferencedClasses(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->type->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type); + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('value-of<%s>', $this->type->describe($level)); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type); + } + + protected function getResult(): Type + { + if ($this->type->isEnum()->yes()) { + $valueTypes = []; + foreach ($this->type->getEnumCases() as $enumCase) { + $valueType = $enumCase->getBackingValueType(); + if ($valueType === null) { + continue; + } + + $valueTypes[] = $valueType; + } + + return TypeCombinator::union(...$valueTypes); + } + + return $this->type->getIterableValueType(); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type); + } + + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode(new IdentifierTypeNode('value-of'), [$this->type->toPhpDocNode()]); + } + +} diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php new file mode 100644 index 00000000..6368ac40 --- /dev/null +++ b/src/Type/VerbosityLevel.php @@ -0,0 +1,236 @@ +value; + } + + /** @api */ + public static function typeOnly(): self + { + return self::create(self::TYPE_ONLY); + } + + /** @api */ + public static function value(): self + { + return self::create(self::VALUE); + } + + /** @api */ + public static function precise(): self + { + return self::create(self::PRECISE); + } + + /** @api */ + public static function cache(): self + { + return self::create(self::CACHE); + } + + public function isTypeOnly(): bool + { + return $this->value === self::TYPE_ONLY; + } + + public function isValue(): bool + { + return $this->value === self::VALUE; + } + + public function isPrecise(): bool + { + return $this->value === self::PRECISE; + } + + /** @api */ + public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acceptedType = null): self + { + $moreVerboseCallback = static function (Type $type, callable $traverse) use (&$moreVerbose, &$veryVerbose): Type { + if ($type->isCallable()->yes()) { + $moreVerbose = true; + + // Keep checking if we need to be very verbose. + return $traverse($type); + } + if ($type->isConstantValue()->yes() && $type->isNull()->no()) { + $moreVerbose = true; + + // For ConstantArrayType we need to keep checking if we need to be very verbose. + if (!$type->isArray()->no()) { + return $traverse($type); + } + + return $type; + } + if ( + // synced with IntersectionType::describe() + $type instanceof AccessoryNonEmptyStringType + || $type instanceof AccessoryNonFalsyStringType + || $type instanceof AccessoryLiteralStringType + || $type instanceof AccessoryNumericStringType + || $type instanceof NonEmptyArrayType + || $type instanceof AccessoryArrayListType + ) { + $moreVerbose = true; + return $type; + } + if ( + $type instanceof AccessoryLowercaseStringType + || $type instanceof AccessoryUppercaseStringType + ) { + $moreVerbose = true; + $veryVerbose = true; + return $type; + } + if ($type instanceof IntegerRangeType) { + $moreVerbose = true; + return $type; + } + return $traverse($type); + }; + + /** @var bool $moreVerbose */ + $moreVerbose = false; + /** @var bool $veryVerbose */ + $veryVerbose = false; + TypeTraverser::map($acceptingType, $moreVerboseCallback); + + if ($veryVerbose) { + return self::precise(); + } + + if ($moreVerbose) { + $verbosity = self::value(); + } + + if ($acceptedType === null) { + return $verbosity ?? self::typeOnly(); + } + + $containsInvariantTemplateType = false; + TypeTraverser::map($acceptingType, static function (Type $type, callable $traverse) use (&$containsInvariantTemplateType): Type { + if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { + $reflection = $type->getClassReflection(); + if ($reflection !== null) { + $templateTypeMap = $reflection->getTemplateTypeMap(); + foreach ($templateTypeMap->getTypes() as $templateType) { + if (!$templateType instanceof TemplateType) { + continue; + } + + if (!$templateType->getVariance()->invariant()) { + continue; + } + + $containsInvariantTemplateType = true; + return $type; + } + } + } + + return $traverse($type); + }); + + if (!$containsInvariantTemplateType) { + return $verbosity ?? self::typeOnly(); + } + + /** @var bool $moreVerbose */ + $moreVerbose = false; + /** @var bool $veryVerbose */ + $veryVerbose = false; + TypeTraverser::map($acceptedType, $moreVerboseCallback); + + if ($veryVerbose) { + return self::precise(); + } + + return $moreVerbose ? self::value() : $verbosity ?? self::typeOnly(); + } + + /** + * @param callable(): string $typeOnlyCallback + * @param callable(): string $valueCallback + * @param callable(): string|null $preciseCallback + * @param callable(): string|null $cacheCallback + */ + public function handle( + callable $typeOnlyCallback, + callable $valueCallback, + ?callable $preciseCallback = null, + ?callable $cacheCallback = null, + ): string + { + if ($this->value === self::TYPE_ONLY) { + return $typeOnlyCallback(); + } + + if ($this->value === self::VALUE) { + return $valueCallback(); + } + + if ($this->value === self::PRECISE) { + if ($preciseCallback !== null) { + return $preciseCallback(); + } + + return $valueCallback(); + } + + if ($cacheCallback !== null) { + return $cacheCallback(); + } + + if ($preciseCallback !== null) { + return $preciseCallback(); + } + + return $valueCallback(); + } + +} diff --git a/src/Type/VoidType.php b/src/Type/VoidType.php new file mode 100644 index 00000000..cc7388a2 --- /dev/null +++ b/src/Type/VoidType.php @@ -0,0 +1,273 @@ +isAcceptedBy($this, $strictTypes); + } + + return new AcceptsResult($type->isVoid()->or($type->isNull()), []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + public function equals(Type $type): bool + { + return $type instanceof self; + } + + public function describe(VerbosityLevel $level): string + { + return 'void'; + } + + public function toNumber(): Type + { + return new ErrorType(); + } + + public function toAbsoluteNumber(): Type + { + return new ErrorType(); + } + + public function toString(): Type + { + return new ErrorType(); + } + + public function toInteger(): Type + { + return new ErrorType(); + } + + public function toFloat(): Type + { + return new ErrorType(); + } + + public function toArray(): Type + { + return new ErrorType(); + } + + public function toArrayKey(): Type + { + return new ErrorType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return new NullType(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNumericString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isLowercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function exponentiate(Type $exponent): Type + { + return new ErrorType(); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode('void'); + } + +} diff --git a/src/autoloadFunctions.php b/src/autoloadFunctions.php new file mode 100644 index 00000000..b304dd38 --- /dev/null +++ b/src/autoloadFunctions.php @@ -0,0 +1,12 @@ + + */ +function autoloadFunctions(): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + return $GLOBALS['__phpstanAutoloadFunctions'] ?? []; +} diff --git a/src/debugScope.php b/src/debugScope.php new file mode 100644 index 00000000..e0cdbef1 --- /dev/null +++ b/src/debugScope.php @@ -0,0 +1,15 @@ +