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
1,099 changes: 43 additions & 1,056 deletions sourcecode-parser/graph/callgraph/builder.go

Large diffs are not rendered by default.

1,011 changes: 1,011 additions & 0 deletions sourcecode-parser/graph/callgraph/builder/builder.go

Large diffs are not rendered by default.

273 changes: 273 additions & 0 deletions sourcecode-parser/graph/callgraph/builder/builder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package builder

import (
"os"
"path/filepath"
"testing"

"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph"
"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph/callgraph/core"
"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph/callgraph/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildCallGraph(t *testing.T) {
// Create a temporary project
tmpDir := t.TempDir()

mainPy := filepath.Join(tmpDir, "main.py")
err := os.WriteFile(mainPy, []byte(`
def greet(name):
return f"Hello, {name}"

def main():
message = greet("World")
print(message)
`), 0644)
require.NoError(t, err)

// Parse project
codeGraph := graph.Initialize(tmpDir)

// Build module registry
moduleRegistry, err := registry.BuildModuleRegistry(tmpDir)
require.NoError(t, err)

// Build call graph
callGraph, err := BuildCallGraph(codeGraph, moduleRegistry, tmpDir)
require.NoError(t, err)
assert.NotNil(t, callGraph)

// Verify functions were indexed
assert.NotEmpty(t, callGraph.Functions)

// Verify edges exist
assert.NotNil(t, callGraph.Edges)

// Verify reverse edges exist
assert.NotNil(t, callGraph.ReverseEdges)
}

func TestIndexFunctions(t *testing.T) {
// Create a temporary project
tmpDir := t.TempDir()

mainPy := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(mainPy, []byte(`
def func1():
pass

def func2():
pass

class MyClass:
def method1(self):
pass
`), 0644)
require.NoError(t, err)

// Parse project
codeGraph := graph.Initialize(tmpDir)

// Build module registry
moduleRegistry, err := registry.BuildModuleRegistry(tmpDir)
require.NoError(t, err)

// Create call graph and index functions
callGraph := core.NewCallGraph()
IndexFunctions(codeGraph, callGraph, moduleRegistry)

// Verify functions were indexed
assert.NotEmpty(t, callGraph.Functions)

// Count functions/methods
functionCount := 0
for _, node := range callGraph.Functions {
if node.Type == "function_definition" || node.Type == "method_declaration" {
functionCount++
}
}
assert.GreaterOrEqual(t, functionCount, 3, "Should have at least 3 functions/methods")
}

func TestGetFunctionsInFile(t *testing.T) {
// Create a temporary file
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.py")

err := os.WriteFile(testFile, []byte(`
def func1():
pass

def func2():
pass
`), 0644)
require.NoError(t, err)

// Parse file
codeGraph := graph.Initialize(tmpDir)

// Get functions in file
functions := GetFunctionsInFile(codeGraph, testFile)

// Verify functions were found
assert.NotEmpty(t, functions)
assert.GreaterOrEqual(t, len(functions), 2, "Should find at least 2 functions")
}

func TestFindContainingFunction(t *testing.T) {
// Create a temporary project
tmpDir := t.TempDir()

testFile := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(testFile, []byte(`
def outer_function():
x = 1
y = 2
return x + y
`), 0644)
require.NoError(t, err)

// Parse file
codeGraph := graph.Initialize(tmpDir)

// Get functions
functions := GetFunctionsInFile(codeGraph, testFile)
require.NotEmpty(t, functions)

// Test finding containing function for a location inside the function
location := core.Location{
File: testFile,
Line: 3,
Column: 5, // Inside function body
}

modulePath := "test"
containingFQN := FindContainingFunction(location, functions, modulePath)

// Should find the outer_function
assert.NotEmpty(t, containingFQN)
assert.Contains(t, containingFQN, "outer_function")
}

func TestFindContainingFunction_ModuleLevel(t *testing.T) {
// Create a temporary project
tmpDir := t.TempDir()

testFile := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(testFile, []byte(`
MODULE_VAR = 42

def my_function():
pass
`), 0644)
require.NoError(t, err)

// Parse file
codeGraph := graph.Initialize(tmpDir)

functions := GetFunctionsInFile(codeGraph, testFile)

// Test module-level code (column == 1)
location := core.Location{
File: testFile,
Line: 2,
Column: 1, // Module level
}

modulePath := "test"
containingFQN := FindContainingFunction(location, functions, modulePath)

// Should return empty for module-level code
assert.Empty(t, containingFQN)
}

