Files
natsnet/tools/go-analyzer/analyzer.go
2026-02-26 06:11:06 -05:00

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, ".")
}