From 8fd69d02a36df998238b9918a02fc96405fb07b3 Mon Sep 17 00:00:00 2001 From: Richardas Kuchinskas Date: Wed, 16 Apr 2025 04:59:42 +0300 Subject: [PATCH 1/5] solution prototype --- src/linter/block.go | 71 ++++++-- src/linter/block_linter.go | 15 +- src/linter/report.go | 25 +++ .../func_params_type_mismatch_phpdoc_test.go | 154 ++++++++++++++++++ 4 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 src/tests/checkers/func_params_type_mismatch_phpdoc_test.go diff --git a/src/linter/block.go b/src/linter/block.go index ec2c899d..12bc6758 100644 --- a/src/linter/block.go +++ b/src/linter/block.go @@ -551,17 +551,47 @@ func (b *blockWalker) handleAndCheckGlobalStmt(s *ir.GlobalStmt) { } } -func (b *blockWalker) CheckParamNullability(params []ir.Node) { +func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocParamTypes map[string]string) { + if phpDocParamTypes == nil || len(phpDocParamTypes) == 0 { + return + } + + phpDocType := phpDocParamTypes["$"+param.Variable.Name] + paramName := param.Variable.Name + + // TODO: case with arrays + switch typ := param.VariableType.(type) { + case *ir.Name: + if phpDocType != typ.Value { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + case *ir.Identifier: + if phpDocType != typ.Value { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + + case *ir.Nullable: + if !strings.Contains(phpDocType, "?") && !strings.Contains(phpDocType, "null") { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + default: + return + } +} + +func (b *blockWalker) CheckParamNullability(params []ir.Node, phpDocParamTypes map[string]string) { for _, param := range params { if p, ok := param.(*ir.Parameter); ok { var paramType ir.Node - paramType, paramOk := p.VariableType.(*ir.Name) - if !paramOk { - paramIdentifier, paramIdentifierOk := p.VariableType.(*ir.Identifier) - if !paramIdentifierOk { - continue - } - paramType = paramIdentifier + + b.checkPhpDocTypesWithTypeHints(p, phpDocParamTypes) + switch typ := p.VariableType.(type) { + case *ir.Name, *ir.Identifier: + paramType = typ + case *ir.Nullable: + continue + default: + continue } paramName, ok := paramType.(*ir.Name) @@ -586,9 +616,29 @@ func (b *blockWalker) CheckParamNullability(params []ir.Node) { } } +func (b *blockWalker) getParamsTypesFromPhpDoc(doc phpdoc.Comment) map[string]string { + if len(doc.Parsed) == 0 { + return nil + } + phpDocParamTypes := make(map[string]string) + + for _, part := range doc.Parsed { + switch part.Name() { + case "param": + param, ok := part.(*phpdoc.TypeVarCommentPart) + if ok { + phpDocParamTypes[param.Var] = param.Type.Expr.Value + } + } + } + return phpDocParamTypes +} + func (b *blockWalker) handleFunction(fun *ir.FunctionStmt) bool { if b.ignoreFunctionBodies { - b.CheckParamNullability(fun.Params) + phpDocParamTypes := b.getParamsTypesFromPhpDoc(fun.Doc) + + b.CheckParamNullability(fun.Params, phpDocParamTypes) return false } @@ -1628,7 +1678,8 @@ func (b *blockWalker) handleCallArgs(args []ir.Node, fn meta.FuncInfo) { ArgTypes: funcArgTypes, } - b.CheckParamNullability(a.Params) + phpDocParamTypes := b.getParamsTypesFromPhpDoc(a.Doc) + b.CheckParamNullability(a.Params, phpDocParamTypes) b.enterClosure(a, isInstance, typ, closureSolver) default: a.Walk(b) diff --git a/src/linter/block_linter.go b/src/linter/block_linter.go index 66cd5c13..927e2de4 100644 --- a/src/linter/block_linter.go +++ b/src/linter/block_linter.go @@ -44,10 +44,12 @@ func (b *blockLinter) enterNode(n ir.Node) { b.checkFunctionCall(n) case *ir.ArrowFunctionExpr: - b.walker.CheckParamNullability(n.Params) + phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(n.Doc) + b.walker.CheckParamNullability(n.Params, phpDocParamTypes) case *ir.ClosureExpr: - b.walker.CheckParamNullability(n.Params) + phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(n.Doc) + b.walker.CheckParamNullability(n.Params, phpDocParamTypes) case *ir.MethodCallExpr: b.checkMethodCall(n) @@ -238,7 +240,8 @@ func (b *blockLinter) checkTrait(n *ir.TraitStmt) { for _, stmt := range n.Stmts { method, ok := stmt.(*ir.ClassMethodStmt) if ok { - b.walker.CheckParamNullability(method.Params) + phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(method.Doc) + b.walker.CheckParamNullability(method.Params, phpDocParamTypes) } } } @@ -252,7 +255,8 @@ func (b *blockLinter) checkClass(class *ir.ClassStmt) { switch value := stmt.(type) { case *ir.ClassMethodStmt: members = append(members, classMethod) - b.walker.CheckParamNullability(value.Params) + phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(value.Doc) + b.walker.CheckParamNullability(value.Params, phpDocParamTypes) case *ir.PropertyListStmt: for _, element := range value.Doc.Parsed { if element.Name() == "deprecated" { @@ -1611,7 +1615,8 @@ func (b *blockLinter) checkInterfaceStmt(iface *ir.InterfaceStmt) { b.report(x, LevelWarning, "nonPublicInterfaceMember", "'%s' can't be %s", methodName, modifier.Value) } } - b.walker.CheckParamNullability(x.Params) + phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(x.Doc) + b.walker.CheckParamNullability(x.Params, phpDocParamTypes) case *ir.ClassConstListStmt: for _, modifier := range x.Modifiers { if strings.EqualFold(modifier.Value, "private") || strings.EqualFold(modifier.Value, "protected") { diff --git a/src/linter/report.go b/src/linter/report.go index c4c9c62f..4e6b656a 100644 --- a/src/linter/report.go +++ b/src/linter/report.go @@ -1356,6 +1356,31 @@ class Main { Before: `if (gettype($a) == "string") { ... }`, After: `if (is_string($a)) { ... }`, }, + + { + Name: "funcParamTypeMissMatch", + Default: true, + Quickfix: false, + Comment: `Report function typehint and phpdoc mismatch.`, + Before: ` +/** + * + * @param ?string $name + * @return string + */ +function wrongParam(string $name): string { + return "Hello, $name"; +}`, + After: ` +/** + * + * @param ?string $name + * @return string + */ +function wrongParam(?string $name): string { + return "Hello, $name"; +}`, + }, } for _, info := range allChecks { diff --git a/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go new file mode 100644 index 00000000..890b3e57 --- /dev/null +++ b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go @@ -0,0 +1,154 @@ +package checkers_test + +import ( + "testing" + + "github.com/VKCOM/noverify/src/linttest" +) + +func TestFunctionParamTypeMismatch(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(`>`, + `param $d miss matched with phpdoc type <>`, + } + test.RunAndMatch() +} + +func TestMethodParamTypeMismatch(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(`>`, + `param $desc miss matched with phpdoc type <>`, + } + test.RunAndMatch() +} + +func TestIgnoreReturnTypeMismatch(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(` 0 ? $a : null; +} + +// Call the function to trigger linting +returnMismatch(5); +`) + test.Expect = []string{ + `Expression evaluated but not used`, + } + test.RunAndMatch() +} + +func TestArrayParamTypeMismatch(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(`>`, + } + test.RunAndMatch() +} From 096b6945a69b3d8f5a7d443e0dfebfd242ad8a97 Mon Sep 17 00:00:00 2001 From: Richardas Kuchinskas Date: Wed, 16 Apr 2025 17:11:35 +0300 Subject: [PATCH 2/5] array and callable support --- src/linter/block.go | 87 ++++++++++-- .../func_params_type_mismatch_phpdoc_test.go | 133 ++++++++++++++++++ .../not_explicit_nullable_types_test.go | 2 +- 3 files changed, 211 insertions(+), 11 deletions(-) diff --git a/src/linter/block.go b/src/linter/block.go index 12bc6758..54723242 100644 --- a/src/linter/block.go +++ b/src/linter/block.go @@ -552,24 +552,91 @@ func (b *blockWalker) handleAndCheckGlobalStmt(s *ir.GlobalStmt) { } func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocParamTypes map[string]string) { - if phpDocParamTypes == nil || len(phpDocParamTypes) == 0 { + if phpDocParamTypes == nil { return } - phpDocType := phpDocParamTypes["$"+param.Variable.Name] + var variableName string + + if param.ByRef { + variableName = "&$" + param.Variable.Name + } else { + variableName = "$" + param.Variable.Name + } + phpDocType := phpDocParamTypes[variableName] + // If not found (when phpdoc omits "&"), try without the "&" prefix + if phpDocType == "" { + phpDocType = phpDocParamTypes["$"+param.Variable.Name] + } + paramName := param.Variable.Name - // TODO: case with arrays switch typ := param.VariableType.(type) { - case *ir.Name: - if phpDocType != typ.Value { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) - } - case *ir.Identifier: - if phpDocType != typ.Value { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + case *ir.Name, *ir.Identifier: + var typeValue string + switch n := typ.(type) { + case *ir.Name: + typeValue = n.Value + case *ir.Identifier: + typeValue = n.Value + } + + normalizedPhpDocType := strings.TrimPrefix(phpDocType, "\\") + + // Special handling for callable remains unchanged + if typeValue == "callable" { + if !strings.HasPrefix(strings.TrimSpace(normalizedPhpDocType), "callable") { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + break } + // Union types + if strings.Contains(normalizedPhpDocType, "|") { + parts := strings.Split(normalizedPhpDocType, "|") + isPhpDocNullable := false + for _, part := range parts { + if strings.TrimSpace(part) == "null" { + isPhpDocNullable = true + break + } + } + + if isPhpDocNullable { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } else { + matchFound := false + for _, part := range parts { + docPart := strings.TrimSpace(part) + if typeValue == "array" { + if docPart == "array" || strings.HasSuffix(docPart, "[]") { + matchFound = true + break + } + } else if docPart == typeValue { + matchFound = true + break + } + } + if !matchFound { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + } + } else { + // Non-union types + if typeValue == "array" { + if normalizedPhpDocType != "array" && !strings.HasSuffix(normalizedPhpDocType, "[]") { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + } else if normalizedPhpDocType != typeValue { + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + } + } case *ir.Nullable: if !strings.Contains(phpDocType, "?") && !strings.Contains(phpDocType, "null") { b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) diff --git a/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go index 890b3e57..20f8c38b 100644 --- a/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go +++ b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go @@ -152,3 +152,136 @@ function funcArrayError1(?array $c) { } test.RunAndMatch() } + +func TestByReferenceCorrect(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(`>`, + } + test.RunAndMatch() +} + +func TestCallableCorrect(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(`>`, + } + test.RunAndMatch() +} + +func TestNullableCorrect(t *testing.T) { + test := linttest.NewSuite(t) + test.AddFile(` Date: Thu, 17 Apr 2025 04:09:43 +0300 Subject: [PATCH 3/5] tests, aliases, better checking type --- src/linter/block.go | 35 ++++++++----- .../func_params_type_mismatch_phpdoc_test.go | 50 +++++++++++++++++-- src/tests/golden/testdata/mustache/golden.txt | 3 ++ src/types/map.go | 2 +- src/types/predicates.go | 4 ++ 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/linter/block.go b/src/linter/block.go index 54723242..544a9caf 100644 --- a/src/linter/block.go +++ b/src/linter/block.go @@ -552,7 +552,7 @@ func (b *blockWalker) handleAndCheckGlobalStmt(s *ir.GlobalStmt) { } func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocParamTypes map[string]string) { - if phpDocParamTypes == nil { + if len(phpDocParamTypes) == 0 { return } @@ -573,18 +573,17 @@ func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocP switch typ := param.VariableType.(type) { case *ir.Name, *ir.Identifier: - var typeValue string + var paramTypeValue string switch n := typ.(type) { case *ir.Name: - typeValue = n.Value + paramTypeValue = n.Value case *ir.Identifier: - typeValue = n.Value + paramTypeValue = n.Value } normalizedPhpDocType := strings.TrimPrefix(phpDocType, "\\") - // Special handling for callable remains unchanged - if typeValue == "callable" { + if paramTypeValue == "callable" { if !strings.HasPrefix(strings.TrimSpace(normalizedPhpDocType), "callable") { b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) @@ -610,12 +609,12 @@ func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocP matchFound := false for _, part := range parts { docPart := strings.TrimSpace(part) - if typeValue == "array" { + if paramTypeValue == "array" { if docPart == "array" || strings.HasSuffix(docPart, "[]") { matchFound = true break } - } else if docPart == typeValue { + } else if docPart == paramTypeValue { matchFound = true break } @@ -627,12 +626,25 @@ func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocP } } else { // Non-union types - if typeValue == "array" { + if paramTypeValue == "array" { if normalizedPhpDocType != "array" && !strings.HasSuffix(normalizedPhpDocType, "[]") { b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) } - } else if normalizedPhpDocType != typeValue { + } else if normalizedPhpDocType != paramTypeValue { + if phpDocType == paramTypeValue { + break + } + + if types.IsBoolean(normalizedPhpDocType) && types.IsBoolean(paramTypeValue) { + break + } + + potentialAlias := b.linter.classParseState().Uses[paramTypeValue] + if potentialAlias == phpDocType { + break + } + b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) } @@ -690,8 +702,7 @@ func (b *blockWalker) getParamsTypesFromPhpDoc(doc phpdoc.Comment) map[string]st phpDocParamTypes := make(map[string]string) for _, part := range doc.Parsed { - switch part.Name() { - case "param": + if part.Name() == "param" { param, ok := part.(*phpdoc.TypeVarCommentPart) if ok { phpDocParamTypes[param.Var] = param.Type.Expr.Value diff --git a/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go index 20f8c38b..c8864939 100644 --- a/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go +++ b/src/tests/checkers/func_params_type_mismatch_phpdoc_test.go @@ -215,17 +215,17 @@ testCallable(function($a, $b) { func TestInterfaceCorrect(t *testing.T) { test := linttest.NewSuite(t) test.AddFile(`> at testdata/mustache/src/Mustache/Compiler.php:43 + public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8', $strictCallables = false, $entityFlags = ENT_COMPAT) + ^^^^^^^^^^^ ERROR classMembersOrder: Constant KLASS must go before methods in the class Mustache_Compiler at testdata/mustache/src/Mustache/Compiler.php:184 const KLASS = ' Date: Fri, 18 Apr 2025 03:20:12 +0300 Subject: [PATCH 4/5] fixes after rebase --- src/linter/block_linter.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/linter/block_linter.go b/src/linter/block_linter.go index 62619078..61bc6c31 100644 --- a/src/linter/block_linter.go +++ b/src/linter/block_linter.go @@ -257,13 +257,6 @@ func (b *blockLinter) checkClass(class *ir.ClassStmt) { members = append(members, classMethod) phpDocParamTypes := b.walker.getParamsTypesFromPhpDoc(value.Doc) b.walker.CheckParamNullability(value.Params, phpDocParamTypes) - case *ir.PropertyListStmt: - for _, element := range value.Doc.Parsed { - if element.Name() == "deprecated" { - b.report(stmt, LevelNotice, "deprecated", "Has deprecated field in class %s", class.ClassName.Value) - } - } - members = append(members, classOtherMember) default: members = append(members, classOtherMember) } From dcf6ede97156fb5697075fa882a2f62f4161fee6 Mon Sep 17 00:00:00 2001 From: Richardas Kuchinskas Date: Fri, 18 Apr 2025 17:37:59 +0300 Subject: [PATCH 5/5] refactoring --- src/linter/block.go | 209 +++++++++++------- src/tests/golden/testdata/mustache/golden.txt | 2 +- 2 files changed, 126 insertions(+), 85 deletions(-) diff --git a/src/linter/block.go b/src/linter/block.go index 544a9caf..fb8919cb 100644 --- a/src/linter/block.go +++ b/src/linter/block.go @@ -556,106 +556,147 @@ func (b *blockWalker) checkPhpDocTypesWithTypeHints(param *ir.Parameter, phpDocP return } - var variableName string - + // Build the lookup key, with fallback if "&$" did not find + name := param.Variable.Name + key := "$" + name if param.ByRef { - variableName = "&$" + param.Variable.Name - } else { - variableName = "$" + param.Variable.Name + if _, ok := phpDocParamTypes["&$"+name]; ok { + key = "&$" + name + } } - phpDocType := phpDocParamTypes[variableName] - // If not found (when phpdoc omits "&"), try without the "&" prefix - if phpDocType == "" { - phpDocType = phpDocParamTypes["$"+param.Variable.Name] + rawDoc := strings.TrimSpace(phpDocParamTypes[key]) + if rawDoc == "" && param.ByRef { + rawDoc = strings.TrimSpace(phpDocParamTypes["$"+name]) } - paramName := param.Variable.Name + if rawDoc == "" { + return + } - switch typ := param.VariableType.(type) { - case *ir.Name, *ir.Identifier: - var paramTypeValue string - switch n := typ.(type) { - case *ir.Name: - paramTypeValue = n.Value - case *ir.Identifier: - paramTypeValue = n.Value - } + b.checkDiffPhpDocWithTypeHints(param.VariableType, name, rawDoc, b.linter.classParseState().Uses) +} - normalizedPhpDocType := strings.TrimPrefix(phpDocType, "\\") - // Special handling for callable remains unchanged - if paramTypeValue == "callable" { - if !strings.HasPrefix(strings.TrimSpace(normalizedPhpDocType), "callable") { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", - "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) - } - break - } +func (b *blockWalker) checkDiffPhpDocWithTypeHints( + node ir.Node, + paramName, rawPhpDocType string, + uses map[string]string, +) { + // 1) Normalization + doc := strings.TrimSpace(rawPhpDocType) + doc = strings.TrimPrefix(doc, `\`) + doc = strings.TrimSpace(doc) + + // 2) Unpack Nullable from AST + var isNullable bool + if n, ok := node.(*ir.Nullable); ok { + isNullable = true + // remove ? and null for type checking + doc = strings.TrimPrefix(doc, "?") + parts := strings.Split(doc, "|") + clean := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" && p != "null" { + clean = append(clean, p) + } + } + doc = strings.Join(clean, "|") + node = n.Expr + } + + // 3) AST type + var typeValue string + switch t := node.(type) { + case *ir.Name: + typeValue = t.Value + case *ir.Identifier: + typeValue = t.Value + default: + return + } - // Union types - if strings.Contains(normalizedPhpDocType, "|") { - parts := strings.Split(normalizedPhpDocType, "|") - isPhpDocNullable := false - for _, part := range parts { - if strings.TrimSpace(part) == "null" { - isPhpDocNullable = true - break - } - } + // 4) Getting alias + alias := uses[typeValue] + typeValue = strings.TrimPrefix(typeValue, `\`) - if isPhpDocNullable { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", - "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) - } else { - matchFound := false - for _, part := range parts { - docPart := strings.TrimSpace(part) - if paramTypeValue == "array" { - if docPart == "array" || strings.HasSuffix(docPart, "[]") { - matchFound = true - break - } - } else if docPart == paramTypeValue { - matchFound = true - break - } - } - if !matchFound { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", - "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) - } - } - } else { - // Non-union types - if paramTypeValue == "array" { - if normalizedPhpDocType != "array" && !strings.HasSuffix(normalizedPhpDocType, "[]") { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", - "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) - } - } else if normalizedPhpDocType != paramTypeValue { - if phpDocType == paramTypeValue { - break - } + if alias == "" { + alias = uses[typeValue] + } - if types.IsBoolean(normalizedPhpDocType) && types.IsBoolean(paramTypeValue) { - break - } + alias = strings.TrimPrefix(alias, `\`) - potentialAlias := b.linter.classParseState().Uses[paramTypeValue] - if potentialAlias == phpDocType { - break - } + // 5) Helper + report := func() { + b.linter.report( + node, LevelWarning, "funcParamTypeMissMatch", + "param $%s miss matched with phpdoc type <<%s>>", + paramName, rawPhpDocType, + ) + } - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", - "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + // 6) if hint nullable, but PHPDoc has no '?' or 'null' - report + if isNullable && !strings.Contains(rawPhpDocType, "?") && !strings.Contains(rawPhpDocType, "null") { + report() + return + } + + // 7) callable + if typeValue == "callable" { + if !strings.HasPrefix(doc, "callable") { + report() + } + return + } + + // 8) Union + if strings.Contains(doc, "|") { + parts := strings.Split(doc, "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + // if union include null, but hint not nullable - report + if !isNullable { + for _, p := range parts { + if p == "null" { + report() + return + } } } - case *ir.Nullable: - if !strings.Contains(phpDocType, "?") && !strings.Contains(phpDocType, "null") { - b.linter.report(param, LevelWarning, "funcParamTypeMissMatch", "param $%s miss matched with phpdoc type <<%s>>", paramName, phpDocType) + // Any contains in union + for _, p := range parts { + // array[] + if typeValue == "array" && (p == "array" || strings.HasSuffix(p, "[]")) { + return + } + // boolean-boolean + if types.IsBoolean(p) && types.IsBoolean(typeValue) { + return + } + // 1-1 or alias + if p == typeValue || p == alias { + return + } } - default: + report() return } + + // 9) Single/general: array / boolean / 1-1 / alias + switch { + case typeValue == "array": + if doc != "array" && !strings.HasSuffix(doc, "[]") { + report() + } + case types.IsBoolean(doc) && types.IsBoolean(typeValue): + // ok + case doc == typeValue: + // ok + case alias != "" && doc == alias: + // ok + default: + report() + } } func (b *blockWalker) CheckParamNullability(params []ir.Node, phpDocParamTypes map[string]string) { diff --git a/src/tests/golden/testdata/mustache/golden.txt b/src/tests/golden/testdata/mustache/golden.txt index 5ded006e..40ca38a0 100644 --- a/src/tests/golden/testdata/mustache/golden.txt +++ b/src/tests/golden/testdata/mustache/golden.txt @@ -129,7 +129,7 @@ MAYBE callSimplify: Could simplify to $id[0] at testdata/mustache/src/Mustache ^^^^^^^^^^^^^^^^^ WARNING funcParamTypeMissMatch: param $tree miss matched with phpdoc type <> at testdata/mustache/src/Mustache/Compiler.php:43 public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8', $strictCallables = false, $entityFlags = ENT_COMPAT) - ^^^^^^^^^^^ + ^^^^^ ERROR classMembersOrder: Constant KLASS must go before methods in the class Mustache_Compiler at testdata/mustache/src/Mustache/Compiler.php:184 const KLASS = '