diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml index e7d019d..f099a21 100644 --- a/.github/workflows/golang.yml +++ b/.github/workflows/golang.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4.2.2 - uses: schubergphilis/mcvs-golang-action@v0.9.4 with: - code-coverage-expected: 62.3 + code-coverage-expected: 84.9 gci: "false" golang-unit-tests-exclusions: |- \(cmd\/prolayout\) diff --git a/.gitignore b/.gitignore index b5b4faa..f4a1928 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .task .vscode +coverage.html +functioncoverage.out +profile.cov reports diff --git a/.golangci.yml b/.golangci.yml index b0dfc56..69e2dec 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -130,6 +130,7 @@ linters: - nilnil - nlreturn - paralleltest + - testpackage - varnamelen - wsl @@ -147,14 +148,16 @@ issues: - path: _test\.go linters: - funlen - - linters: - staticcheck text: "SA9003:" - - linters: - lll text: "^//go:generate " + - linters: + - revive + path: internal/analyzer/analyzer.go + text: "cyclomatic: function \\(\\*runner\\).assessDir has cyclomatic complexity 8 \\(\\> max enabled 7\\)" exclude-use-default: false diff --git a/.prolayout.yml b/.prolayout.yml index 491db75..08530dc 100644 --- a/.prolayout.yml +++ b/.prolayout.yml @@ -2,3 +2,5 @@ module: "github.com/wimspaargaren/prolayout" root: - name: "bar" + - name: "internal" + - name: "tests" diff --git a/README.md b/README.md index abb3a03..9469260 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ fashion. ## Example configuration file -```YAML +```yml module: "github.com/wimspaargaren/prolayout" root: - name: "cmd" @@ -35,3 +35,5 @@ root: files: - name: ".*_test.go" ``` + +and run `prolayout ./...` diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 38be6a0..d71acf3 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -58,10 +58,7 @@ func (r *runner) assessDir(pass *analysis.Pass) (*model.Dir, error) { dir := &model.Dir{} for _, folder := range packageSplittedPerFolder { - if len(dirs) == 0 { - return nil, nil - } - if strings.HasSuffix(folder, ".test") { + if len(dirs) == 0 || strings.HasSuffix(folder, ".test") { return nil, nil } @@ -70,12 +67,16 @@ func (r *runner) assessDir(pass *analysis.Pass) (*model.Dir, error) { return nil, err } if !ok { + if len(pass.Files) == 0 || packagePathWithoutModule == "" { + continue + } pass.ReportRangef(pass.Files[0], "package not allowed: %s, %s not found in allowed names: [%s]", packagePathWithoutModule, folder, strings.Join(dirsNames(dirs), ",")) break } dir = res dirs = res.Dirs } + return dir, nil } diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go new file mode 100644 index 0000000..5548b76 --- /dev/null +++ b/internal/analyzer/analyzer_test.go @@ -0,0 +1,253 @@ +package analyzer + +import ( + "go/types" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/analysis" + + "github.com/wimspaargaren/prolayout/internal/errors" + "github.com/wimspaargaren/prolayout/internal/model" +) + +func TestAssessDir(t *testing.T) { + testCases := []struct { + name string + pass *analysis.Pass + expected *model.Dir + hasError bool + }{ + { + name: "if folder contains a '.test' suffix, then skip assessDir and return nil twice", + pass: &analysis.Pass{ + Pkg: types.NewPackage("github.com/wimspaargaren/prolayout/tests.test", "main"), + }, + expected: nil, + hasError: false, + }, + { + name: "if folder contains a '.something' suffix, then loop through and return 'dir *model.Dir' at the end", + pass: &analysis.Pass{ + Pkg: types.NewPackage("github.com/wimspaargaren/prolayout/tests.something", "main"), + }, + expected: &model.Dir{Name: "", Files: []*model.File(nil), Dirs: []*model.Dir(nil)}, + hasError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := &runner{Root: model.Root{Root: []*model.Dir{{Name: "internal"}}}} + dir, err := r.assessDir(tc.pass) + if tc.hasError { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, dir) + }) + } +} + +func TestDirsNames(t *testing.T) { + testCases := []struct { + name string + dirs []*model.Dir + expected []string + }{ + { + name: "multiple directories", + dirs: []*model.Dir{ + {Name: "dir1"}, + {Name: "dir2"}, + {Name: "dir3"}, + }, + expected: []string{"dir1", "dir2", "dir3"}, + }, + { + name: "single directory", + dirs: []*model.Dir{ + {Name: "dir1"}, + }, + expected: []string{"dir1"}, + }, + { + name: "no directories", + dirs: []*model.Dir{}, + expected: []string{}, + }, + { + name: "directories with empty names", + dirs: []*model.Dir{ + {Name: ""}, + {Name: ""}, + }, + expected: []string{"", ""}, + }, + { + name: "nil directory slice", + dirs: nil, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := dirsNames(tc.dirs) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestMatchFiles(t *testing.T) { + r := &runner{} + + testCases := []struct { + name string + files []*model.File + input string + expectedOk bool + expectedErr error + }{ + { + name: "valid match", + files: []*model.File{ + {Name: "^file1.go$"}, + {Name: "^file2.go$"}, + }, + input: "file2", + expectedOk: true, + expectedErr: nil, + }, + { + name: "no match", + files: []*model.File{ + {Name: "^file1$"}, + {Name: "^file2$"}, + }, + input: "file3", + expectedOk: false, + expectedErr: nil, + }, + { + name: "invalid regular expression", + files: []*model.File{ + {Name: "file1["}, // invalid regex + }, + input: "file1", + expectedOk: false, + expectedErr: errors.ErrInvalidFileNameRegex{FileName: "file1["}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ok, err := r.matchFiles(tc.files, tc.input) + + if tc.expectedErr != nil { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedOk, ok) + }) + } +} + +func TestMatchDir(t *testing.T) { + testCases := []struct { + name string + dirs []*model.Dir + input string + expectedDir *model.Dir + expectedOk bool + expectedErr error + }{ + { + name: "valid match", + dirs: []*model.Dir{ + {Name: "^dir1$"}, + {Name: "^dir2$"}, + }, + input: "dir1", + expectedDir: &model.Dir{Name: "^dir1$"}, + expectedOk: true, + expectedErr: nil, + }, + { + name: "no match", + dirs: []*model.Dir{ + {Name: "^dir1$"}, + {Name: "^dir2$"}, + }, + input: "dir3", + expectedDir: nil, + expectedOk: false, + expectedErr: nil, + }, + { + name: "invalid regular expression", + dirs: []*model.Dir{ + {Name: "dir1["}, // invalid regex + }, + input: "dir1", + expectedDir: nil, + expectedOk: false, + expectedErr: errors.ErrInvalidDirNameRegex{DirName: "dir1["}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dir, ok, err := matchDir(tc.dirs, tc.input) + + if tc.expectedErr != nil { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, err) + return + } + require.NoError(t, err) + + assert.Equal(t, tc.expectedOk, ok) + assert.Equal(t, tc.expectedDir, dir) + }) + } +} + +func TestSplitPath(t *testing.T) { + testCases := []struct { + path string + expected []string + }{ + { + path: "dir1/dir2/file.txt", + expected: []string{"dir1", "dir2", "file.txt"}, + }, + { + path: "singlefile", + expected: []string{"singlefile"}, + }, + { + path: "dir1/dir2/dir3/", + expected: []string{"dir1", "dir2", "dir3", ""}, + }, + { + path: "/leading/slash", + expected: []string{"", "leading", "slash"}, + }, + { + path: "", + expected: []string{""}, + }, + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + result := splitPath(tc.path) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..bcb4d68 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,53 @@ +package errors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrInvalidFileNameRegex_Error(t *testing.T) { + testCases := []struct { + fileName string + expected string + }{ + { + fileName: "invalid[file].txt", + expected: "file name \"invalid[file].txt\" is not a valid regular expression", + }, + { + fileName: "validfile.txt", + expected: "file name \"validfile.txt\" is not a valid regular expression", + }, + } + + for _, tc := range testCases { + t.Run(tc.fileName, func(t *testing.T) { + err := ErrInvalidFileNameRegex{FileName: tc.fileName} + assert.Equal(t, tc.expected, err.Error()) + }) + } +} + +func TestErrInvalidDirNameRegex_Error(t *testing.T) { + testCases := []struct { + dirName string + expected string + }{ + { + dirName: "invalid[dir]", + expected: "directory name \"invalid[dir]\" is not a valid regular expression", + }, + { + dirName: "validdir", + expected: "directory name \"validdir\" is not a valid regular expression", + }, + } + + for _, tc := range testCases { + t.Run(tc.dirName, func(t *testing.T) { + err := ErrInvalidDirNameRegex{DirName: tc.dirName} + assert.Equal(t, tc.expected, err.Error()) + }) + } +} diff --git a/main.go b/main.go index 5011e0f..f07663d 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,10 @@ package main import ( + "fmt" "log" "os" + "path/filepath" "golang.org/x/tools/go/analysis/singlechecker" "gopkg.in/yaml.v3" @@ -12,15 +14,27 @@ import ( "github.com/wimspaargaren/prolayout/internal/model" ) -func main() { - data, err := os.ReadFile(".prolayout.yml") +const proLayoutFile = ".prolayout.yml" + +func readAndUnmarshalProLayoutYML(proLayoutFile string) (*model.Root, error) { + data, err := os.ReadFile(filepath.Clean(proLayoutFile)) if err != nil { - log.Fatalf("error: %v", err) + return nil, fmt.Errorf("'%w'", err) } t := model.Root{} err = yaml.Unmarshal(data, &t) if err != nil { - log.Fatalf("error: %v", err) + return nil, fmt.Errorf("'%w'", err) } - singlechecker.Main(analyzer.New(t)) + + return &t, nil +} + +func main() { + unmarshalledProLayoutYML, err := readAndUnmarshalProLayoutYML(proLayoutFile) + if err != nil { + log.Fatalf("failed to unmarshal '%s': '%v'", proLayoutFile, err) + } + + singlechecker.Main(analyzer.New(*unmarshalledProLayoutYML)) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..89d119c --- /dev/null +++ b/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wimspaargaren/prolayout/internal/model" +) + +func TestReadAndUnmarshalProLayoutYML(t *testing.T) { + exp := model.Root{ + Module: "github.com/wimspaargaren/prolayout", + Root: []*model.Dir{{Name: "bar"}, {Name: "internal"}, {Name: "tests"}}, + } + act, err := readAndUnmarshalProLayoutYML(proLayoutFile) + require.NoError(t, err) + assert.Equal(t, exp, *act) + + act, err = readAndUnmarshalProLayoutYML(proLayoutFile + "-does-not-exist") + require.Empty(t, act) + require.EqualError(t, err, "'open .prolayout.yml-does-not-exist: no such file or directory'") + + dir := t.TempDir() + createTempFile := func(t *testing.T, dir, name, content string) string { + t.Helper() + + filePath := filepath.Join(dir, name) + err := os.WriteFile(filePath, []byte(content), 0o600) + if err != nil { + t.Fatalf("Failed to create temporary file: %v", err) + } + return filePath + } + filePath := createTempFile(t, dir, "layout.yml", "name: Example\nversion: 1.0.0\ninvalid field") + result, err := readAndUnmarshalProLayoutYML(filePath) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not find expected ':''") +}