diff --git a/cmd/analyze.go b/cmd/analyze.go new file mode 100644 index 0000000..5c2a531 --- /dev/null +++ b/cmd/analyze.go @@ -0,0 +1,181 @@ +/* +Copyright © 2024 Alessio Greggi + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/alegrey91/harpoon/internal/analyzer" + "github.com/alegrey91/harpoon/internal/executor" + "github.com/alegrey91/harpoon/internal/metadata" + "github.com/spf13/cobra" +) + +var excludedPaths []string +var exclude string +var saveAnalysis bool + +// captureCmd represents the create args +var analyzeCmd = &cobra.Command{ + Use: "analyze", + Short: "Analyze infers the symbols of functions that are tested by unit-tests", + Long: ` +`, + Example: " harpoon analyze --exclude vendor/ /path/to/repo/", + Run: func(cmd *cobra.Command, args []string) { + if exclude != "" { + excludedPaths = strings.Split(exclude, ",") + } + + file, err := os.Open("go.mod") + if err != nil { + fmt.Printf("failed to open %s: %v\n", "go.mod", err) + return + } + defer file.Close() + + moduleName, err := analyzer.GetModuleName(file) + if err != nil { + fmt.Println("module name not found in go.mod") + return + } + + symbolsList := metadata.NewSymbolsList() + + // Walk the directory to find all _test.go files + err = filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error walking filesystem: %v", err) + } + fmt.Printf("analyzing file: %s\n", path) + + if shouldSkipPath(path) { + fmt.Println("file was skipped") + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") { + fmt.Println("analyzing symbols") + symbolNames, err := analyzer.AnalyzeTestFile(moduleName, path) + if err != nil { + return fmt.Errorf("unable to infer symbols from test file: %s", path) + } + + fmt.Println("building test binary") + // build test binary + os.Mkdir(".harpoon", 0644) + pkgPath := getPackagePath(path) + testFile := filepath.Base(path) + testFile = strings.ReplaceAll(testFile, "_test.go", ".test") + _, err = executor.Build(pkgPath, ".harpoon/"+testFile) + if err != nil { + return fmt.Errorf("failed to build test file: %v", err) + } + + symbolsOrig := metadata.NewSymbolsOrigin(".harpoon/" + testFile) + + fmt.Println("test: .harpoon/" + testFile) + for _, symbol := range symbolNames { + // retrieve tested function from symbol + parts := strings.Split(symbol, ".") + testedFunction := parts[len(parts)-1] + + // retrieve source file from _test.go file + sourceFile := strings.ReplaceAll(path, "_test", "") + file, err := os.Open(sourceFile) + if err != nil { + return fmt.Errorf("failed to open %s: %v", sourceFile, err) + } + defer file.Close() + + functionExists, err := analyzer.CheckFunctionExists(testedFunction, file) + if !functionExists { + fmt.Printf("function not found: %v\n", err) + continue + } + symbolsOrig.Add(symbol) + } + if !symbolsOrig.IsEmpty() { + symbolsList.Add(symbolsOrig) + } + } + return nil + }) + + if err != nil { + fmt.Printf("error walking the path: %v\n", err) + } + + // store to file + file, err = os.Create(".harpoon.yml") + if err != nil { + fmt.Printf("failed to create symbols list file") + return + } + mw := io.Writer(file) + fmt.Fprintln(mw, symbolsList.String()) + fmt.Println("file .harpoon.yml is ready") + }, +} + +func init() { + rootCmd.AddCommand(analyzeCmd) + + analyzeCmd.Flags().StringVarP(&exclude, "exclude", "e", "", "Skip directories specified in the comma separated list") + analyzeCmd.Flags().BoolVarP(&saveAnalysis, "save", "s", false, "Save analysis in a file") +} + +func shouldSkipPath(path string) bool { + for _, excludedPath := range excludedPaths { + if strings.Contains(path, excludedPath) { + return true + } + } + return false +} + +func getPackagePath(inputPath string) string { + // Normalize the path + normalizedPath := filepath.Clean(inputPath) + + // Get the directory part of the path if it's a file path + dirPath := normalizedPath + if !strings.HasSuffix(inputPath, "/") { + dirPath = filepath.Dir(normalizedPath) + } + + // Ensure the path starts with "./" + if !strings.HasPrefix(dirPath, ".") { + dirPath = "./" + dirPath + } + + // Remove any leading "../" or "./" parts not relevant to the target directory structure + // Adjust this according to your specific requirements + dirPath = strings.TrimPrefix(dirPath, "../") + dirPath = strings.TrimPrefix(dirPath, "./") + + // Add "./" at the start again if necessary + dirPath = "./" + dirPath + + return dirPath +} diff --git a/internal/analyzer/analyze.go b/internal/analyzer/analyze.go new file mode 100644 index 0000000..ede4de8 --- /dev/null +++ b/internal/analyzer/analyze.go @@ -0,0 +1,83 @@ +package analyzer + +import ( + "bufio" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +func isTestFunction(name string) bool { + return strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Test_") +} + +func getTestedFunctionName(testName string) string { + if strings.HasPrefix(testName, "Test_") { + return testName[5:] // Remove "Test_" + } + return testName[4:] // Remove "Test" +} + +func AnalyzeTestFile(moduleName, path string) ([]string, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, path, nil, parser.AllErrors) + if err != nil { + return []string{}, fmt.Errorf("failed to parse %s: %v", path, err) + } + + // retrieve the last directory from the module name + lastDirectory := filepath.Base(filepath.Clean(moduleName)) + // remove the file from the directory + dir, _ := filepath.Split(path) + // remove the / char from the end of the path + dir = strings.TrimSuffix(dir, "/") + // remove all the ../ from the path + dir = strings.ReplaceAll(dir, "../", "") + // remove the base directory since it is already present in the module name + dir = strings.TrimPrefix(dir, lastDirectory+"/") + + var functionList []string + for _, decl := range node.Decls { + if fn, isFn := decl.(*ast.FuncDecl); isFn { + if isTestFunction(fn.Name.Name) { + testedFunction := getTestedFunctionName(fn.Name.Name) + functionList = append(functionList, fmt.Sprintf("%s/%s.%s", moduleName, dir, testedFunction)) + } + } + } + return functionList, nil +} + +func GetModuleName(goModFile *os.File) (string, error) { + scanner := bufio.NewScanner(goModFile) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error reading %s: %v", goModFile.Name(), err) + } + return "", fmt.Errorf("unable to find module in file: %s", goModFile.Name()) +} + +func CheckFunctionExists(functionName string, goFile *os.File) (bool, error) { + scanner := bufio.NewScanner(goFile) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "func "+functionName) { + return true, nil + } + } + + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("error reading %s: %v", goFile.Name(), err) + } + return false, fmt.Errorf("unable to find function \"%s\" in %s file", functionName, goFile.Name()) +} diff --git a/internal/executor/exec.go b/internal/executor/exec.go index f151812..1ff6fb1 100644 --- a/internal/executor/exec.go +++ b/internal/executor/exec.go @@ -23,3 +23,20 @@ func Run(cmd []string, cmdOutput bool, wg *sync.WaitGroup) { command.Wait() } + +func Build(packagePath, outputFile string) (string, error) { + cmd := exec.Command( + "go", + "test", + "-gcflags", "-N -l", // disable optimization + "-c", packagePath, // build test binary + "-o", outputFile, // save it in a dedicated directory + ) + stdout, err := cmd.Output() + + if err != nil { + return "", fmt.Errorf("failed to execute build command: %v", err) + } + + return string(stdout), nil +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..a559603 --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,47 @@ +package metadata + +import "fmt" + +type SymbolsList struct { + SymbolsOrigins []SymbolsOrigin +} + +func NewSymbolsList() *SymbolsList { + return &SymbolsList{} +} + +func (sl *SymbolsList) Add(so *SymbolsOrigin) { + sl.SymbolsOrigins = append(sl.SymbolsOrigins, *so) +} + +func (sl *SymbolsList) String() string { + output := "---\n" + output += "symbolsOrigins:\n" + for _, symbolsOrigin := range sl.SymbolsOrigins { + output += fmt.Sprintf(" - %s:\n", symbolsOrigin.TestBinaryPath) + output += " symbols:\n" + for _, symbol := range symbolsOrigin.Symbols { + output += fmt.Sprintf(" - %s\n", symbol) + } + } + return output +} + +type SymbolsOrigin struct { + TestBinaryPath string + Symbols []string +} + +func NewSymbolsOrigin(testBinPath string) *SymbolsOrigin { + return &SymbolsOrigin{ + TestBinaryPath: testBinPath, + } +} + +func (so *SymbolsOrigin) IsEmpty() bool { + return len(so.Symbols) == 0 +} + +func (so *SymbolsOrigin) Add(symbol string) { + so.Symbols = append(so.Symbols, symbol) +}