Skip to content

Commit

Permalink
feat: create analyze command
Browse files Browse the repository at this point in the history
Signed-off-by: Alessio Greggi <[email protected]>
  • Loading branch information
alegrey91 committed Jun 10, 2024
1 parent e95b585 commit 04f4d6c
Show file tree
Hide file tree
Showing 4 changed files with 328 additions and 0 deletions.
181 changes: 181 additions & 0 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
@@ -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
}
83 changes: 83 additions & 0 deletions internal/analyzer/analyze.go
Original file line number Diff line number Diff line change
@@ -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())
}
17 changes: 17 additions & 0 deletions internal/executor/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
47 changes: 47 additions & 0 deletions internal/metadata/metadata.go
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit 04f4d6c

Please sign in to comment.