Skip to content

Commit d1c8568

Browse files
authored
Add CSV file support for complexity analysis (#6)
1 parent ca3bfda commit d1c8568

File tree

4 files changed

+321
-4
lines changed

4 files changed

+321
-4
lines changed

grit/cmd/stat/subcommands/complexity.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,18 @@ var ComplexityCmd = &cobra.Command{ //nolint:exhaustruct // no need to set all f
2525
Short: "Finds the most complex files",
2626
Args: cobra.ExactArgs(1),
2727
RunE: func(_ *cobra.Command, args []string) error {
28-
repoPath, err := filepath.Abs(args[0])
28+
path, err := filepath.Abs(args[0])
2929
if err != nil {
3030
return fmt.Errorf("error getting absolute path: %w", err)
3131
}
3232

33-
flag.LogIfVerbose("Processing repository: %s\n", repoPath)
33+
flag.LogIfVerbose("Processing repository: %s\n", path)
3434

3535
if err := complexity.PopulateOpts(&complexityOpts, excludeComplexityRegex); err != nil {
3636
return fmt.Errorf("failed to create options: %w", err)
3737
}
3838

39-
fileStat, err := complexity.RunComplexity(repoPath, &complexityOpts)
39+
fileStat, err := complexity.RunComplexity(path, &complexityOpts)
4040
if err != nil {
4141
return fmt.Errorf("error running complexity analysis: %w", err)
4242
}
@@ -51,7 +51,10 @@ func init() {
5151
flags := ComplexityCmd.PersistentFlags()
5252

5353
flags.StringVarP(&complexityOpts.Engine, flag.LongEngine, flag.ShortEngine, complexity.Gocyclo,
54-
fmt.Sprintf("Specify complexity calculation engine: [%s, %s]", complexity.Gocyclo, complexity.Gocognit))
54+
fmt.Sprintf(`Specify complexity calculation engine: [%s, %s, %s].
55+
When CSV engine is chosen, GRIT will try to read function complexity data from CSV file specified by <path> parameter.
56+
The file should have following fields: "filename,function,complexity,line-count (optional),packages (optional)"
57+
`, complexity.Gocyclo, complexity.Gocognit, complexity.CSV))
5558
flags.IntVarP(&complexityOpts.Top, flag.LongTop, flag.ShortTop, git.DefaultTop, "Number of top files to display")
5659
flags.BoolVarP(&flag.Verbose, flag.LongVerbose, flag.ShortVerbose, false, "Show detailed progress")
5760
flags.StringVar(&excludeComplexityRegex, flag.LongExclude, "", "Exclude files matching regex pattern")

pkg/complexity/csv.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package complexity
2+
3+
import (
4+
"encoding/csv"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
func RunCSV(filepath string, opts *Options) ([]*FileStat, error) {
14+
file, err := os.Open(filepath)
15+
if err != nil {
16+
return nil, fmt.Errorf("failed to open CSV file at %s: %w", filepath, err)
17+
}
18+
defer file.Close()
19+
20+
functionStats, err := readComplexityFromCSV(file)
21+
if err != nil {
22+
return nil, fmt.Errorf("failed to parse complexity data from CSV: %w", err)
23+
}
24+
25+
fileMap := make(map[string][]FunctionStat)
26+
for _, stat := range functionStats {
27+
fileMap[stat.File] = append(fileMap[stat.File], *stat)
28+
}
29+
30+
result := make([]*FileStat, 0, len(fileMap))
31+
32+
for file, functions := range fileMap {
33+
if opts.ExcludeRegex != nil && opts.ExcludeRegex.MatchString(file) {
34+
continue
35+
}
36+
37+
result = append(result, &FileStat{
38+
Path: file,
39+
Functions: functions,
40+
})
41+
}
42+
43+
// Calculate average complexity for each file
44+
AvgComplexity(result)
45+
46+
return result, nil
47+
}
48+
49+
const minimalColumns = 4
50+
51+
func readComplexityFromCSV(r io.Reader) ([]*FunctionStat, error) { //nolint:cyclop // complexity is not a problem here
52+
csvReader := csv.NewReader(r)
53+
csvReader.FieldsPerRecord = -1 // Allow variable number of fields per record
54+
55+
records, err := csvReader.ReadAll()
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to read CSV data: %w", err)
58+
}
59+
60+
if len(records) == 0 {
61+
return nil, errors.New("CSV data is empty")
62+
}
63+
64+
result := make([]*FunctionStat, 0, len(records))
65+
66+
for pos, record := range records {
67+
// Minimum required fields: filename, function, length, complexity
68+
if len(record) < minimalColumns {
69+
return nil, fmt.Errorf("row %d: insufficient columns, expected at least 4", pos+1)
70+
}
71+
72+
stat := &FunctionStat{
73+
File: record[0],
74+
Name: record[1],
75+
}
76+
77+
length, err := strconv.Atoi(record[2])
78+
if err != nil {
79+
return nil, fmt.Errorf("row %d: invalid length value '%s': %w", pos+1, record[2], err)
80+
}
81+
82+
stat.Length = length
83+
84+
complexity, err := strconv.Atoi(record[3])
85+
if err != nil {
86+
return nil, fmt.Errorf("row %d: invalid complexity value '%s': %w", pos+1, record[3], err)
87+
}
88+
89+
stat.Complexity = complexity
90+
91+
// Optional: line number
92+
if len(record) > 4 && record[4] != "" {
93+
line, err := strconv.Atoi(record[4])
94+
if err != nil {
95+
return nil, fmt.Errorf("row %d: invalid line value '%s': %w", pos+1, record[4], err)
96+
}
97+
98+
stat.Line = line
99+
}
100+
101+
// Optional: packages
102+
if len(record) > 5 && record[5] != "" {
103+
stat.Package = strings.Split(record[5], ";")
104+
}
105+
106+
result = append(result, stat)
107+
}
108+
109+
return result, nil
110+
}

pkg/complexity/csv_test.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package complexity
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"regexp"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestReadComplexityFromCSV(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
csv string
18+
want []*FunctionStat
19+
wantErr bool
20+
}{
21+
{
22+
name: "valid csv with all fields",
23+
csv: `filename.go,Calculate,120,15,42,pkg1;pkg2
24+
anothefile.go,Process,80,8,123,main`,
25+
want: []*FunctionStat{
26+
{
27+
File: "filename.go",
28+
Name: "Calculate",
29+
Length: 120,
30+
Complexity: 15,
31+
Line: 42,
32+
Package: []string{"pkg1", "pkg2"},
33+
},
34+
{
35+
File: "anothefile.go",
36+
Name: "Process",
37+
Length: 80,
38+
Complexity: 8,
39+
Line: 123,
40+
Package: []string{"main"},
41+
},
42+
},
43+
wantErr: false,
44+
},
45+
{
46+
name: "valid csv with header row",
47+
csv: `filename.go,Calculate,120,15,42,pkg1;pkg2`,
48+
want: []*FunctionStat{
49+
{
50+
File: "filename.go",
51+
Name: "Calculate",
52+
Length: 120,
53+
Complexity: 15,
54+
Line: 42,
55+
Package: []string{"pkg1", "pkg2"},
56+
},
57+
},
58+
wantErr: false,
59+
},
60+
{
61+
name: "valid csv with minimum required fields",
62+
csv: `filename.go,Calculate,120,15
63+
anothefile.go,Process,80,8`,
64+
want: []*FunctionStat{
65+
{
66+
File: "filename.go",
67+
Name: "Calculate",
68+
Length: 120,
69+
Complexity: 15,
70+
},
71+
{
72+
File: "anothefile.go",
73+
Name: "Process",
74+
Length: 80,
75+
Complexity: 8,
76+
},
77+
},
78+
wantErr: false,
79+
},
80+
{
81+
name: "valid csv with mixed field counts",
82+
csv: `filename.go,Calculate,120,15,42
83+
anothefile.go,Process,80,8,,main`,
84+
want: []*FunctionStat{
85+
{
86+
File: "filename.go",
87+
Name: "Calculate",
88+
Length: 120,
89+
Complexity: 15,
90+
Line: 42,
91+
},
92+
{
93+
File: "anothefile.go",
94+
Name: "Process",
95+
Length: 80,
96+
Complexity: 8,
97+
Package: []string{"main"},
98+
},
99+
},
100+
wantErr: false,
101+
},
102+
{
103+
name: "empty csv",
104+
csv: "",
105+
want: nil,
106+
wantErr: true,
107+
},
108+
{
109+
name: "insufficient columns",
110+
csv: "filename.go,Calculate,120",
111+
want: nil,
112+
wantErr: true,
113+
},
114+
{
115+
name: "invalid length value",
116+
csv: "filename.go,Calculate,invalid,15",
117+
want: nil,
118+
wantErr: true,
119+
},
120+
{
121+
name: "invalid complexity value",
122+
csv: "filename.go,Calculate,120,invalid",
123+
want: nil,
124+
wantErr: true,
125+
},
126+
{
127+
name: "invalid line value",
128+
csv: "filename.go,Calculate,120,15,invalid",
129+
want: nil,
130+
wantErr: true,
131+
},
132+
}
133+
134+
for _, tt := range tests {
135+
t.Run(tt.name, func(t *testing.T) {
136+
reader := strings.NewReader(tt.csv)
137+
got, err := readComplexityFromCSV(reader)
138+
139+
if tt.wantErr {
140+
assert.Error(t, err)
141+
142+
return
143+
}
144+
145+
require.NoError(t, err)
146+
assert.Equal(t, tt.want, got)
147+
})
148+
}
149+
}
150+
151+
func TestRunCSV(t *testing.T) {
152+
csvContent := `file1.go,func1,50,5,10,pkg1
153+
file1.go,func2,70,10,20,pkg1
154+
file2.go,func3,30,3,15,pkg2
155+
file3.go,func4,100,15,25,pkg3;pkg4`
156+
157+
csvPath := filepath.Join(t.TempDir(), "complexity.csv")
158+
err := os.WriteFile(csvPath, []byte(csvContent), 0o600)
159+
160+
require.NoError(t, err)
161+
162+
opts := &Options{
163+
Engine: CSV,
164+
Top: 10,
165+
}
166+
167+
results, err := RunCSV(csvPath, opts)
168+
require.NoError(t, err)
169+
assert.Len(t, results, 3)
170+
171+
expectedFiles := map[string]struct {
172+
FunctionCount int
173+
AvgComplexity float64
174+
}{
175+
"file1.go": {FunctionCount: 2, AvgComplexity: 7.5}, // (5+10)/2 = 7.5
176+
"file2.go": {FunctionCount: 1, AvgComplexity: 3.0},
177+
"file3.go": {FunctionCount: 1, AvgComplexity: 15.0},
178+
}
179+
180+
for _, file := range results {
181+
expected, exists := expectedFiles[file.Path]
182+
assert.True(t, exists, "Unexpected file in results: %s", file.Path)
183+
184+
if exists {
185+
assert.Len(t, file.Functions, expected.FunctionCount, "Incorrect function count for %s", file.Path)
186+
assert.InEpsilon(t, expected.AvgComplexity, file.AvgComplexity, 0.001,
187+
"Incorrect average complexity for %s", file.Path)
188+
}
189+
}
190+
191+
// Test with exclude regex
192+
excludeOpts := &Options{
193+
Engine: CSV,
194+
ExcludeRegex: regexp.MustCompile(`file[12]\.go`),
195+
}
196+
197+
filteredResults, err := RunCSV(csvPath, excludeOpts)
198+
require.NoError(t, err)
199+
assert.Len(t, filteredResults, 1)
200+
assert.Equal(t, "file3.go", filteredResults[0].Path)
201+
}

pkg/complexity/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type Engine = string
1212
const (
1313
Gocyclo = "gocyclo"
1414
Gocognit = "gocognit"
15+
CSV = "csv-file"
1516
)
1617

1718
type FileStat struct {
@@ -57,6 +58,8 @@ func RunComplexity(repoPath string, opts *Options) ([]*FileStat, error) {
5758
return RunGocyclo(repoPath, opts)
5859
case Gocognit:
5960
return RunGocognit(repoPath, opts)
61+
case CSV:
62+
return RunCSV(repoPath, opts)
6063
default:
6164
return nil, ErrUnsupportedEngine
6265
}

0 commit comments

Comments
 (0)