Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project variables
BINARY_NAME=lea
VERSION=0.2.0
VERSION=0.2.1
BUILD_DIR=bin
MAIN_PATH=./cmd/lea/main.go

Expand Down
98 changes: 38 additions & 60 deletions internal/cli/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
// For "func:cmd/server:main" it returns "main".
// For "method:internal/wallet:UpdateBalance" it returns "UpdateBalance".
// For "main" it returns "main" unchanged.
func extractBaseName(input string) string {

Check failure on line 84 in internal/cli/commands/root.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

func extractBaseName is unused (unused)
// Find the last colon or dot separator
lastColon := strings.LastIndex(input, ":")
lastDot := strings.LastIndex(input, ".")
Expand All @@ -99,8 +99,7 @@
// It tries multiple strategies:
// 1. Exact match as-is
// 2. Try with func:/method:/type: prefixes
// 3. Fuzzy suffix/contains match against all node IDs
// 4. Name-based fallback: extract the base name and search by symbol name
// 3. LIKE-based fuzzy match for plain text input
func resolveSymbolID(ctx context.Context, store contracts.Store, input string) (string, error) {
// Normalize input first (strip absolute paths, runtime prefixes, module prefixes)
normalized := normalizeSymbolInput(input)
Expand All @@ -126,78 +125,57 @@
}
}

