Files
Claude-Code-Workflow/codex-lens/tests/parsers/test_astgrep_processor.py
catlog22 48a6a1f2aa Add comprehensive tests for ast-grep and tree-sitter relationship extraction
- Introduced test suite for AstGrepPythonProcessor covering pattern definitions, parsing, and relationship extraction.
- Added comparison tests between tree-sitter and ast-grep for consistency in relationship extraction.
- Implemented tests for ast-grep binding module to verify functionality and availability.
- Ensured tests cover various scenarios including inheritance, function calls, and imports.
2026-02-15 21:14:14 +08:00

403 lines
13 KiB
Python

"""Tests for AstGrepPythonProcessor.
Tests pattern-based relationship extraction from Python source code
using ast-grep-py bindings.
"""
from pathlib import Path
import pytest
from codexlens.parsers.astgrep_processor import (
AstGrepPythonProcessor,
BaseAstGrepProcessor,
is_astgrep_processor_available,
)
from codexlens.parsers.patterns.python import (
PYTHON_PATTERNS,
METAVARS,
RELATIONSHIP_PATTERNS,
get_pattern,
get_patterns_for_relationship,
get_metavar,
)
# Check if ast-grep is available for conditional test skipping
ASTGREP_AVAILABLE = is_astgrep_processor_available()
class TestPatternDefinitions:
"""Tests for Python pattern definitions."""
def test_python_patterns_exist(self):
"""Verify all expected patterns are defined."""
expected_patterns = [
"class_def",
"class_with_bases",
"func_def",
"async_func_def",
"import_stmt",
"import_from",
"call",
"method_call",
]
for pattern_name in expected_patterns:
assert pattern_name in PYTHON_PATTERNS, f"Missing pattern: {pattern_name}"
def test_get_pattern_returns_correct_pattern(self):
"""Test get_pattern returns expected pattern strings."""
# Note: ast-grep-py 0.40+ uses $$$ for zero-or-more multi-match
assert get_pattern("class_def") == "class $NAME $$$BODY"
assert get_pattern("func_def") == "def $NAME($$$PARAMS): $$$BODY"
assert get_pattern("import_stmt") == "import $MODULE"
def test_get_pattern_raises_for_unknown(self):
"""Test get_pattern raises KeyError for unknown patterns."""
with pytest.raises(KeyError):
get_pattern("nonexistent_pattern")
def test_metavars_defined(self):
"""Verify metavariable mappings are defined."""
expected_metavars = [
"class_name",
"func_name",
"import_module",
"call_func",
]
for var in expected_metavars:
assert var in METAVARS, f"Missing metavar: {var}"
def test_get_metavar(self):
"""Test get_metavar returns correct values."""
assert get_metavar("class_name") == "NAME"
assert get_metavar("func_name") == "NAME"
assert get_metavar("import_module") == "MODULE"
def test_relationship_patterns_mapping(self):
"""Test relationship type to pattern mapping."""
assert "class_with_bases" in get_patterns_for_relationship("inheritance")
assert "import_stmt" in get_patterns_for_relationship("imports")
assert "import_from" in get_patterns_for_relationship("imports")
assert "call" in get_patterns_for_relationship("calls")
class TestAstGrepPythonProcessorAvailability:
"""Tests for processor availability."""
def test_is_available_returns_bool(self):
"""Test is_available returns a boolean."""
processor = AstGrepPythonProcessor()
assert isinstance(processor.is_available(), bool)
def test_is_available_matches_global_check(self):
"""Test is_available matches is_astgrep_processor_available."""
processor = AstGrepPythonProcessor()
assert processor.is_available() == is_astgrep_processor_available()
def test_module_level_check(self):
"""Test module-level availability function."""
assert isinstance(is_astgrep_processor_available(), bool)
@pytest.mark.skipif(not ASTGREP_AVAILABLE, reason="ast-grep-py not installed")
class TestAstGrepPythonProcessorParsing:
"""Tests for Python parsing with ast-grep."""
def test_parse_simple_function(self):
"""Test parsing a simple function definition."""
processor = AstGrepPythonProcessor()
code = "def hello():\n pass"
result = processor.parse(code, Path("test.py"))
assert result is not None
assert result.language == "python"
assert len(result.symbols) == 1
assert result.symbols[0].name == "hello"
assert result.symbols[0].kind == "function"
def test_parse_class(self):
"""Test parsing a class definition."""
processor = AstGrepPythonProcessor()
code = "class MyClass:\n pass"
result = processor.parse(code, Path("test.py"))
assert result is not None
assert len(result.symbols) == 1
assert result.symbols[0].name == "MyClass"
assert result.symbols[0].kind == "class"
def test_parse_async_function(self):
"""Test parsing an async function definition."""
processor = AstGrepPythonProcessor()
code = "async def fetch_data():\n pass"
result = processor.parse(code, Path("test.py"))
assert result is not None
assert len(result.symbols) == 1
assert result.symbols[0].name == "fetch_data"
def test_parse_class_with_inheritance(self):
"""Test parsing class with inheritance."""
processor = AstGrepPythonProcessor()
code = """
class Base:
pass
class Child(Base):
pass
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
names = [s.name for s in result.symbols]
assert "Base" in names
assert "Child" in names
# Check inheritance relationship
inherits = [
r for r in result.relationships
if r.relationship_type.value == "inherits"
]
assert any(r.source_symbol == "Child" for r in inherits)
def test_parse_imports(self):
"""Test parsing import statements."""
processor = AstGrepPythonProcessor()
code = """
import os
from sys import path
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
imports = [
r for r in result.relationships
if r.relationship_type.value == "imports"
]
assert len(imports) >= 1
targets = {r.target_symbol for r in imports}
assert "os" in targets
def test_parse_function_calls(self):
"""Test parsing function calls."""
processor = AstGrepPythonProcessor()
code = """
def main():
print("hello")
len([1, 2, 3])
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
calls = [
r for r in result.relationships
if r.relationship_type.value == "calls"
]
targets = {r.target_symbol for r in calls}
assert "print" in targets
assert "len" in targets
def test_parse_empty_file(self):
"""Test parsing an empty file."""
processor = AstGrepPythonProcessor()
result = processor.parse("", Path("test.py"))
assert result is not None
assert len(result.symbols) == 0
def test_parse_returns_indexed_file(self):
"""Test that parse returns proper IndexedFile structure."""
processor = AstGrepPythonProcessor()
code = "def test():\n pass"
result = processor.parse(code, Path("test.py"))
assert result is not None
assert result.path.endswith("test.py")
assert result.language == "python"
assert isinstance(result.symbols, list)
assert isinstance(result.chunks, list)
assert isinstance(result.relationships, list)
@pytest.mark.skipif(not ASTGREP_AVAILABLE, reason="ast-grep-py not installed")
class TestAstGrepPythonProcessorRelationships:
"""Tests for relationship extraction."""
def test_inheritance_extraction(self):
"""Test extraction of inheritance relationships."""
processor = AstGrepPythonProcessor()
code = """
class Animal:
pass
class Dog(Animal):
pass
class Cat(Animal):
pass
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
inherits = [
r for r in result.relationships
if r.relationship_type.value == "inherits"
]
# Should have 2 inheritance relationships
assert len(inherits) >= 2
sources = {r.source_symbol for r in inherits}
assert "Dog" in sources
assert "Cat" in sources
def test_call_extraction_skips_self(self):
"""Test that self.method() calls are filtered."""
processor = AstGrepPythonProcessor()
code = """
class Service:
def process(self):
self.internal()
external_call()
def external_call():
pass
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
calls = [
r for r in result.relationships
if r.relationship_type.value == "calls"
]
targets = {r.target_symbol for r in calls}
# self.internal should be filtered
assert "self.internal" not in targets
assert "external_call" in targets
def test_import_with_alias_resolution(self):
"""Test import alias resolution in calls."""
processor = AstGrepPythonProcessor()
code = """
import os.path as osp
def main():
osp.join("a", "b")
"""
result = processor.parse(code, Path("test.py"))
assert result is not None
calls = [
r for r in result.relationships
if r.relationship_type.value == "calls"
]
targets = {r.target_symbol for r in calls}
# Should resolve osp to os.path
assert any("os.path" in t for t in targets)
@pytest.mark.skipif(not ASTGREP_AVAILABLE, reason="ast-grep-py not installed")
class TestAstGrepPythonProcessorRunAstGrep:
"""Tests for run_ast_grep method."""
def test_run_ast_grep_returns_list(self):
"""Test run_ast_grep returns a list."""
processor = AstGrepPythonProcessor()
code = "def hello():\n pass"
processor._binding.parse(code) if processor._binding else None
matches = processor.run_ast_grep(code, "def $NAME($$$PARAMS) $$$BODY")
assert isinstance(matches, list)
def test_run_ast_grep_finds_matches(self):
"""Test run_ast_grep finds expected matches."""
processor = AstGrepPythonProcessor()
code = "def hello():\n pass"
matches = processor.run_ast_grep(code, "def $NAME($$$PARAMS) $$$BODY")
assert len(matches) >= 1
def test_run_ast_grep_empty_code(self):
"""Test run_ast_grep with empty code."""
processor = AstGrepPythonProcessor()
matches = processor.run_ast_grep("", "def $NAME($$$PARAMS) $$$BODY")
assert matches == []
def test_run_ast_grep_no_matches(self):
"""Test run_ast_grep when pattern doesn't match."""
processor = AstGrepPythonProcessor()
code = "x = 1"
matches = processor.run_ast_grep(code, "class $NAME $$$BODY")
assert matches == []
class TestAstGrepPythonProcessorFallback:
"""Tests for fallback behavior when ast-grep unavailable."""
def test_parse_returns_none_when_unavailable(self):
"""Test parse returns None when ast-grep unavailable."""
# This test runs regardless of availability
# When unavailable, should gracefully return None
processor = AstGrepPythonProcessor()
if not processor.is_available():
code = "def test():\n pass"
result = processor.parse(code, Path("test.py"))
assert result is None
def test_run_ast_grep_empty_when_unavailable(self):
"""Test run_ast_grep returns empty list when unavailable."""
processor = AstGrepPythonProcessor()
if not processor.is_available():
matches = processor.run_ast_grep("code", "pattern")
assert matches == []
class TestBaseAstGrepProcessor:
"""Tests for abstract base class."""
def test_cannot_instantiate_base_class(self):
"""Test that BaseAstGrepProcessor cannot be instantiated directly."""
with pytest.raises(TypeError):
BaseAstGrepProcessor("python") # type: ignore[abstract]
def test_subclass_implements_abstract_methods(self):
"""Test that AstGrepPythonProcessor implements all abstract methods."""
processor = AstGrepPythonProcessor()
# Should have process_matches method
assert hasattr(processor, "process_matches")
# Should have parse method
assert hasattr(processor, "parse")
# Check methods are callable
assert callable(processor.process_matches)
assert callable(processor.parse)
class TestPatternIntegration:
"""Tests for pattern module integration with processor."""
def test_processor_uses_pattern_module(self):
"""Verify processor uses patterns from pattern module."""
# The processor should import and use patterns from patterns/python/
from codexlens.parsers.astgrep_processor import get_pattern
# Verify pattern access works
assert get_pattern("class_def") is not None
assert get_pattern("func_def") is not None
def test_pattern_consistency(self):
"""Test pattern definitions are consistent."""
# Patterns used by processor should exist in pattern module
patterns_needed = [
"class_def",
"class_with_bases",
"func_def",
"async_func_def",
"import_stmt",
"import_from",
"call",
]
for pattern_name in patterns_needed:
# Should not raise KeyError
pattern = get_pattern(pattern_name)
assert pattern is not None
assert len(pattern) > 0