feat(go-analyzer): add AST parsing and analysis engine
This commit is contained in:
344
tools/go-analyzer/analyzer.go
Normal file
344
tools/go-analyzer/analyzer.go
Normal file
@@ -0,0 +1,344 @@
|
||||
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, ".")
|
||||
}
|
||||
Reference in New Issue
Block a user