// Strategy 3: List all nodes and look for suffix/partial matches
allNodes, err := store.ListNodes(ctx)
if err != nil {
return "", fmt.Errorf("error listing nodes for fuzzy match: %w", err)
// If the user passed a prefixed URI (contains ":"), only use exact match
if strings.Contains(input, ":") {
return "", fmt.Errorf("symbol %q not found in the graph. Use 'lea symbols' to list available symbols", input)
}

// Try suffix match first: normalized input matches the end of ID
var candidates []string
for _, n := range allNodes {
if strings.HasSuffix(n.ID, ":"+normalized) || strings.HasSuffix(n.ID, "."+normalized) {
candidates = append(candidates, n.ID)
}
// Strategy 3: LIKE-based fuzzy match for plain text input
matches, err := store.SearchNodes(ctx, "%"+normalized+"%")
if err != nil {
return "", fmt.Errorf("error searching for symbol: %w", err)
}

// Try name match: normalized input matches the Name field
if len(candidates) == 0 {
for _, n := range allNodes {
if strings.EqualFold(n.Name, normalized) || strings.Contains(strings.ToLower(n.Name), strings.ToLower(normalized)) {
candidates = append(candidates, n.ID)
}
}
if len(matches) == 1 {
return matches[0].ID, nil
}

// Try contains match in ID
if len(candidates) == 0 {
lowerInput := strings.ToLower(normalized)
for _, n := range allNodes {
if strings.Contains(strings.ToLower(n.ID), lowerInput) {
candidates = append(candidates, n.ID)
if len(matches) > 1 {
// Try suffix-priority match: normalized matches after ":" or "."
var suffixCandidates []string
for _, n := range matches {
if strings.HasSuffix(n.ID, ":"+normalized) || strings.HasSuffix(n.ID, "."+normalized) {
suffixCandidates = append(suffixCandidates, n.ID)
}
}
}

if len(candidates) == 1 {
return candidates[0], nil
}
if len(candidates) > 1 {
return "", fmt.Errorf("ambiguous symbol %q, multiple matches:\n %s",
input, strings.Join(candidates, "\n "))
}

// Strategy 4: Name-based fallback — extract the base name and search by symbol Name
baseName := extractBaseName(normalized)
if baseName != "" && baseName != normalized {
var nameMatches []string
for _, n := range allNodes {
if strings.EqualFold(n.Name, baseName) {
nameMatches = append(nameMatches, n.ID)
}
if len(suffixCandidates) == 1 {
return suffixCandidates[0], nil
}
if len(nameMatches) == 1 {
fmt.Fprintf(os.Stderr, "Did you mean %q? (auto-resolved)\n", nameMatches[0])
return nameMatches[0], nil
if len(suffixCandidates) > 1 {
return "", fmt.Errorf("ambiguous symbol %q, multiple matches:\n %s",
input, strings.Join(suffixCandidates, "\n "))
}
if len(nameMatches) > 1 {
return "", fmt.Errorf("symbol %q not found. Did you mean one of these?\n %s",
input, strings.Join(nameMatches, "\n "))
var ids []string
for _, n := range matches {
ids = append(ids, n.ID)
}
return "", fmt.Errorf("ambiguous symbol %q, multiple matches:\n %s",
input, strings.Join(ids, "\n "))
}

// Strategy 5: Last resort — find any node whose name contains the base name
if baseName != "" {
var fuzzyNames []string
lowerBase := strings.ToLower(baseName)
for _, n := range allNodes {
if strings.Contains(strings.ToLower(n.Name), lowerBase) {
fuzzyNames = append(fuzzyNames, n.ID)
// Strategy 4: Name-based fallback — search by symbol Name
if err == nil {
var byName []string
for _, n := range matches {
if strings.EqualFold(n.Name, normalized) || strings.Contains(strings.ToLower(n.Name), strings.ToLower(normalized)) {
byName = append(byName, n.ID)
}
}
if len(fuzzyNames) > 0 {
if len(byName) == 1 {
fmt.Fprintf(os.Stderr, "Did you mean %q? (auto-resolved)\n", byName[0])
return byName[0], nil
}
if len(byName) > 1 {
return "", fmt.Errorf("symbol %q not found. Did you mean one of these?\n %s",
input, strings.Join(fuzzyNames, "\n "))
input, strings.Join(byName, "\n "))
}
}

Expand Down
86 changes: 47 additions & 39 deletions internal/parser/calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ import (

// CallParser extracts call graph edges and control flow edges from Go source files.
type CallParser struct {
fset *token.FileSet
reg *TypeRegistry
pkgPath string
imports map[string]string
edges []*graph.Edge
order int
fset *token.FileSet
reg *TypeRegistry
pkgPath string
imports map[string]string
moduleName string
edges []*graph.Edge
order int
}

// NewCallParser creates a new call parser.
func NewCallParser() *CallParser {
func NewCallParser(moduleName string) *CallParser {
return &CallParser{
fset: token.NewFileSet(),
fset: token.NewFileSet(),
moduleName: moduleName,
}
}

Expand Down Expand Up @@ -182,7 +184,7 @@ func (cp *CallParser) ExtractControlFlow(_ context.Context, path string) ([]*gra
}

currentFunc := cp.funcID(fn, cp.pkgPath)
walkStmtList(fn.Body.List, cp.pkgPath, currentFunc, &cp.order, nil, cp.imports, cp.reg, cp.fset, &cp.edges)
walkStmtList(fn.Body.List, cp.pkgPath, currentFunc, &cp.order, nil, cp.imports, cp.reg, cp.fset, &cp.edges, cp.moduleName)
}

return cp.edges, nil
Expand Down Expand Up @@ -215,7 +217,7 @@ func (cp *CallParser) resolveCallExpr(ce *ast.CallExpr) string {
}

// Fallback: simple resolution without registry
return resolveCallTarget(target, cp.imports, cp.pkgPath)
return resolveCallTarget(target, cp.imports, cp.pkgPath, cp.moduleName)
}

// extractImports builds an alias -> full path map from import declarations.
Expand Down Expand Up @@ -407,7 +409,7 @@ func trackAssignVarFromExpr(rhs ast.Expr, varName string, reg *TypeRegistry, imp
}

// resolveCallTarget is a fallback resolution without TypeRegistry.
func resolveCallTarget(target string, imports map[string]string, pkgPath string) string {
func resolveCallTarget(target string, imports map[string]string, pkgPath, moduleName string) string {
// Handle built-in functions (no dot, no import resolution needed)
if !strings.Contains(target, ".") {
if isBuiltinFunc(target) {
Expand All @@ -417,8 +419,14 @@ func resolveCallTarget(target string, imports map[string]string, pkgPath string)
}
parts := strings.SplitN(target, ".", 2)
if path, ok := imports[parts[0]]; ok {
// Internal module package
if moduleName != "" && strings.HasPrefix(path, moduleName) {
rel := strings.TrimPrefix(path, moduleName)
rel = strings.TrimPrefix(rel, "/")
return fmt.Sprintf("func:%s:%s", rel, parts[1])
}
// Go standard library package (fmt, os, context, etc.)
if isStdlibImport(path) {
if isStdlibImport(path, moduleName) {
return fmt.Sprintf("stdlib:%s:%s", path, parts[1])
}
return fmt.Sprintf("func:%s:%s", path, parts[1])
Expand All @@ -427,9 +435,9 @@ func resolveCallTarget(target string, imports map[string]string, pkgPath string)
}

// walkStmtList walks a list of statements to collect control flow edges.
func walkStmtList(stmts []ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, fset *token.FileSet, edges *[]*graph.Edge) {
func walkStmtList(stmts []ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, fset *token.FileSet, edges *[]*graph.Edge, moduleName string) {
for _, stmt := range stmts {
walkStmt(stmt, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges)
walkStmt(stmt, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges, moduleName)
}
}

Expand All @@ -438,7 +446,7 @@ type flowContext struct {
condition string
}

func walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, fset *token.FileSet, edges *[]*graph.Edge) {
func walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, fset *token.FileSet, edges *[]*graph.Edge, moduleName string) {
switch s := stmt.(type) {
case *ast.AssignStmt:
// Track short variable declarations like calc := &Calculator{}
Expand All @@ -447,48 +455,48 @@ func walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack [
trackAssignVarFromExpr(s.Rhs[0], ident.Name, reg, imports, pkgPath)
}
}
collectCallsFromNode(s, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges)
collectCallsFromNode(s, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges, moduleName)
case *ast.BlockStmt:
walkStmtList(s.List, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges)
walkStmtList(s.List, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges, moduleName)
case *ast.IfStmt:
ctx := append(ctxStack, flowContext{kind: "if", condition: exprString(s.Cond, fset)})
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Cond, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
collectCallsFromNode(s.Cond, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
if s.Else != nil {
walkStmt(s.Else, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmt(s.Else, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
}
case *ast.ForStmt:
ctx := append(ctxStack, flowContext{kind: "for", condition: exprString(s.Cond, fset)})
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Cond, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Post, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
collectCallsFromNode(s.Cond, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
collectCallsFromNode(s.Post, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
case *ast.RangeStmt:
ctx := append(ctxStack, flowContext{kind: "range", condition: exprString(s.X, fset)})
collectCallsFromNode(s.X, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.X, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
walkStmtList(s.Body.List, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
case *ast.SwitchStmt:
ctx := append(ctxStack, flowContext{kind: "switch", condition: exprString(s.Tag, fset)})
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Tag, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
collectCallsFromNode(s.Tag, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
for _, stmt := range s.Body.List {
clause, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
}
case *ast.TypeSwitchStmt:
ctx := append(ctxStack, flowContext{kind: "type-switch"})
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Assign, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Init, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
collectCallsFromNode(s.Assign, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
for _, stmt := range s.Body.List {
clause, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
}
case *ast.SelectStmt:
ctx := append(ctxStack, flowContext{kind: "select"})
Expand All @@ -497,20 +505,20 @@ func walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack [
if !ok {
continue
}
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
walkStmtList(clause.Body, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
}
case *ast.DeferStmt:
ctx := append(ctxStack, flowContext{kind: "defer"})
collectCallsFromNode(s.Call, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Call, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
case *ast.GoStmt:
ctx := append(ctxStack, flowContext{kind: "go"})
collectCallsFromNode(s.Call, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges)
collectCallsFromNode(s.Call, pkgPath, currentFunc, order, ctx, imports, reg, fset, edges, moduleName)
default:
collectCallsFromNode(stmt, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges)
collectCallsFromNode(stmt, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges, moduleName)
}
}

func collectCallsFromNode(node ast.Node, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, _ *token.FileSet, edges *[]*graph.Edge) {
func collectCallsFromNode(node ast.Node, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, _ *token.FileSet, edges *[]*graph.Edge, moduleName string) {
if node == nil || currentFunc == "" {
return
}
Expand All @@ -532,7 +540,7 @@ func collectCallsFromNode(node ast.Node, pkgPath, currentFunc string, order *int
targetID = reg.ResolveCallTarget(target, imports, pkgPath)
}
} else {
targetID = resolveCallTarget(target, imports, pkgPath)
targetID = resolveCallTarget(target, imports, pkgPath, moduleName)
}
if targetID == "" {
return true
Expand Down
15 changes: 9 additions & 6 deletions internal/parser/golang/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@
}

// isStdlibImport returns true if importPath belongs to Go's standard library.
// Go stdlib packages never have a dot in the first path segment (before the first "/").
func isStdlibImport(importPath string) bool {
// Paths starting with moduleName are internal module paths, not stdlib.
func isStdlibImport(importPath, moduleName string) bool {
if importPath == "" {
return false
}
if moduleName != "" && strings.HasPrefix(importPath, moduleName) {
return false
}
firstSeg := importPath
if idx := strings.Index(importPath, "/"); idx >= 0 {
firstSeg = importPath[:idx]
Expand All @@ -42,7 +45,7 @@
}

// isInternalModulePath checks if the import path belongs to the current module.
func isInternalModulePath(path, moduleName string) bool {

Check failure on line 48 in internal/parser/golang/parser.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

func isInternalModulePath is unused (unused)
return moduleName != "" && strings.HasPrefix(path, moduleName)
}

Expand Down Expand Up @@ -482,7 +485,7 @@
rel := strings.TrimPrefix(canonicalPath, p.moduleName)
rel = strings.TrimPrefix(rel, "/")
pkgPart = rel
} else if isStdlibImport(canonicalPath) {
} else if isStdlibImport(canonicalPath, p.moduleName) {
// Keep as-is for stdlib
pkgPart = canonicalPath
} else {
Expand All @@ -500,7 +503,7 @@
rel = strings.TrimPrefix(rel, "/")
return fmt.Sprintf("method:%s:%s.%s", rel, subParts[1], name)
}
if isStdlibImport(pkgPath2) {
if isStdlibImport(pkgPath2, p.moduleName) {
return fmt.Sprintf("method:%s:%s.%s", pkgPath2, subParts[1], name)
}
return fmt.Sprintf("method:%s:%s.%s", pkgPath2, subParts[1], name)
Expand All @@ -522,7 +525,7 @@
return fmt.Sprintf("func:%s:%s", relPath, name)
}
// Go standard library package (fmt, os, context, etc.)
if isStdlibImport(path) {
if isStdlibImport(path, p.moduleName) {
return fmt.Sprintf("stdlib:%s:%s", path, name)
}
// External third-party package
Expand Down Expand Up @@ -890,7 +893,7 @@
rel = strings.TrimPrefix(rel, "/")
return fmt.Sprintf("func:%s:%s", rel, methodName)
}
if isStdlibImport(path) {
if isStdlibImport(path, p.moduleName) {
return fmt.Sprintf("stdlib:%s:%s", path, methodName)
}
return fmt.Sprintf("func:%s:%s", path, methodName)
Expand Down
Loading
Loading