Skip to content
29 changes: 29 additions & 0 deletions api/analyzers/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,32 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_

pass

@abstractmethod
def add_file_imports(self, file: File) -> None:
"""
Add import statements to the file.

Args:
file (File): The file to add imports to.
"""

pass

@abstractmethod
def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
"""
Resolve an import statement to entities.

Args:
files (dict[Path, File]): All files in the project.
lsp (SyncLanguageServer): The language server.
file_path (Path): The path to the file containing the import.
path (Path): The path to the project root.
import_node (Node): The import statement node.

Returns:
list[Entity]: List of resolved entities.
"""

pass

8 changes: 8 additions & 0 deletions api/analyzers/csharp/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,11 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")

def add_file_imports(self, file: File) -> None:
# C# import tracking not yet implemented
pass

def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
# C# import resolution not yet implemented
return []
18 changes: 18 additions & 0 deletions api/analyzers/java/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from pathlib import Path
import subprocess
from ...entities import *
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer

Expand Down Expand Up @@ -127,3 +129,19 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")

def add_file_imports(self, file: File) -> None:
"""
Extract and add import statements from the file.
Java imports are not yet implemented.
"""
# TODO: Implement Java import tracking
pass

def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
"""
Resolve an import statement to the entities it imports.
Java imports are not yet implemented.
"""
# TODO: Implement Java import resolution
return []
8 changes: 8 additions & 0 deletions api/analyzers/javascript/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
res.append(file.entities[method_dec])
return res

def add_file_imports(self, file: File) -> None:
"""JavaScript import tracking not yet implemented."""
pass

def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
"""JavaScript import resolution not yet implemented."""
return []

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
"""Dispatch symbol resolution based on the symbol category.

Expand Down
8 changes: 8 additions & 0 deletions api/analyzers/kotlin/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
break
return res

def add_file_imports(self, file: File) -> None:
"""Kotlin import tracking not yet implemented."""
pass

def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
"""Kotlin import resolution not yet implemented."""
return []

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
if key in ["implement_interface", "base_class", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
Expand Down
99 changes: 96 additions & 3 deletions api/analyzers/python/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import tomllib
from ...entities import *
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer

Expand Down Expand Up @@ -96,9 +98,11 @@ def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_pa
if node.type == 'attribute':
node = node.child_by_field_name('attribute')
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_definition'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
decl = resolved_node
if decl.type not in ['class_definition', 'function_definition']:
decl = self.find_parent(resolved_node, ['class_definition', 'function_definition'])
if decl in file.entities:
Comment on lines +102 to +104
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_type() now treats function_definition as a valid declaration and can return Function entities. Since this method is also used for return_type / parameters symbol resolution, it can create RETURNS/PARAMETERS edges to functions (not types) when the LSP resolves an annotation to a function symbol. Consider keeping resolve_type() limited to class/type declarations and handling function imports via resolve_method() (or a dedicated resolver) to avoid mixing semantics.

Suggested change
if decl.type not in ['class_definition', 'function_definition']:
decl = self.find_parent(resolved_node, ['class_definition', 'function_definition'])
if decl in file.entities:
if decl.type != 'class_definition':
decl = self.find_parent(resolved_node, ['class_definition'])
if decl and decl.type == 'class_definition' and decl in file.entities:

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this, but I believe the current approach is acceptable here. The purpose of this PR is to track imports, and resolve_type() expanding to include function_definition is what enables _resolve_import_name() to resolve function imports via a single call path before falling back to resolve_method().

The risk of resolve_type() returning Function entities for base_class/parameters/return_type resolution is low in practice — the LSP resolves annotations to their actual definition, and a type annotation like x: some_function would be unusual Python. That said, splitting resolve_type into type-only and definition-generic variants is a valid improvement but better suited for a dedicated refactor PR to avoid scope creep here.

Also added explicit Entity/File imports to the Python analyzer in 1b1ef5c to match the Java analyzer pattern and resolve F405 ruff warnings.

res.append(file.entities[decl])
return res

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
Expand All @@ -122,3 +126,92 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")

def add_file_imports(self, file: File) -> None:
"""
Extract and add import statements from the file.