func TestValidateFQN(t *testing.T) {
moduleRegistry := core.NewModuleRegistry()

// Add a test module
moduleRegistry.Modules["mymodule"] = "/path/to/mymodule.py"
moduleRegistry.FileToModule["/path/to/mymodule.py"] = "mymodule"

tests := []struct {
name string
fqn string
expected bool
}{
{"Valid module FQN", "mymodule.func", true},
{"Invalid module FQN", "unknownmodule.func", false},
{"Empty FQN", "", false},
{"Valid module name without dot", "mymodule", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateFQN(tt.fqn, moduleRegistry)
assert.Equal(t, tt.expected, result)
})
}
}

func TestDetectPythonVersion(t *testing.T) {
// Create a temporary project
tmpDir := t.TempDir()

// Test with .python-version file
pythonVersionFile := filepath.Join(tmpDir, ".python-version")
err := os.WriteFile(pythonVersionFile, []byte("3.11.0\n"), 0644)
require.NoError(t, err)

version := DetectPythonVersion(tmpDir)
assert.NotEmpty(t, version)
assert.Contains(t, version, "3.11")
}

func TestDetectPythonVersion_NoPythonVersionFile(t *testing.T) {
// Create an empty temporary directory
tmpDir := t.TempDir()

// Should fall back to checking pyproject.toml or default
version := DetectPythonVersion(tmpDir)
// Should return a default version or detect from system
assert.NotEmpty(t, version)
}

func TestBuildCallGraph_WithEdges(t *testing.T) {
// Create a project with function calls
tmpDir := t.TempDir()

mainPy := filepath.Join(tmpDir, "main.py")
err := os.WriteFile(mainPy, []byte(`
def helper():
return 42

def caller():
result = helper()
return result
`), 0644)
require.NoError(t, err)

// Parse and build call graph
codeGraph := graph.Initialize(tmpDir)

moduleRegistry, err := registry.BuildModuleRegistry(tmpDir)
require.NoError(t, err)

callGraph, err := BuildCallGraph(codeGraph, moduleRegistry, tmpDir)
require.NoError(t, err)

// Verify edges were created
assert.NotEmpty(t, callGraph.Edges)

// Check that caller has edges to helper
foundEdge := false
for callerFQN, callees := range callGraph.Edges {
if len(callees) > 0 {
foundEdge = true
t.Logf("Function %s calls: %v", callerFQN, callees)
}
}

assert.True(t, foundEdge, "Expected at least one call edge")
}
88 changes: 88 additions & 0 deletions sourcecode-parser/graph/callgraph/builder/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package builder

import (
"sync"

"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph/callgraph/core"
"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph/callgraph/resolution"
)

// ImportMapCache provides thread-safe caching of ImportMap instances.
// It prevents redundant import extraction by caching results keyed by file path.
//
// Thread-safety:
// - All methods are safe for concurrent use
// - Uses RWMutex for optimized read-heavy workloads
// - GetOrExtract handles double-checked locking pattern
type ImportMapCache struct {
cache map[string]*core.ImportMap // Maps file path to ImportMap
mu sync.RWMutex // Protects cache map
}

// NewImportMapCache creates a new empty import map cache.
func NewImportMapCache() *ImportMapCache {
return &ImportMapCache{
cache: make(map[string]*core.ImportMap),
}
}

// Get retrieves an ImportMap from the cache if it exists.
//
// Parameters:
// - filePath: absolute path to the Python file
//
// Returns:
// - ImportMap and true if found in cache, nil and false otherwise
func (c *ImportMapCache) Get(filePath string) (*core.ImportMap, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

importMap, ok := c.cache[filePath]
return importMap, ok
}

// Put stores an ImportMap in the cache.
//
// Parameters:
// - filePath: absolute path to the Python file
// - importMap: the extracted ImportMap to cache
func (c *ImportMapCache) Put(filePath string, importMap *core.ImportMap) {
c.mu.Lock()
defer c.mu.Unlock()

c.cache[filePath] = importMap
}

// GetOrExtract retrieves an ImportMap from cache or extracts it if not cached.
// This is the main entry point for using the cache.
//
// Parameters:
// - filePath: absolute path to the Python file
// - sourceCode: file contents (only used if extraction needed)
// - registry: module registry for resolving imports
//
// Returns:
// - ImportMap from cache or newly extracted
// - error if extraction fails (cache misses only)
//
// Thread-safety:
// - Multiple goroutines can safely call GetOrExtract concurrently
// - First caller for a file will extract and cache
// - Subsequent callers will get cached result
func (c *ImportMapCache) GetOrExtract(filePath string, sourceCode []byte, registry *core.ModuleRegistry) (*core.ImportMap, error) {
// Try to get from cache (fast path with read lock)
if importMap, ok := c.Get(filePath); ok {
return importMap, nil
}

// Cache miss - extract imports (expensive operation)
importMap, err := resolution.ExtractImports(filePath, sourceCode, registry)
if err != nil {
return nil, err
}

// Store in cache for future use
c.Put(filePath, importMap)

return importMap, nil
}
Loading
Loading