1b279d4d10
e2e Tests / e2e (macos-latest) (push) Waiting to run
e2e Tests / e2e (windows-latest) (push) Waiting to run
Nix CI / check (push) Waiting to run
Python CI / Python bindings (macos-latest) (push) Waiting to run
Python CI / Python bindings (windows-latest) (push) Waiting to run
Build & Publish / Build Neovim aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Neovim x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Waiting to run
Build & Publish / Release (push) Blocked by required conditions
Build & Publish / Publish Python wheels to PyPI (push) Blocked by required conditions
Build & Publish / Publish Rust crates (push) Blocked by required conditions
Build & Publish / Publish npm packages (push) Blocked by required conditions
Rust CI / Test (macos-latest) (push) Waiting to run
Rust CI / Test (windows-latest) (push) Waiting to run
Rust CI / Fuzz Tests (macos-latest) (push) Waiting to run
Rust CI / Fuzz Tests (windows-latest) (push) Waiting to run
Build & Publish / Build MCP aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Waiting to run
Lua CI / lua-language-server type check (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Python CI / Python bindings (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 3s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 3s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 2s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 1s
Rust CI / cargo fmt (push) Failing after 1s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 4s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 1s
Rust CI / cargo clippy (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 1s
Stylua / Check lua files using Stylua (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 4s
e2e Tests / e2e (alpine-musl) (push) Successful in 58m54s
392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""Tests for fff Python bindings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.metadata as metadata
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import fff
|
|
from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem
|
|
|
|
|
|
def rel(path: str) -> str:
|
|
return path.replace("\\", "/")
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_dir() -> str:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
(root / "src").mkdir()
|
|
(root / "docs").mkdir()
|
|
(root / "src" / "main.py").write_text(
|
|
"from utils import helper\n\n"
|
|
"def main():\n"
|
|
" value = helper()\n"
|
|
" return value\n"
|
|
)
|
|
(root / "src" / "utils.py").write_text(
|
|
"def helper():\n"
|
|
" return 'alpha'\n"
|
|
)
|
|
(root / "src" / "profile.ts").write_text(
|
|
"class Profile {}\n"
|
|
"function renderProfile() { return new Profile(); }\n"
|
|
)
|
|
(root / "docs" / "guide.txt").write_text(
|
|
"alpha line before\n"
|
|
"needle target\n"
|
|
"omega line after\n"
|
|
)
|
|
(root / "README.md").write_text("# Sample project\n")
|
|
yield str(root)
|
|
|
|
|
|
def test_imports_and_package_version() -> None:
|
|
assert fff.__version__ == metadata.version("fff-search")
|
|
assert GrepCursor(12).offset == 12
|
|
assert "GrepCursor" in fff.__all__
|
|
|
|
|
|
def test_pathlib_base_path(sample_dir: str) -> None:
|
|
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert finder.closed is False
|
|
assert finder.base_path is not None
|
|
assert finder.scan_progress.scanned_files_count >= 1
|
|
result = finder.search("main")
|
|
assert result.total_matched >= 1
|
|
|
|
|
|
async def test_wait_for_scan_async(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert await finder.wait_for_scan(timeout_ms=5000) is True
|
|
assert finder.is_scanning() is False
|
|
result = finder.search("main")
|
|
assert result.total_matched >= 1
|
|
|
|
|
|
async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None:
|
|
import asyncio
|
|
|
|
ticks = 0
|
|
|
|
async def ticker() -> None:
|
|
nonlocal ticks
|
|
while True:
|
|
ticks += 1
|
|
await asyncio.sleep(0.01)
|
|
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
|
|
background = asyncio.ensure_future(ticker())
|
|
try:
|
|
assert await finder.wait_for_scan(timeout_ms=5000) is True
|
|
finally:
|
|
background.cancel()
|
|
# the loop kept running other tasks while we awaited the scan
|
|
assert ticks > 0
|
|
|
|
|
|
async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000) is True
|
|
# already finished, so the async wait resolves immediately to True
|
|
assert await finder.wait_for_scan(timeout_ms=5000) is True
|
|
|
|
|
|
def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None:
|
|
with pytest.raises(TypeError):
|
|
GrepCursor()
|
|
|
|
with pytest.raises(TypeError):
|
|
FileFinder(Path(sample_dir), None)
|
|
|
|
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
with pytest.raises(TypeError):
|
|
finder.search("main", None)
|
|
with pytest.raises(TypeError):
|
|
finder.glob("*.py", None)
|
|
with pytest.raises(TypeError):
|
|
finder.directory_search("src", None)
|
|
with pytest.raises(TypeError):
|
|
finder.mixed_search("src", None)
|
|
with pytest.raises(TypeError):
|
|
finder.grep("needle", "plain")
|
|
with pytest.raises(TypeError):
|
|
finder.multi_grep(["needle"], None)
|
|
|
|
|
|
def test_close_and_context_manager(sample_dir: str) -> None:
|
|
finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert finder.closed is False
|
|
assert finder.base_path is not None
|
|
finder.close()
|
|
assert finder.closed is True
|
|
|
|
with pytest.raises(FFFException, match="File picker not initialized"):
|
|
finder.search("main")
|
|
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder:
|
|
assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert ctx_finder.closed is False
|
|
|
|
assert ctx_finder.closed is True
|
|
with pytest.raises(FFFException, match="File picker not initialized"):
|
|
ctx_finder.search("main")
|
|
|
|
fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
|
|
assert fresh.wait_for_scan_blocking(timeout_ms=5000)
|
|
fresh.close()
|
|
assert fresh.closed is True
|
|
with pytest.raises(FFFException, match="File picker not initialized"):
|
|
fresh.search("main")
|
|
|
|
|
|
def test_reprs(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert repr(finder).startswith("FileFinder(")
|
|
result = finder.search("main")
|
|
assert repr(result).startswith("SearchResult(")
|
|
assert repr(result.items[0]).startswith("FileItem(")
|
|
assert repr(result.scores[0]).startswith("Score(")
|
|
|
|
grep_result = finder.grep("needle")
|
|
assert repr(grep_result).startswith("GrepResult(")
|
|
assert repr(grep_result.items[0]).startswith("GrepMatch(")
|
|
assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(")
|
|
|
|
dir_result = finder.directory_search("src")
|
|
assert repr(dir_result).startswith("DirSearchResult(")
|
|
assert repr(dir_result.items[0]).startswith("DirItem(")
|
|
|
|
mixed = finder.mixed_search("src", page_size=10)
|
|
assert repr(mixed).startswith("MixedSearchResult(")
|
|
|
|
cursor = GrepCursor(42)
|
|
assert repr(cursor) == "GrepCursor(offset=42)"
|
|
|
|
progress = finder.scan_progress
|
|
assert repr(progress).startswith("ScanProgress(")
|
|
|
|
|
|
def test_file_search_scores_and_pagination(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
result = finder.search("main", page_size=1)
|
|
assert result.total_matched >= 1
|
|
assert len(result) == len(result.items) == 1
|
|
assert bool(result) is True
|
|
assert any("main.py" in rel(item.relative_path) for item in result.items)
|
|
|
|
score = result.scores[0]
|
|
assert isinstance(score.total, int)
|
|
assert isinstance(score.exact_match, bool)
|
|
assert isinstance(score.match_type, str)
|
|
|
|
second_page = finder.search("", page_index=1, page_size=1)
|
|
assert len(second_page) == len(second_page.items) == 1
|
|
|
|
empty = finder.search("definitely_no_such_file_xyz")
|
|
assert len(empty) == 0
|
|
assert bool(empty) is False
|
|
|
|
|
|
def test_glob_variants(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
py_files = finder.glob("*.py")
|
|
assert {Path(rel(item.relative_path)).name for item in py_files.items} == {
|
|
"main.py",
|
|
"utils.py",
|
|
}
|
|
|
|
src_files = finder.glob("src/*.py")
|
|
assert src_files.total_matched == 2
|
|
|
|
md_files = finder.glob("*.md")
|
|
assert md_files.total_matched == 1
|
|
assert rel(md_files.items[0].relative_path) == "README.md"
|
|
|
|
|
|
def test_directory_and_mixed_search(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
dirs = finder.directory_search("src")
|
|
assert dirs.total_matched >= 1
|
|
assert len(dirs) == len(dirs.items)
|
|
assert bool(dirs) is True
|
|
assert any(rel(item.relative_path).startswith("src") for item in dirs.items)
|
|
|
|
mixed = finder.mixed_search("src", page_size=10)
|
|
assert mixed.total_matched >= 3
|
|
assert len(mixed) == len(mixed.items)
|
|
assert bool(mixed) is True
|
|
assert any(isinstance(item, MixedDirItem) for item in mixed.items)
|
|
assert any(isinstance(item, MixedFileItem) for item in mixed.items)
|
|
|
|
# max_access_frecency is the same field on both dir item types, so the
|
|
# values must agree for a shared directory regardless of which search
|
|
# produced them.
|
|
dir_frecency = {
|
|
rel(item.relative_path): item.max_access_frecency for item in dirs.items
|
|
}
|
|
for item in mixed.items:
|
|
if isinstance(item, MixedDirItem):
|
|
path = rel(item.relative_path)
|
|
if path in dir_frecency:
|
|
assert item.max_access_frecency == dir_frecency[path]
|
|
|
|
|
|
def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
plain = finder.grep("needle", before_context=1, after_context=1)
|
|
assert plain.total_matched == 1
|
|
assert len(plain) == len(plain.items) == 1
|
|
assert bool(plain) is True
|
|
match = plain.items[0]
|
|
assert rel(match.relative_path) == "docs/guide.txt"
|
|
assert match.line_content == "needle target"
|
|
assert match.context_before == ["alpha line before"]
|
|
assert match.context_after == ["omega line after"]
|
|
assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)]
|
|
|
|
regex = finder.grep(r"def \w+", mode="regex")
|
|
assert regex.total_matched == 2
|
|
assert {Path(rel(m.relative_path)).name for m in regex.items} == {
|
|
"main.py",
|
|
"utils.py",
|
|
}
|
|
|
|
fuzzy = finder.grep("df mn", mode="fuzzy")
|
|
assert fuzzy.total_matched >= 1
|
|
assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items)
|
|
assert any(m.fuzzy_score is not None for m in fuzzy.items)
|
|
|
|
invalid = finder.grep("[", mode="regex")
|
|
assert invalid.regex_fallback_error is not None
|
|
|
|
|
|
def test_grep_invalid_mode_raises(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
with pytest.raises(FFFException, match="invalid grep mode"):
|
|
finder.grep("needle", mode="typo")
|
|
with pytest.raises(FFFException, match="invalid grep mode"):
|
|
finder.multi_grep(["needle"], mode="typo")
|
|
|
|
|
|
def test_grep_cursor_paginates_by_file(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
first = finder.grep("def", page_limit=1)
|
|
assert first.total_matched >= 1
|
|
assert first.next_file_offset > 0
|
|
assert first.has_more is True
|
|
assert first.next_cursor() is not None
|
|
assert first.next_cursor().offset == first.next_file_offset
|
|
|
|
second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1)
|
|
assert second.total_matched >= 1
|
|
|
|
first_paths = {rel(m.relative_path) for m in first.items}
|
|
second_paths = {rel(m.relative_path) for m in second.items}
|
|
assert first_paths.isdisjoint(second_paths)
|
|
|
|
exhausted = finder.grep("nonexistent_xyz")
|
|
assert exhausted.has_more is False
|
|
assert exhausted.next_cursor() is None
|
|
assert len(exhausted) == 0
|
|
assert bool(exhausted) is False
|
|
|
|
|
|
def test_multi_grep_and_error_handling(sample_dir: str) -> None:
|
|
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
result = finder.multi_grep(("def main", "def helper"))
|
|
assert result.total_matched == 2
|
|
assert {Path(rel(m.relative_path)).name for m in result.items} == {
|
|
"main.py",
|
|
"utils.py",
|
|
}
|
|
|
|
with pytest.raises(FFFException, match="patterns must not be empty"):
|
|
finder.multi_grep([])
|
|
|
|
|
|
def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None:
|
|
history_db = tmp_path / "history"
|
|
selected_file = Path(sample_dir) / "src" / "main.py"
|
|
|
|
with FileFinder(
|
|
sample_dir,
|
|
history_db_path=history_db,
|
|
watch=False,
|
|
enable_content_indexing=False,
|
|
) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert finder.track_query("main", selected_file)
|
|
assert finder.get_historical_query(0) == "main"
|
|
|
|
with FileFinder(
|
|
sample_dir,
|
|
history_db_path=history_db,
|
|
watch=False,
|
|
enable_content_indexing=False,
|
|
) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
assert finder.get_historical_query(0) == "main"
|
|
|
|
|
|
def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None:
|
|
other = tmp_path / "other-project"
|
|
other.mkdir()
|
|
(other / "other.py").write_text("def other():\n return 42\n")
|
|
|
|
frecency_db = tmp_path / "frecency"
|
|
history_db = tmp_path / "history"
|
|
with FileFinder(
|
|
sample_dir,
|
|
frecency_db_path=frecency_db,
|
|
history_db_path=history_db,
|
|
watch=False,
|
|
enable_content_indexing=False,
|
|
) as finder:
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
|
|
health = finder.health_check(Path(sample_dir))
|
|
assert health["file_picker"]["initialized"] is True
|
|
assert health["frecency"]["initialized"] is True
|
|
assert health["query_tracker"]["initialized"] is True
|
|
|
|
# With no explicit path, the check inspects the indexed base path
|
|
# rather than the process cwd.
|
|
default_health = finder.health_check()
|
|
assert default_health["file_picker"]["base_path"] is not None
|
|
assert "error" not in default_health.get("git", {}) or default_health["git"][
|
|
"error"
|
|
] != "could not determine current directory"
|
|
|
|
finder.reindex(str(other))
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
result = finder.search("other")
|
|
assert result.total_matched == 1
|
|
assert rel(result.items[0].relative_path) == "other.py"
|
|
|
|
finder.reindex(Path(other))
|
|
assert finder.wait_for_scan_blocking(timeout_ms=5000)
|
|
result2 = finder.search("other")
|
|
assert result2.total_matched == 1
|