Supports:
- import module
- import module as alias
- from module import name
- from module import name1, name2
- from module import name as alias
"""
try:
captures = self._captures("""
(import_statement) @import
(import_from_statement) @import_from
""", file.tree.root_node)

# Add all import statement nodes to the file
if 'import' in captures:
for import_node in captures['import']:
file.add_import(import_node)

if 'import_from' in captures:
for import_node in captures['import_from']:
file.add_import(import_node)
except Exception as e:
logger.debug(f"Failed to extract imports from {file.path}: {e}")

def _resolve_import_name(self, files, lsp, file_path, path, identifier):
"""Try to resolve an imported name as both a type and a function."""
resolved = self.resolve_type(files, lsp, file_path, path, identifier)
if not resolved:
resolved = self.resolve_method(files, lsp, file_path, path, identifier)
return resolved

def resolve_import(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, import_node: Node) -> list[Entity]:
"""
Resolve an import statement to the entities it imports.
"""
res = []

try:
if import_node.type == 'import_statement':
# Handle "import module" or "import module as alias"
# Find all dotted_name and aliased_import nodes
for child in import_node.children:
if child.type == 'dotted_name':
# Try to resolve the module/name
identifier = child.children[0] if child.child_count > 0 else child
res.extend(self._resolve_import_name(files, lsp, file_path, path, identifier))
elif child.type == 'aliased_import':
# Get the actual name from aliased import (before 'as')
if child.child_count > 0:
actual_name = child.children[0]
if actual_name.type == 'dotted_name' and actual_name.child_count > 0:
identifier = actual_name.children[0]
else:
identifier = actual_name
res.extend(self._resolve_import_name(files, lsp, file_path, path, identifier))

elif import_node.type == 'import_from_statement':
# Handle "from module import name1, name2"
# Find the 'import' keyword to know where imported names start
import_keyword_found = False
for child in import_node.children:
if child.type == 'import':
import_keyword_found = True
continue

# After 'import' keyword, dotted_name nodes are the imported names
if import_keyword_found and child.type == 'dotted_name':
# Try to resolve the imported name
identifier = child.children[0] if child.child_count > 0 else child
res.extend(self._resolve_import_name(files, lsp, file_path, path, identifier))
elif import_keyword_found and child.type == 'aliased_import':
# Handle "from module import name as alias"
if child.child_count > 0:
actual_name = child.children[0]
if actual_name.type == 'dotted_name' and actual_name.child_count > 0:
identifier = actual_name.children[0]
else:
identifier = actual_name
res.extend(self._resolve_import_name(files, lsp, file_path, path, identifier))

except Exception as e:
logger.debug(f"Failed to resolve import: {e}")

return res
13 changes: 13 additions & 0 deletions api/analyzers/source_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def first_pass(self, path: Path, files: list[Path], ignore: list[str], graph: Gr
# Walk thought the AST
graph.add_file(file)
self.create_hierarchy(file, analyzer, graph)

# Extract import statements
if not analyzer.is_dependency(str(file_path)):
analyzer.add_file_imports(file)

def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
"""
Expand Down Expand Up @@ -162,6 +166,8 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
continue
file = self.files[file_path]
logging.info(f'Processing file ({i + 1}/{files_len}): {file_path}')

# Resolve entity symbols
for _, entity in file.entities.items():
entity.resolved_symbol(lambda key, symbol, fp=file_path: analyzers[fp.suffix].resolve_symbol(self.files, lsps[fp.suffix], fp, path, key, symbol))
for key, resolved_set in entity.resolved_symbols.items():
Expand All @@ -178,6 +184,13 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
graph.connect_entities("RETURNS", entity.id, resolved.id)
elif key == "parameters":
graph.connect_entities("PARAMETERS", entity.id, resolved.id)

