diff --git a/analyzer_test.go b/analyzer_test.go index 04cdb8d..6b002cd 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -3,6 +3,7 @@ package gobce import ( "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -135,3 +136,160 @@ func score(v int) int { t.Fatalf("expected uncovered branches from resolvable source file") } } + +func TestAnalyzeIfWithoutElseTerminatingBody(t *testing.T) { + tests := []struct { + name string + ifBodyCount int + fallthroughCount int + wantUncoveredTruePath bool + wantUncoveredImplicitFalse bool + }{ + { + name: "only_true_path_covered", + ifBodyCount: 1, + fallthroughCount: 0, + wantUncoveredImplicitFalse: true, + }, + { + name: "only_false_path_covered", + ifBodyCount: 0, + fallthroughCount: 1, + wantUncoveredTruePath: true, + }, + { + name: "both_paths_covered", + ifBodyCount: 1, + fallthroughCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + return 1 + } + return 2 +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,6.3 1 " + strconv.Itoa(tt.ifBodyCount), + srcPath + ":6.3,7.10 1 " + strconv.Itoa(tt.fallthroughCount), + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + gotUncoveredTruePath := hasUncoveredBranchKind(result, "if_true_path") + if gotUncoveredTruePath != tt.wantUncoveredTruePath { + t.Fatalf("if_true_path uncovered: got %v, want %v", gotUncoveredTruePath, tt.wantUncoveredTruePath) + } + + gotUncoveredImplicitFalse := hasUncoveredBranchKind(result, "if_implicit_false_path") + if gotUncoveredImplicitFalse != tt.wantUncoveredImplicitFalse { + t.Fatalf("if_implicit_false_path uncovered: got %v, want %v", gotUncoveredImplicitFalse, tt.wantUncoveredImplicitFalse) + } + }) + } +} + +func TestAnalyzeIfWithoutElseNonTerminatingBody(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + score := 0 + if v > 10 { + score = 1 + } + return score +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,5.13 2 1", + srcPath + ":5.13,7.3 1 0", + srcPath + ":7.3,8.14 1 1", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + if hasUncoveredBranchKind(result, "if_implicit_false_path") { + t.Fatalf("did not expect if_implicit_false_path for non-terminating if body") + } +} + +func TestAnalyzeIfWithoutElsePanicBody(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + panic("too high") + } + return 2 +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,6.3 1 1", + srcPath + ":6.3,7.10 1 0", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + if !hasUncoveredBranchKind(result, "if_implicit_false_path") { + t.Fatalf("expected if_implicit_false_path in uncovered branches") + } +} + +func hasUncoveredBranchKind(result Result, kind string) bool { + for _, b := range result.UncoveredBranches { + if b.Kind == kind { + return true + } + } + return false +} diff --git a/internal/analyzer/branches.go b/internal/analyzer/branches.go index 2b75e10..601fa9d 100644 --- a/internal/analyzer/branches.go +++ b/internal/analyzer/branches.go @@ -44,7 +44,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra FilePath: filePath, Line: fset.Position(n.Body.Lbrace).Line, Kind: "if_true_path", - Covered: spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()), + Covered: blockStmtCovered(blocks, fset, n.Body), }) if n.Else != nil { @@ -54,6 +54,13 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra Kind: "if_false_path", Covered: spanCovered(blocks, fset, n.Else.Pos(), n.Else.End()), }) + } else if ifBodyTerminates(n.Body) { + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.If).Line, + Kind: "if_implicit_false_path", + Covered: fallthroughCovered(blocks, fset, n.Body), + }) } case *ast.SwitchStmt: @@ -61,7 +68,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra case *ast.TypeSwitchStmt: candidates = append(candidates, collectCaseClauseCandidates(filePath, fset, blocks, n.Body.List, "type_switch_case_path", "type_switch_default_path")...) case *ast.ForStmt: - bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + bodyCovered := blockStmtCovered(blocks, fset, n.Body) stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) candidates = append(candidates, branchCandidate{ FilePath: filePath, @@ -76,7 +83,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra Covered: stmtCovered && !bodyCovered, }) case *ast.RangeStmt: - bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + bodyCovered := blockStmtCovered(blocks, fset, n.Body) stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) candidates = append(candidates, branchCandidate{ FilePath: filePath, @@ -118,6 +125,46 @@ func collectCaseClauseCandidates(filePath string, fset *token.FileSet, blocks [] return candidates } +func ifBodyTerminates(body *ast.BlockStmt) bool { + if len(body.List) == 0 { + return false + } + + last := body.List[len(body.List)-1] + switch s := last.(type) { + case *ast.ReturnStmt: + return true + case *ast.BranchStmt: + return true + case *ast.ExprStmt: + call, ok := s.X.(*ast.CallExpr) + if !ok { + return false + } + ident, ok := call.Fun.(*ast.Ident) + return ok && ident.Name == "panic" + default: + return false + } +} + +func fallthroughCovered(blocks []coverageBlock, fset *token.FileSet, body *ast.BlockStmt) bool { + endPos := fset.Position(body.End()) + for _, b := range blocks { + if b.Count <= 0 { + continue + } + if b.StartLine == endPos.Line && b.StartCol == endPos.Column { + return true + } + } + return false +} + +func blockStmtCovered(blocks []coverageBlock, fset *token.FileSet, body *ast.BlockStmt) bool { + return spanCovered(blocks, fset, body.Lbrace+1, body.End()) +} + func spanCovered(blocks []coverageBlock, fset *token.FileSet, start token.Pos, end token.Pos) bool { startPos := fset.Position(start) endPos := fset.Position(end)