mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat(cli): 添加 --rule 选项支持模板自动发现
重构 ccw cli 模板系统: - 新增 template-discovery.ts 模块,支持扁平化模板自动发现 - 添加 --rule <template> 选项,自动加载 protocol 和 template - 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt) - 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式 - 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt 其他更新: - Dashboard: 添加 Claude Manager 和 Issue Manager 页面 - Codex-lens: 增强 chain_search 和 clustering 模块 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
codex-lens/tests/lsp/__init__.py
Normal file
1
codex-lens/tests/lsp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package for LSP module."""
|
||||
477
codex-lens/tests/lsp/test_hover.py
Normal file
477
codex-lens/tests/lsp/test_hover.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""Tests for hover provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock
|
||||
import tempfile
|
||||
|
||||
from codexlens.entities import Symbol
|
||||
|
||||
|
||||
class TestHoverInfo:
|
||||
"""Test HoverInfo dataclass."""
|
||||
|
||||
def test_hover_info_import(self):
|
||||
"""HoverInfo can be imported."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo
|
||||
|
||||
assert HoverInfo is not None
|
||||
|
||||
def test_hover_info_fields(self):
|
||||
"""HoverInfo has all required fields."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo
|
||||
|
||||
info = HoverInfo(
|
||||
name="my_function",
|
||||
kind="function",
|
||||
signature="def my_function(x: int) -> str:",
|
||||
documentation="A test function.",
|
||||
file_path="/test/file.py",
|
||||
line_range=(10, 15),
|
||||
)
|
||||
assert info.name == "my_function"
|
||||
assert info.kind == "function"
|
||||
assert info.signature == "def my_function(x: int) -> str:"
|
||||
assert info.documentation == "A test function."
|
||||
assert info.file_path == "/test/file.py"
|
||||
assert info.line_range == (10, 15)
|
||||
|
||||
def test_hover_info_optional_documentation(self):
|
||||
"""Documentation can be None."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="def func():",
|
||||
documentation=None,
|
||||
file_path="/test.py",
|
||||
line_range=(1, 2),
|
||||
)
|
||||
assert info.documentation is None
|
||||
|
||||
|
||||
class TestHoverProvider:
|
||||
"""Test HoverProvider class."""
|
||||
|
||||
def test_provider_import(self):
|
||||
"""HoverProvider can be imported."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
assert HoverProvider is not None
|
||||
|
||||
def test_returns_none_for_unknown_symbol(self):
|
||||
"""Returns None when symbol not found."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = []
|
||||
mock_registry = Mock()
|
||||
|
||||
provider = HoverProvider(mock_index, mock_registry)
|
||||
result = provider.get_hover_info("unknown_symbol")
|
||||
|
||||
assert result is None
|
||||
mock_index.search.assert_called_once_with(
|
||||
name="unknown_symbol", limit=1, prefix_mode=False
|
||||
)
|
||||
|
||||
def test_returns_none_for_non_exact_match(self):
|
||||
"""Returns None when search returns non-exact matches."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
# Return a symbol with different name (prefix match but not exact)
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_function_extended"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test/file.py"
|
||||
mock_symbol.range = (10, 15)
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
mock_registry = Mock()
|
||||
|
||||
provider = HoverProvider(mock_index, mock_registry)
|
||||
result = provider.get_hover_info("my_function")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_hover_info_for_known_symbol(self):
|
||||
"""Returns HoverInfo for found symbol."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = None # No file, will use fallback signature
|
||||
mock_symbol.range = (10, 15)
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
mock_registry = Mock()
|
||||
|
||||
provider = HoverProvider(mock_index, mock_registry)
|
||||
result = provider.get_hover_info("my_func")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "my_func"
|
||||
assert result.kind == "function"
|
||||
assert result.line_range == (10, 15)
|
||||
assert result.signature == "function my_func"
|
||||
|
||||
def test_extracts_signature_from_file(self):
|
||||
"""Extracts signature from actual file content."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
# Create a temporary file with Python content
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("# comment\n")
|
||||
f.write("def test_function(x: int, y: str) -> bool:\n")
|
||||
f.write(" return True\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "test_function"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (2, 3) # Line 2 (1-based)
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
|
||||
provider = HoverProvider(mock_index, None)
|
||||
result = provider.get_hover_info("test_function")
|
||||
|
||||
assert result is not None
|
||||
assert "def test_function(x: int, y: str) -> bool:" in result.signature
|
||||
finally:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_extracts_multiline_signature(self):
|
||||
"""Extracts multiline function signature."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
# Create a temporary file with multiline signature
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("def complex_function(\n")
|
||||
f.write(" arg1: int,\n")
|
||||
f.write(" arg2: str,\n")
|
||||
f.write(") -> bool:\n")
|
||||
f.write(" return True\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "complex_function"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (1, 5) # Line 1 (1-based)
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
|
||||
provider = HoverProvider(mock_index, None)
|
||||
result = provider.get_hover_info("complex_function")
|
||||
|
||||
assert result is not None
|
||||
assert "def complex_function(" in result.signature
|
||||
# Should capture multiline signature
|
||||
assert "arg1: int" in result.signature
|
||||
finally:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_handles_nonexistent_file_gracefully(self):
|
||||
"""Returns fallback signature when file doesn't exist."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/nonexistent/path/file.py"
|
||||
mock_symbol.range = (10, 15)
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
|
||||
provider = HoverProvider(mock_index, None)
|
||||
result = provider.get_hover_info("my_func")
|
||||
|
||||
assert result is not None
|
||||
assert result.signature == "function my_func"
|
||||
|
||||
def test_handles_invalid_line_range(self):
|
||||
"""Returns fallback signature when line range is invalid."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("def test():\n")
|
||||
f.write(" pass\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "test"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (100, 105) # Line beyond file length
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = [mock_symbol]
|
||||
|
||||
provider = HoverProvider(mock_index, None)
|
||||
result = provider.get_hover_info("test")
|
||||
|
||||
assert result is not None
|
||||
assert result.signature == "function test"
|
||||
finally:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestFormatHoverMarkdown:
|
||||
"""Test markdown formatting."""
|
||||
|
||||
def test_format_python_signature(self):
|
||||
"""Formats Python signature with python code fence."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="def func(x: int) -> str:",
|
||||
documentation=None,
|
||||
file_path="/test/file.py",
|
||||
line_range=(10, 15),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "```python" in result
|
||||
assert "def func(x: int) -> str:" in result
|
||||
assert "function" in result
|
||||
assert "file.py" in result
|
||||
assert "line 10" in result
|
||||
|
||||
def test_format_javascript_signature(self):
|
||||
"""Formats JavaScript signature with javascript code fence."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="myFunc",
|
||||
kind="function",
|
||||
signature="function myFunc(x) {",
|
||||
documentation=None,
|
||||
file_path="/test/file.js",
|
||||
line_range=(5, 10),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "```javascript" in result
|
||||
assert "function myFunc(x) {" in result
|
||||
|
||||
def test_format_typescript_signature(self):
|
||||
"""Formats TypeScript signature with typescript code fence."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="myFunc",
|
||||
kind="function",
|
||||
signature="function myFunc(x: number): string {",
|
||||
documentation=None,
|
||||
file_path="/test/file.ts",
|
||||
line_range=(5, 10),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "```typescript" in result
|
||||
|
||||
def test_format_with_documentation(self):
|
||||
"""Includes documentation when available."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="def func():",
|
||||
documentation="This is a test function.",
|
||||
file_path="/test/file.py",
|
||||
line_range=(10, 15),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "This is a test function." in result
|
||||
assert "---" in result # Separator before docs
|
||||
|
||||
def test_format_without_documentation(self):
|
||||
"""Does not include documentation section when None."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="def func():",
|
||||
documentation=None,
|
||||
file_path="/test/file.py",
|
||||
line_range=(10, 15),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
# Should have one separator for location, not two
|
||||
# The result should not have duplicate doc separator
|
||||
lines = result.split("\n")
|
||||
separator_count = sum(1 for line in lines if line.strip() == "---")
|
||||
assert separator_count == 1 # Only location separator
|
||||
|
||||
def test_format_unknown_extension(self):
|
||||
"""Uses empty code fence for unknown file extensions."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="func code here",
|
||||
documentation=None,
|
||||
file_path="/test/file.xyz",
|
||||
line_range=(1, 2),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
# Should have code fence without language specifier
|
||||
assert "```\n" in result or "```xyz" not in result
|
||||
|
||||
def test_format_class_symbol(self):
|
||||
"""Formats class symbol correctly."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="MyClass",
|
||||
kind="class",
|
||||
signature="class MyClass:",
|
||||
documentation=None,
|
||||
file_path="/test/file.py",
|
||||
line_range=(1, 20),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "class MyClass:" in result
|
||||
assert "*class*" in result
|
||||
assert "line 1" in result
|
||||
|
||||
def test_format_empty_file_path(self):
|
||||
"""Handles empty file path gracefully."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverInfo, HoverProvider
|
||||
|
||||
info = HoverInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
signature="def func():",
|
||||
documentation=None,
|
||||
file_path="",
|
||||
line_range=(1, 2),
|
||||
)
|
||||
mock_index = Mock()
|
||||
provider = HoverProvider(mock_index, None)
|
||||
|
||||
result = provider.format_hover_markdown(info)
|
||||
|
||||
assert "unknown" in result or "```" in result
|
||||
|
||||
|
||||
class TestHoverProviderRegistry:
|
||||
"""Test HoverProvider with registry integration."""
|
||||
|
||||
def test_provider_accepts_none_registry(self):
|
||||
"""HoverProvider works without registry."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
mock_index = Mock()
|
||||
mock_index.search.return_value = []
|
||||
|
||||
provider = HoverProvider(mock_index, None)
|
||||
result = provider.get_hover_info("test")
|
||||
|
||||
assert result is None
|
||||
assert provider.registry is None
|
||||
|
||||
def test_provider_stores_registry(self):
|
||||
"""HoverProvider stores registry reference."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.providers import HoverProvider
|
||||
|
||||
mock_index = Mock()
|
||||
mock_registry = Mock()
|
||||
|
||||
provider = HoverProvider(mock_index, mock_registry)
|
||||
|
||||
assert provider.global_index is mock_index
|
||||
assert provider.registry is mock_registry
|
||||
497
codex-lens/tests/lsp/test_references.py
Normal file
497
codex-lens/tests/lsp/test_references.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""Tests for reference search functionality.
|
||||
|
||||
This module tests the ReferenceResult dataclass and search_references method
|
||||
in ChainSearchEngine, as well as the updated lsp_references handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
class TestReferenceResult:
|
||||
"""Test ReferenceResult dataclass."""
|
||||
|
||||
def test_reference_result_fields(self):
|
||||
"""ReferenceResult has all required fields."""
|
||||
from codexlens.search.chain_search import ReferenceResult
|
||||
|
||||
ref = ReferenceResult(
|
||||
file_path="/test/file.py",
|
||||
line=10,
|
||||
column=5,
|
||||
context="def foo():",
|
||||
relationship_type="call",
|
||||
)
|
||||
assert ref.file_path == "/test/file.py"
|
||||
assert ref.line == 10
|
||||
assert ref.column == 5
|
||||
assert ref.context == "def foo():"
|
||||
assert ref.relationship_type == "call"
|
||||
|
||||
def test_reference_result_with_empty_context(self):
|
||||
"""ReferenceResult can have empty context."""
|
||||
from codexlens.search.chain_search import ReferenceResult
|
||||
|
||||
ref = ReferenceResult(
|
||||
file_path="/test/file.py",
|
||||
line=1,
|
||||
column=0,
|
||||
context="",
|
||||
relationship_type="import",
|
||||
)
|
||||
assert ref.context == ""
|
||||
|
||||
def test_reference_result_different_relationship_types(self):
|
||||
"""ReferenceResult supports different relationship types."""
|
||||
from codexlens.search.chain_search import ReferenceResult
|
||||
|
||||
types = ["call", "import", "inheritance", "implementation", "usage"]
|
||||
for rel_type in types:
|
||||
ref = ReferenceResult(
|
||||
file_path="/test/file.py",
|
||||
line=1,
|
||||
column=0,
|
||||
context="test",
|
||||
relationship_type=rel_type,
|
||||
)
|
||||
assert ref.relationship_type == rel_type
|
||||
|
||||
|
||||
class TestExtractContext:
|
||||
"""Test the _extract_context helper method."""
|
||||
|
||||
def test_extract_context_middle_of_file(self):
|
||||
"""Extract context from middle of file."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine, ReferenceResult
|
||||
|
||||
content = "\n".join([
|
||||
"line 1",
|
||||
"line 2",
|
||||
"line 3",
|
||||
"line 4", # target line
|
||||
"line 5",
|
||||
"line 6",
|
||||
"line 7",
|
||||
])
|
||||
|
||||
# Create minimal mock engine to test _extract_context
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
context = engine._extract_context(content, line=4, context_lines=2)
|
||||
|
||||
assert "line 2" in context
|
||||
assert "line 3" in context
|
||||
assert "line 4" in context
|
||||
assert "line 5" in context
|
||||
assert "line 6" in context
|
||||
|
||||
def test_extract_context_start_of_file(self):
|
||||
"""Extract context at start of file."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
content = "\n".join([
|
||||
"line 1", # target
|
||||
"line 2",
|
||||
"line 3",
|
||||
"line 4",
|
||||
])
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
context = engine._extract_context(content, line=1, context_lines=2)
|
||||
|
||||
assert "line 1" in context
|
||||
assert "line 2" in context
|
||||
assert "line 3" in context
|
||||
|
||||
def test_extract_context_end_of_file(self):
|
||||
"""Extract context at end of file."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
content = "\n".join([
|
||||
"line 1",
|
||||
"line 2",
|
||||
"line 3",
|
||||
"line 4", # target
|
||||
])
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
context = engine._extract_context(content, line=4, context_lines=2)
|
||||
|
||||
assert "line 2" in context
|
||||
assert "line 3" in context
|
||||
assert "line 4" in context
|
||||
|
||||
def test_extract_context_empty_content(self):
|
||||
"""Extract context from empty content."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
context = engine._extract_context("", line=1, context_lines=3)
|
||||
|
||||
assert context == ""
|
||||
|
||||
def test_extract_context_invalid_line(self):
|
||||
"""Extract context with invalid line number."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
content = "line 1\nline 2\nline 3"
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
# Line 0 (invalid)
|
||||
assert engine._extract_context(content, line=0, context_lines=1) == ""
|
||||
|
||||
# Line beyond end
|
||||
assert engine._extract_context(content, line=100, context_lines=1) == ""
|
||||
|
||||
|
||||
class TestSearchReferences:
|
||||
"""Test search_references method."""
|
||||
|
||||
def test_returns_empty_for_no_source_path_and_no_registry(self):
|
||||
"""Returns empty list when no source path and registry has no mappings."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_registry.list_mappings.return_value = []
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
results = engine.search_references("test_symbol")
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_returns_empty_for_no_indexes(self):
|
||||
"""Returns empty list when no indexes found."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
mock_mapper.source_to_index_db.return_value = Path("/nonexistent/_index.db")
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
with patch.object(engine, "_find_start_index", return_value=None):
|
||||
results = engine.search_references("test_symbol", Path("/some/path"))
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_deduplicates_results(self):
|
||||
"""Removes duplicate file:line references."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine, ReferenceResult
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
# Create a temporary database with duplicate relationships
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "_index.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
start_line INTEGER NOT NULL,
|
||||
end_line INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE code_relationships (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_symbol_id INTEGER NOT NULL,
|
||||
target_qualified_name TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
source_line INTEGER NOT NULL,
|
||||
target_file TEXT
|
||||
);
|
||||
|
||||
INSERT INTO files VALUES (1, '/test/file.py', 'python', 'def test(): pass');
|
||||
INSERT INTO symbols VALUES (1, 1, 'test_func', 'function', 1, 1);
|
||||
INSERT INTO code_relationships VALUES (1, 1, 'target_func', 'call', 10, NULL);
|
||||
INSERT INTO code_relationships VALUES (2, 1, 'target_func', 'call', 10, NULL);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(engine, "_find_start_index", return_value=db_path):
|
||||
with patch.object(engine, "_collect_index_paths", return_value=[db_path]):
|
||||
results = engine.search_references("target_func", Path(tmpdir))
|
||||
|
||||
# Should only have 1 result due to deduplication
|
||||
assert len(results) == 1
|
||||
assert results[0].line == 10
|
||||
|
||||
def test_sorts_by_file_and_line(self):
|
||||
"""Results sorted by file path then line number."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine, ReferenceResult
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "_index.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
start_line INTEGER NOT NULL,
|
||||
end_line INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE code_relationships (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_symbol_id INTEGER NOT NULL,
|
||||
target_qualified_name TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
source_line INTEGER NOT NULL,
|
||||
target_file TEXT
|
||||
);
|
||||
|
||||
INSERT INTO files VALUES (1, '/test/b_file.py', 'python', 'content');
|
||||
INSERT INTO files VALUES (2, '/test/a_file.py', 'python', 'content');
|
||||
INSERT INTO symbols VALUES (1, 1, 'func1', 'function', 1, 1);
|
||||
INSERT INTO symbols VALUES (2, 2, 'func2', 'function', 1, 1);
|
||||
INSERT INTO code_relationships VALUES (1, 1, 'target', 'call', 20, NULL);
|
||||
INSERT INTO code_relationships VALUES (2, 1, 'target', 'call', 10, NULL);
|
||||
INSERT INTO code_relationships VALUES (3, 2, 'target', 'call', 5, NULL);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(engine, "_find_start_index", return_value=db_path):
|
||||
with patch.object(engine, "_collect_index_paths", return_value=[db_path]):
|
||||
results = engine.search_references("target", Path(tmpdir))
|
||||
|
||||
# Should be sorted: a_file.py:5, b_file.py:10, b_file.py:20
|
||||
assert len(results) == 3
|
||||
assert results[0].file_path == "/test/a_file.py"
|
||||
assert results[0].line == 5
|
||||
assert results[1].file_path == "/test/b_file.py"
|
||||
assert results[1].line == 10
|
||||
assert results[2].file_path == "/test/b_file.py"
|
||||
assert results[2].line == 20
|
||||
|
||||
def test_respects_limit(self):
|
||||
"""Returns at most limit results."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "_index.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
start_line INTEGER NOT NULL,
|
||||
end_line INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE code_relationships (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_symbol_id INTEGER NOT NULL,
|
||||
target_qualified_name TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
source_line INTEGER NOT NULL,
|
||||
target_file TEXT
|
||||
);
|
||||
|
||||
INSERT INTO files VALUES (1, '/test/file.py', 'python', 'content');
|
||||
INSERT INTO symbols VALUES (1, 1, 'func', 'function', 1, 1);
|
||||
""")
|
||||
# Insert many relationships
|
||||
for i in range(50):
|
||||
conn.execute(
|
||||
"INSERT INTO code_relationships VALUES (?, 1, 'target', 'call', ?, NULL)",
|
||||
(i + 1, i + 1)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(engine, "_find_start_index", return_value=db_path):
|
||||
with patch.object(engine, "_collect_index_paths", return_value=[db_path]):
|
||||
results = engine.search_references("target", Path(tmpdir), limit=10)
|
||||
|
||||
assert len(results) == 10
|
||||
|
||||
def test_matches_qualified_name(self):
|
||||
"""Matches symbols by qualified name suffix."""
|
||||
from codexlens.search.chain_search import ChainSearchEngine
|
||||
|
||||
mock_registry = Mock()
|
||||
mock_mapper = Mock()
|
||||
|
||||
engine = ChainSearchEngine(mock_registry, mock_mapper)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "_index.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
start_line INTEGER NOT NULL,
|
||||
end_line INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE code_relationships (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_symbol_id INTEGER NOT NULL,
|
||||
target_qualified_name TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
source_line INTEGER NOT NULL,
|
||||
target_file TEXT
|
||||
);
|
||||
|
||||
INSERT INTO files VALUES (1, '/test/file.py', 'python', 'content');
|
||||
INSERT INTO symbols VALUES (1, 1, 'caller', 'function', 1, 1);
|
||||
-- Fully qualified name
|
||||
INSERT INTO code_relationships VALUES (1, 1, 'module.submodule.target_func', 'call', 10, NULL);
|
||||
-- Simple name
|
||||
INSERT INTO code_relationships VALUES (2, 1, 'target_func', 'call', 20, NULL);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(engine, "_find_start_index", return_value=db_path):
|
||||
with patch.object(engine, "_collect_index_paths", return_value=[db_path]):
|
||||
results = engine.search_references("target_func", Path(tmpdir))
|
||||
|
||||
# Should find both references
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
class TestLspReferencesHandler:
|
||||
"""Test the LSP references handler."""
|
||||
|
||||
def test_handler_uses_search_engine(self):
|
||||
"""Handler uses search_engine.search_references when available."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from lsprotocol import types as lsp
|
||||
from codexlens.lsp.handlers import _path_to_uri
|
||||
from codexlens.search.chain_search import ReferenceResult
|
||||
|
||||
# Create mock references
|
||||
mock_references = [
|
||||
ReferenceResult(
|
||||
file_path="/test/file1.py",
|
||||
line=10,
|
||||
column=5,
|
||||
context="def foo():",
|
||||
relationship_type="call",
|
||||
),
|
||||
ReferenceResult(
|
||||
file_path="/test/file2.py",
|
||||
line=20,
|
||||
column=0,
|
||||
context="import foo",
|
||||
relationship_type="import",
|
||||
),
|
||||
]
|
||||
|
||||
# Verify conversion to LSP Location
|
||||
locations = []
|
||||
for ref in mock_references:
|
||||
locations.append(
|
||||
lsp.Location(
|
||||
uri=_path_to_uri(ref.file_path),
|
||||
range=lsp.Range(
|
||||
start=lsp.Position(
|
||||
line=max(0, ref.line - 1),
|
||||
character=ref.column,
|
||||
),
|
||||
end=lsp.Position(
|
||||
line=max(0, ref.line - 1),
|
||||
character=ref.column + len("foo"),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(locations) == 2
|
||||
# First reference at line 10 (0-indexed = 9)
|
||||
assert locations[0].range.start.line == 9
|
||||
assert locations[0].range.start.character == 5
|
||||
# Second reference at line 20 (0-indexed = 19)
|
||||
assert locations[1].range.start.line == 19
|
||||
assert locations[1].range.start.character == 0
|
||||
|
||||
def test_handler_falls_back_to_global_index(self):
|
||||
"""Handler falls back to global_index when search_engine unavailable."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from codexlens.lsp.handlers import symbol_to_location
|
||||
from codexlens.entities import Symbol
|
||||
|
||||
# Test fallback path converts Symbol to Location
|
||||
symbol = Symbol(
|
||||
name="test_func",
|
||||
kind="function",
|
||||
range=(10, 15),
|
||||
file="/test/file.py",
|
||||
)
|
||||
|
||||
location = symbol_to_location(symbol)
|
||||
assert location is not None
|
||||
# LSP uses 0-based lines
|
||||
assert location.range.start.line == 9
|
||||
assert location.range.end.line == 14
|
||||
210
codex-lens/tests/lsp/test_server.py
Normal file
210
codex-lens/tests/lsp/test_server.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Tests for codex-lens LSP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from codexlens.entities import Symbol
|
||||
|
||||
|
||||
class TestCodexLensLanguageServer:
|
||||
"""Tests for CodexLensLanguageServer."""
|
||||
|
||||
def test_server_import(self):
|
||||
"""Test that server module can be imported."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from codexlens.lsp.server import CodexLensLanguageServer, server
|
||||
|
||||
assert CodexLensLanguageServer is not None
|
||||
assert server is not None
|
||||
assert server.name == "codexlens-lsp"
|
||||
|
||||
def test_server_initialization(self):
|
||||
"""Test server instance creation."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.server import CodexLensLanguageServer
|
||||
|
||||
ls = CodexLensLanguageServer()
|
||||
assert ls.registry is None
|
||||
assert ls.mapper is None
|
||||
assert ls.global_index is None
|
||||
assert ls.search_engine is None
|
||||
assert ls.workspace_root is None
|
||||
|
||||
|
||||
class TestDefinitionHandler:
|
||||
"""Tests for definition handler."""
|
||||
|
||||
def test_definition_lookup(self):
|
||||
"""Test definition lookup returns location for known symbol."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from lsprotocol import types as lsp
|
||||
from codexlens.lsp.handlers import symbol_to_location
|
||||
|
||||
symbol = Symbol(
|
||||
name="test_function",
|
||||
kind="function",
|
||||
range=(10, 15),
|
||||
file="/path/to/file.py",
|
||||
)
|
||||
|
||||
location = symbol_to_location(symbol)
|
||||
|
||||
assert location is not None
|
||||
assert isinstance(location, lsp.Location)
|
||||
# LSP uses 0-based lines
|
||||
assert location.range.start.line == 9
|
||||
assert location.range.end.line == 14
|
||||
|
||||
def test_definition_no_file(self):
|
||||
"""Test definition lookup returns None for symbol without file."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.handlers import symbol_to_location
|
||||
|
||||
symbol = Symbol(
|
||||
name="test_function",
|
||||
kind="function",
|
||||
range=(10, 15),
|
||||
file=None,
|
||||
)
|
||||
|
||||
location = symbol_to_location(symbol)
|
||||
assert location is None
|
||||
|
||||
|
||||
class TestCompletionHandler:
|
||||
"""Tests for completion handler."""
|
||||
|
||||
def test_get_prefix_at_position(self):
|
||||
"""Test extracting prefix at cursor position."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.handlers import _get_prefix_at_position
|
||||
|
||||
document_text = "def hello_world():\n print(hel"
|
||||
|
||||
# Cursor at end of "hel"
|
||||
prefix = _get_prefix_at_position(document_text, 1, 14)
|
||||
assert prefix == "hel"
|
||||
|
||||
# Cursor at beginning of line (after whitespace)
|
||||
prefix = _get_prefix_at_position(document_text, 1, 4)
|
||||
assert prefix == ""
|
||||
|
||||
# Cursor after "he" in "hello_world" - returns text before cursor
|
||||
prefix = _get_prefix_at_position(document_text, 0, 6)
|
||||
assert prefix == "he"
|
||||
|
||||
# Cursor at end of "hello_world"
|
||||
prefix = _get_prefix_at_position(document_text, 0, 15)
|
||||
assert prefix == "hello_world"
|
||||
|
||||
def test_get_word_at_position(self):
|
||||
"""Test extracting word at cursor position."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.handlers import _get_word_at_position
|
||||
|
||||
document_text = "def hello_world():\n print(msg)"
|
||||
|
||||
# Cursor on "hello_world"
|
||||
word = _get_word_at_position(document_text, 0, 6)
|
||||
assert word == "hello_world"
|
||||
|
||||
# Cursor on "print"
|
||||
word = _get_word_at_position(document_text, 1, 6)
|
||||
assert word == "print"
|
||||
|
||||
# Cursor on "msg"
|
||||
word = _get_word_at_position(document_text, 1, 11)
|
||||
assert word == "msg"
|
||||
|
||||
def test_symbol_kind_mapping(self):
|
||||
"""Test symbol kind to completion kind mapping."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from lsprotocol import types as lsp
|
||||
from codexlens.lsp.handlers import _symbol_kind_to_completion_kind
|
||||
|
||||
assert _symbol_kind_to_completion_kind("function") == lsp.CompletionItemKind.Function
|
||||
assert _symbol_kind_to_completion_kind("class") == lsp.CompletionItemKind.Class
|
||||
assert _symbol_kind_to_completion_kind("method") == lsp.CompletionItemKind.Method
|
||||
assert _symbol_kind_to_completion_kind("variable") == lsp.CompletionItemKind.Variable
|
||||
|
||||
# Unknown kind should default to Text
|
||||
assert _symbol_kind_to_completion_kind("unknown") == lsp.CompletionItemKind.Text
|
||||
|
||||
|
||||
class TestWorkspaceSymbolHandler:
|
||||
"""Tests for workspace symbol handler."""
|
||||
|
||||
def test_symbol_kind_to_lsp(self):
|
||||
"""Test symbol kind to LSP SymbolKind mapping."""
|
||||
pytest.importorskip("pygls")
|
||||
pytest.importorskip("lsprotocol")
|
||||
|
||||
from lsprotocol import types as lsp
|
||||
from codexlens.lsp.handlers import _symbol_kind_to_lsp
|
||||
|
||||
assert _symbol_kind_to_lsp("function") == lsp.SymbolKind.Function
|
||||
assert _symbol_kind_to_lsp("class") == lsp.SymbolKind.Class
|
||||
assert _symbol_kind_to_lsp("method") == lsp.SymbolKind.Method
|
||||
assert _symbol_kind_to_lsp("interface") == lsp.SymbolKind.Interface
|
||||
|
||||
# Unknown kind should default to Variable
|
||||
assert _symbol_kind_to_lsp("unknown") == lsp.SymbolKind.Variable
|
||||
|
||||
|
||||
class TestUriConversion:
|
||||
"""Tests for URI path conversion."""
|
||||
|
||||
def test_path_to_uri(self):
|
||||
"""Test path to URI conversion."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.handlers import _path_to_uri
|
||||
|
||||
# Unix path
|
||||
uri = _path_to_uri("/home/user/file.py")
|
||||
assert uri.startswith("file://")
|
||||
assert "file.py" in uri
|
||||
|
||||
def test_uri_to_path(self):
|
||||
"""Test URI to path conversion."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
from codexlens.lsp.handlers import _uri_to_path
|
||||
|
||||
# Basic URI
|
||||
path = _uri_to_path("file:///home/user/file.py")
|
||||
assert path.name == "file.py"
|
||||
|
||||
|
||||
class TestMainEntryPoint:
|
||||
"""Tests for main entry point."""
|
||||
|
||||
def test_main_help(self):
|
||||
"""Test that main shows help without errors."""
|
||||
pytest.importorskip("pygls")
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
# Patch sys.argv to show help
|
||||
with patch.object(sys, 'argv', ['codexlens-lsp', '--help']):
|
||||
from codexlens.lsp.server import main
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
# Help exits with 0
|
||||
assert exc_info.value.code == 0
|
||||
Reference in New Issue
Block a user