# Resolve file imports
for import_node in file.imports:
resolved_entities = analyzers[file_path.suffix].resolve_import(self.files, lsps[file_path.suffix], file_path, path, import_node)
for resolved_entity in resolved_entities:
file.add_resolved_import(resolved_entity)
graph.connect_entities("IMPORTS", file.id, resolved_entity.id)

def analyze_files(self, files: list[Path], path: Path, graph: Graph) -> None:
self.first_pass(path, files, [], graph)
Expand Down
20 changes: 20 additions & 0 deletions api/entities/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,30 @@ def __init__(self, path: Path, tree: Tree) -> None:
self.path = path
self.tree = tree
self.entities: dict[Node, Entity] = {}
self.imports: list[Node] = []
self.resolved_imports: set[Entity] = set()

def add_entity(self, entity: Entity):
entity.parent = self
self.entities[entity.node] = entity

def add_import(self, import_node: Node):
"""
Add an import statement node to track.

Args:
import_node (Node): The import statement node.
"""
self.imports.append(import_node)

def add_resolved_import(self, resolved_entity: Entity):
"""
Add a resolved import entity.

Args:
resolved_entity (Entity): The resolved entity that is imported.
"""
self.resolved_imports.add(resolved_entity)

def __str__(self) -> str:
return f"path: {self.path}"
Expand Down
11 changes: 11 additions & 0 deletions test-project/a.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include <stdio.h>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify stdio APIs are not used in this file before removing the include.
rg -n '^\s*#include\s*<stdio\.h>' test-project/a.c
rg -nP '\b(FILE|printf|fprintf|snprintf|puts|putchar|fopen|fclose)\b' test-project/a.c

Repository: FalkorDB/code-graph

Length of output: 83


Remove the unused <stdio.h> include.

Line 1 contains #include <stdio.h>, but no stdio functions or types are used in this file. Removing it eliminates the Clang error ('stdio.h' file not found) and keeps the fixture minimal.

Suggested change
-#include <stdio.h>
 `#include` "src/ff.h"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#include <stdio.h>
`#include` "src/ff.h"
🧰 Tools
🪛 Clang (14.0.6)

[error] 1-1: 'stdio.h' file not found

(clang-diagnostic-error)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test-project/a.c` at line 1, Remove the unused include directive '#include
<stdio.h>' from the top of the file (the '#include <stdio.h>' line shown in the
diff) so the file no longer references stdio and the Clang error is avoided;
simply delete that include line to keep the fixture minimal.

#include "src/ff.h"


/* Create an empty intset. */
intset* intsetNew(void) {
intset *is = zmalloc(sizeof(intset));
is->encoding = intrev32ifbe(INTSET_ENC_INT16);
is->length = 0;
return is;
}
26 changes: 26 additions & 0 deletions test-project/c.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package test_project;

public class c {

private int a;

public static void main(String[] args) {
System.out.println("Hello, World!");
}

public static void print() {
System.out.println("Hello, World!");
}

public int getA() {
return a;
}

public void setA(int a) {
this.a = a;
}

public void inc() {
setA(getA() + 1);
}
}
12 changes: 12 additions & 0 deletions tests/source_files/py_imports/module_a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Module A with a class definition."""

class ClassA:
"""A simple class in module A."""

def method_a(self):
"""A method in ClassA."""
return "Method A"

def function_a():
"""A function in module A."""
return "Function A"
11 changes: 11 additions & 0 deletions tests/source_files/py_imports/module_b.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Module B that imports from module A."""

from module_a import ClassA, function_a

class ClassB(ClassA):
"""A class that extends ClassA."""

def method_b(self):
"""A method in ClassB."""
result = function_a()
return f"Method B: {result}"
Loading
Loading