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
4 changes: 2 additions & 2 deletions benchmarks/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ def apply_task_filters(


def path_matches(file_path: str, target_path: str) -> bool:
"""Return True if file_path ends with target_path (file-level match, no line span)."""
"""Return True if either path is a suffix of the other (handles absolute vs relative paths)."""
norm_file = file_path.replace("\\", "/")
norm_target = target_path.replace("\\", "/")
return norm_file == norm_target or norm_file.endswith(f"/{norm_target}")
return norm_file == norm_target or norm_file.endswith(f"/{norm_target}") or norm_target.endswith(f"/{norm_file}")


def target_matches_location(file_path: str, start_line: int, end_line: int, target: Target) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion src/semble/index/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ def create_index_from_path(
args = BasicArgs()
semantic_index = SelectableBasicBackend(embeddings, args)
else:
raise ValueError("Unable to create index.")
raise ValueError(f"No supported files found under {path}.")

return bm25_index, semantic_index, chunks
26 changes: 15 additions & 11 deletions src/semble/index/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from semble.index.create import create_index_from_path
from semble.index.dense import SelectableBasicBackend, load_model
from semble.index.sparse import selector_to_mask
from semble.search import search_bm25, search_hybrid, search_semantic
from semble.types import Chunk, Encoder, IndexStats, SearchMode, SearchResult

Expand All @@ -25,21 +24,18 @@ def __init__(
bm25_index: BM25,
semantic_index: SelectableBasicBackend,
chunks: list[Chunk],
index_root: Path,
) -> None:
"""Configure the index.

:param model: Embedding model to use.
:param bm25_index: The bm25 index.
:param semantic_index: The semantic index.
:param chunks: The found chunks.
:param index_root: The root of the index.
"""
self.model: Encoder = model
self.chunks: list[Chunk] = chunks
self._bm25_index: BM25 = bm25_index
self._semantic_index: SelectableBasicBackend = semantic_index
self._index_root: Path = index_root
self.file_mapping, self.language_mapping = self._populate_mapping()

def _populate_mapping(self) -> tuple[dict[str, list[int]], dict[str, list[int]]]:
Expand Down Expand Up @@ -84,15 +80,23 @@ def from_path(
:param extensions: File extensions to include. Defaults to a standard set of code extensions.
:param ignore: Directory names to skip. Defaults to common VCS and build dirs.
:param include_docs: If True, also index documentation files (.md, .yaml, etc.).
:return: An indexed SembleIndex.
:return: An indexed SembleIndex. Chunk file paths are relative to ``path``.
:raises FileNotFoundError: If `path` does not exist.
:raises NotADirectoryError: If `path` exists but is not a directory.
:raises ValueError: If `path` is a directory but contains no supported files.
"""
model = model or load_model()
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
if not path.is_dir():
raise NotADirectoryError(f"Path is not a directory: {path}")
path = path.resolve()
bm25, vicinity, chunks = create_index_from_path(
path, model=model, extensions=extensions, ignore=ignore, include_docs=include_docs
path, model=model, extensions=extensions, ignore=ignore, include_docs=include_docs, display_root=path
)

index = SembleIndex(model, bm25, vicinity, chunks, path)
index = SembleIndex(model, bm25, vicinity, chunks)

return index

Expand All @@ -118,7 +122,8 @@ def from_git(
:raises RuntimeError: If git is not on PATH or the clone fails.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
cmd = ["git", "clone", "--depth", "1", *(["--branch", ref] if ref else []), url, tmp_dir]
# `--` prevents `url` from being interpreted as a git option (e.g. `--upload-pack=...`).
cmd = ["git", "clone", "--depth", "1", *(["--branch", ref] if ref else []), "--", url, tmp_dir]
try:
result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL)
except FileNotFoundError:
Expand All @@ -136,16 +141,15 @@ def from_git(
display_root=resolved_path,
)

index = SembleIndex(model, bm25, vicinity, chunks, resolved_path)
index = SembleIndex(model, bm25, vicinity, chunks)

return index

def find_related(self, file_path: str, line: int, top_k: int = 5) -> list[SearchResult]:
"""Return chunks semantically similar to the chunk at the given file location.

:param file_path: Path to the file, in the same format stored by the index.
For indexes built with `from_path` this is an absolute path; for
indexes built with `from_git` this is a repo-relative path
For both `from_path` and `from_git` this is a repo-relative path
(e.g. ``src/foo.py``). Use `chunk.file_path` from a prior search result
to guarantee the correct format.
:param line: Line number (1-indexed) used to identify the source chunk.
Expand Down
8 changes: 7 additions & 1 deletion src/semble/mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import re
from pathlib import Path
from typing import Annotated, Literal

Expand Down Expand Up @@ -147,9 +148,14 @@ async def get(self, source: str, ref: str | None = None) -> SembleIndex:
raise


_GIT_URL_SCHEMES = ("https://", "http://", "ssh://", "git://", "git+ssh://", "file://")
# scp-like syntax: [user@]host:path, where host has no '/' before the ':'.
_SCP_GIT_URL_RE = re.compile(r"^[\w.-]+@[\w.-]+:(?!/)")


def _is_git_url(path: str) -> bool:
"""Return True if path looks like a remote git URL rather than a local path."""
return path.startswith(("https://", "http://", "git@", "ssh://"))
return path.startswith(_GIT_URL_SCHEMES) or _SCP_GIT_URL_RE.match(path) is not None


def _format_results(header: str, results: list[SearchResult]) -> str:
Expand Down
Loading