345 lines
8.3 KiB
Go
345 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Analyzer parses Go source code and extracts structural information.
|
|
type Analyzer struct {
|
|
sourceDir string
|
|
fset *token.FileSet
|
|
}
|
|
|
|
// NewAnalyzer creates a new Analyzer for the given source directory.
|
|
func NewAnalyzer(sourceDir string) *Analyzer {
|
|
return &Analyzer{
|
|
sourceDir: sourceDir,
|
|
fset: token.NewFileSet(),
|
|
}
|
|
}
|
|
|
|
// Analyze runs the full analysis pipeline.
|
|
func (a *Analyzer) Analyze() (*AnalysisResult, error) {
|
|
serverDir := filepath.Join(a.sourceDir, "server")
|
|
|
|
// 1. Discover all Go files grouped by directory
|
|
fileGroups, err := a.discoverFiles(serverDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discovering files: %w", err)
|
|
}
|
|
|
|
// 2. Parse each group into modules
|
|
result := &AnalysisResult{}
|
|
allImports := make(map[string]*ImportInfo)
|
|
|
|
for dir, files := range fileGroups {
|
|
module, imports, err := a.parseModule(dir, files)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing module %s: %w", dir, err)
|
|
}
|
|
result.Modules = append(result.Modules, *module)
|
|
for _, imp := range imports {
|
|
if existing, ok := allImports[imp.ImportPath]; ok {
|
|
existing.UsedInFiles = append(existing.UsedInFiles, imp.UsedInFiles...)
|
|
} else {
|
|
allImports[imp.ImportPath] = &imp
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Build module-level dependencies from import analysis
|
|
result.Dependencies = a.buildDependencies(result.Modules)
|
|
|
|
// 4. Collect imports
|
|
for _, imp := range allImports {
|
|
result.Imports = append(result.Imports, *imp)
|
|
}
|
|
sort.Slice(result.Imports, func(i, j int) bool {
|
|
return result.Imports[i].ImportPath < result.Imports[j].ImportPath
|
|
})
|
|
|
|
// Sort modules by name
|
|
sort.Slice(result.Modules, func(i, j int) bool {
|
|
return result.Modules[i].Name < result.Modules[j].Name
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// discoverFiles walks the source tree and groups .go files by directory.
|
|
func (a *Analyzer) discoverFiles(root string) (map[string][]string, error) {
|
|
groups := make(map[string][]string)
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
if info.Name() == "configs" || info.Name() == "testdata" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(info.Name(), ".go") {
|
|
return nil
|
|
}
|
|
dir := filepath.Dir(path)
|
|
groups[dir] = append(groups[dir], path)
|
|
return nil
|
|
})
|
|
return groups, err
|
|
}
|
|
|
|
// parseModule parses all Go files in a directory into a Module.
|
|
func (a *Analyzer) parseModule(dir string, files []string) (*Module, []ImportInfo, error) {
|
|
moduleName := a.moduleNameFromDir(dir)
|
|
|
|
module := &Module{
|
|
Name: moduleName,
|
|
GoPackage: moduleName,
|
|
GoFile: dir,
|
|
}
|
|
|
|
var sourceFiles []string
|
|
var testFiles []string
|
|
for _, f := range files {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
testFiles = append(testFiles, f)
|
|
} else {
|
|
sourceFiles = append(sourceFiles, f)
|
|
}
|
|
}
|
|
|
|
var allImports []ImportInfo
|
|
totalLines := 0
|
|
|
|
for _, f := range sourceFiles {
|
|
features, imports, lines, err := a.parseSourceFile(f)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: skipping %s: %v\n", f, err)
|
|
continue
|
|
}
|
|
module.Features = append(module.Features, features...)
|
|
allImports = append(allImports, imports...)
|
|
totalLines += lines
|
|
}
|
|
|
|
for _, f := range testFiles {
|
|
tests, _, lines, err := a.parseTestFile(f)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: skipping test %s: %v\n", f, err)
|
|
continue
|
|
}
|
|
module.Tests = append(module.Tests, tests...)
|
|
totalLines += lines
|
|
}
|
|
|
|
module.GoLineCount = totalLines
|
|
return module, allImports, nil
|
|
}
|
|
|
|
// parseSourceFile extracts functions, methods, and imports from a Go source file.
|
|
func (a *Analyzer) parseSourceFile(filePath string) ([]Feature, []ImportInfo, int, error) {
|
|
src, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
|
|
file, err := parser.ParseFile(a.fset, filePath, src, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
|
|
lines := strings.Count(string(src), "\n") + 1
|
|
relPath := a.relPath(filePath)
|
|
|
|
var features []Feature
|
|
var imports []ImportInfo
|
|
|
|
for _, imp := range file.Imports {
|
|
path := strings.Trim(imp.Path.Value, "\"")
|
|
imports = append(imports, ImportInfo{
|
|
ImportPath: path,
|
|
IsStdlib: isStdlib(path),
|
|
UsedInFiles: []string{relPath},
|
|
})
|
|
}
|
|
|
|
for _, decl := range file.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
feature := Feature{
|
|
Name: fn.Name.Name,
|
|
GoFile: relPath,
|
|
GoMethod: fn.Name.Name,
|
|
GoLineNumber: a.fset.Position(fn.Pos()).Line,
|
|
}
|
|
|
|
startLine := a.fset.Position(fn.Pos()).Line
|
|
endLine := a.fset.Position(fn.End()).Line
|
|
feature.GoLineCount = endLine - startLine + 1
|
|
|
|
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
|
feature.GoClass = a.receiverTypeName(fn.Recv.List[0].Type)
|
|
feature.Name = feature.GoClass + "." + fn.Name.Name
|
|
}
|
|
|
|
if fn.Doc != nil {
|
|
feature.Description = strings.TrimSpace(fn.Doc.Text())
|
|
}
|
|
|
|
features = append(features, feature)
|
|
}
|
|
|
|
return features, imports, lines, nil
|
|
}
|
|
|
|
// parseTestFile extracts test functions from a Go test file.
|
|
func (a *Analyzer) parseTestFile(filePath string) ([]TestFunc, []ImportInfo, int, error) {
|
|
src, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
|
|
file, err := parser.ParseFile(a.fset, filePath, src, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
|
|
lines := strings.Count(string(src), "\n") + 1
|
|
relPath := a.relPath(filePath)
|
|
|
|
var tests []TestFunc
|
|
var imports []ImportInfo
|
|
|
|
for _, imp := range file.Imports {
|
|
path := strings.Trim(imp.Path.Value, "\"")
|
|
imports = append(imports, ImportInfo{
|
|
ImportPath: path,
|
|
IsStdlib: isStdlib(path),
|
|
UsedInFiles: []string{relPath},
|
|
})
|
|
}
|
|
|
|
for _, decl := range file.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
name := fn.Name.Name
|
|
if !strings.HasPrefix(name, "Test") && !strings.HasPrefix(name, "Benchmark") {
|
|
continue
|
|
}
|
|
|
|
startLine := a.fset.Position(fn.Pos()).Line
|
|
endLine := a.fset.Position(fn.End()).Line
|
|
|
|
test := TestFunc{
|
|
Name: name,
|
|
GoFile: relPath,
|
|
GoMethod: name,
|
|
GoLineNumber: startLine,
|
|
GoLineCount: endLine - startLine + 1,
|
|
}
|
|
|
|
if fn.Doc != nil {
|
|
test.Description = strings.TrimSpace(fn.Doc.Text())
|
|
}
|
|
|
|
test.FeatureName = a.inferFeatureName(name)
|
|
tests = append(tests, test)
|
|
}
|
|
|
|
return tests, imports, lines, nil
|
|
}
|
|
|
|
// buildDependencies creates module-level dependencies based on cross-package imports.
|
|
func (a *Analyzer) buildDependencies(modules []Module) []Dependency {
|
|
pkgToModule := make(map[string]string)
|
|
for _, m := range modules {
|
|
pkgToModule[m.GoPackage] = m.Name
|
|
}
|
|
|
|
var deps []Dependency
|
|
for _, m := range modules {
|
|
if m.Name != "server" && m.GoPackage != "server" {
|
|
deps = append(deps, Dependency{
|
|
SourceModule: "server",
|
|
TargetModule: m.Name,
|
|
DependencyKind: "calls",
|
|
})
|
|
}
|
|
}
|
|
|
|
return deps
|
|
}
|
|
|
|
// moduleNameFromDir converts a directory path to a module name.
|
|
func (a *Analyzer) moduleNameFromDir(dir string) string {
|
|
base := filepath.Base(dir)
|
|
if base == "server" {
|
|
return "server"
|
|
}
|
|
return base
|
|
}
|
|
|
|
// relPath returns a path relative to the analyzer's source directory.
|
|
func (a *Analyzer) relPath(absPath string) string {
|
|
rel, err := filepath.Rel(a.sourceDir, absPath)
|
|
if err != nil {
|
|
return absPath
|
|
}
|
|
return rel
|
|
}
|
|
|
|
// receiverTypeName extracts the type name from a method receiver.
|
|
func (a *Analyzer) receiverTypeName(expr ast.Expr) string {
|
|
switch t := expr.(type) {
|
|
case *ast.StarExpr:
|
|
return a.receiverTypeName(t.X)
|
|
case *ast.Ident:
|
|
return t.Name
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// inferFeatureName attempts to derive a feature name from a test name.
|
|
func (a *Analyzer) inferFeatureName(testName string) string {
|
|
name := testName
|
|
for _, prefix := range []string{"Test", "Benchmark"} {
|
|
if strings.HasPrefix(name, prefix) {
|
|
name = strings.TrimPrefix(name, prefix)
|
|
break
|
|
}
|
|
}
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(name, "_"); idx > 0 {
|
|
name = name[:idx] + "." + name[idx+1:]
|
|
}
|
|
return name
|
|
}
|
|
|
|
// isStdlib checks if an import path is a Go standard library package.
|
|
func isStdlib(importPath string) bool {
|
|
firstSlash := strings.Index(importPath, "/")
|
|
var first string
|
|
if firstSlash < 0 {
|
|
first = importPath
|
|
} else {
|
|
first = importPath[:firstSlash]
|
|
}
|
|
return !strings.Contains(first, ".")
|
|
}
|