diffrag 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffrag/__init__.py +101 -0
- diffrag/__main__.py +3 -0
- diffrag/ai/__init__.py +15 -0
- diffrag/ai/base.py +106 -0
- diffrag/ai/ollama.py +144 -0
- diffrag/ai/openai_compat.py +193 -0
- diffrag/cli/__init__.py +5 -0
- diffrag/cli/main.py +440 -0
- diffrag/config/__init__.py +21 -0
- diffrag/config/settings.py +190 -0
- diffrag/data/config.example.toml +53 -0
- diffrag/diff/__init__.py +11 -0
- diffrag/diff/git.py +227 -0
- diffrag/diff/models.py +76 -0
- diffrag/exceptions.py +21 -0
- diffrag/indexing/__init__.py +5 -0
- diffrag/indexing/indexer.py +387 -0
- diffrag/indexing/splitter.py +296 -0
- diffrag/py.typed +0 -0
- diffrag/review/__init__.py +5 -0
- diffrag/review/prompts.py +75 -0
- diffrag/review/reviewer.py +322 -0
- diffrag-0.2.3.dist-info/METADATA +425 -0
- diffrag-0.2.3.dist-info/RECORD +26 -0
- diffrag-0.2.3.dist-info/WHEEL +4 -0
- diffrag-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""ChromaDB-backed repository indexer for RAG context retrieval."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import chromadb
|
|
10
|
+
import pathspec
|
|
11
|
+
|
|
12
|
+
from ..ai.base import EmbeddingClient
|
|
13
|
+
from ..exceptions import IndexingError
|
|
14
|
+
from .splitter import CodeSplitter
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_EXCLUDED_PATTERNS = [
|
|
19
|
+
".git",
|
|
20
|
+
".venv",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
"node_modules",
|
|
23
|
+
".idea",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_COLLECTION_NAME = "repo_index"
|
|
31
|
+
_EMBED_BATCH_SIZE = 32
|
|
32
|
+
_MAX_FILE_SIZE = 1_000_000 # 1 MB
|
|
33
|
+
_META_FILENAME = ".index_meta.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _ScanResult:
|
|
38
|
+
files: list[Path]
|
|
39
|
+
n_gitignore: int = 0
|
|
40
|
+
n_pattern: int = 0
|
|
41
|
+
n_oversized: int = 0
|
|
42
|
+
n_binary: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ContextChunk:
|
|
47
|
+
"""A retrieved chunk of repository context.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
content: Text content of the chunk.
|
|
51
|
+
file_path: Repository-relative path of the source file.
|
|
52
|
+
chunk_index: Zero-based index of this chunk within the source file.
|
|
53
|
+
distance: Cosine distance to the query vector (lower = more similar).
|
|
54
|
+
language: Detected language (e.g. ``"python"``), or ``None``.
|
|
55
|
+
node_type: AST node category (``"function"``, ``"class"``, ``"preamble"``),
|
|
56
|
+
or ``None`` when no syntax-aware split was performed.
|
|
57
|
+
symbol_name: Qualified symbol name (e.g. ``"MyClass.my_method"``), or ``None``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
content: str
|
|
61
|
+
file_path: str
|
|
62
|
+
chunk_index: int
|
|
63
|
+
distance: float
|
|
64
|
+
language: str | None = None
|
|
65
|
+
node_type: str | None = None
|
|
66
|
+
symbol_name: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RepoIndexer:
|
|
70
|
+
"""Builds and queries a ChromaDB vector index of a local repository.
|
|
71
|
+
|
|
72
|
+
Files are read, split into overlapping character chunks, embedded, and
|
|
73
|
+
stored in a persistent ChromaDB collection. At query time, the query
|
|
74
|
+
text is embedded and the nearest chunks are returned.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
db_path: Directory where the ChromaDB database is persisted.
|
|
78
|
+
embedding_client: Client used to compute text embeddings.
|
|
79
|
+
chunk_size: Maximum characters per chunk.
|
|
80
|
+
chunk_overlap: Characters of overlap between consecutive chunks.
|
|
81
|
+
excluded_patterns: Directory or file name segments to skip.
|
|
82
|
+
max_file_size: Files larger than this (bytes) are skipped.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
db_path: Path,
|
|
88
|
+
embedding_client: EmbeddingClient,
|
|
89
|
+
chunk_size: int = 1024,
|
|
90
|
+
chunk_overlap: int = 128,
|
|
91
|
+
excluded_patterns: list[str] | None = None,
|
|
92
|
+
max_file_size: int = _MAX_FILE_SIZE,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._db_path = db_path
|
|
95
|
+
self._embedding_client = embedding_client
|
|
96
|
+
self._chunk_size = chunk_size
|
|
97
|
+
self._chunk_overlap = chunk_overlap
|
|
98
|
+
self._excluded_patterns = set(excluded_patterns or _DEFAULT_EXCLUDED_PATTERNS)
|
|
99
|
+
self._max_file_size = max_file_size
|
|
100
|
+
self._splitter = CodeSplitter(chunk_size, chunk_overlap)
|
|
101
|
+
|
|
102
|
+
self._chroma = chromadb.PersistentClient(path=str(db_path))
|
|
103
|
+
self._collection = self._chroma.get_or_create_collection(_COLLECTION_NAME)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Public API
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Staleness helpers
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def is_stale(self, repo_path: Path, commit_hash: str) -> bool:
|
|
114
|
+
"""Return ``True`` if the index must be rebuilt.
|
|
115
|
+
|
|
116
|
+
The index is considered stale when the collection is empty, the stored
|
|
117
|
+
commit hash differs from *commit_hash*, or the stored repository path
|
|
118
|
+
differs from *repo_path*.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
repo_path: Root directory of the repository being reviewed.
|
|
122
|
+
commit_hash: Current HEAD commit SHA of the repository.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
``True`` when a rebuild is required, ``False`` when the cached index
|
|
126
|
+
is still valid.
|
|
127
|
+
"""
|
|
128
|
+
if self.document_count == 0:
|
|
129
|
+
return True
|
|
130
|
+
meta = self._read_meta()
|
|
131
|
+
return meta.get("commit_hash") != commit_hash or meta.get("repo_path") != str(
|
|
132
|
+
repo_path.resolve()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _meta_path(self) -> Path:
|
|
136
|
+
return self._db_path / _META_FILENAME
|
|
137
|
+
|
|
138
|
+
def _read_meta(self) -> dict:
|
|
139
|
+
try:
|
|
140
|
+
return json.loads(self._meta_path().read_text())
|
|
141
|
+
except (OSError, json.JSONDecodeError):
|
|
142
|
+
return {}
|
|
143
|
+
|
|
144
|
+
def _write_meta(self, repo_path: Path, commit_hash: str) -> None:
|
|
145
|
+
self._db_path.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
self._meta_path().write_text(
|
|
147
|
+
json.dumps({"commit_hash": commit_hash, "repo_path": str(repo_path.resolve())})
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Public API
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def document_count(self) -> int:
|
|
156
|
+
"""Number of chunks currently stored in the index.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Total chunk count in the ChromaDB collection.
|
|
160
|
+
"""
|
|
161
|
+
return self._collection.count()
|
|
162
|
+
|
|
163
|
+
async def build_index(
|
|
164
|
+
self,
|
|
165
|
+
repo_path: Path,
|
|
166
|
+
on_progress: Callable[[str], None] | None = None,
|
|
167
|
+
commit_hash: str | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Rebuild the vector index from scratch for *repo_path*.
|
|
170
|
+
|
|
171
|
+
Deletes any existing index for this collection before re-indexing.
|
|
172
|
+
If *commit_hash* is provided, it is persisted to ``.index_meta.json``
|
|
173
|
+
so that future runs can skip rebuilding when the repo has not changed.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
repo_path: Root directory of the repository to index.
|
|
177
|
+
on_progress: Optional callback invoked with a human-readable status
|
|
178
|
+
string at each major step. Messages ending in ``"..."`` indicate
|
|
179
|
+
an ongoing operation; others mark a completed milestone.
|
|
180
|
+
commit_hash: HEAD commit SHA to associate with this index snapshot.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
IndexingError: If a critical error occurs during indexing.
|
|
184
|
+
"""
|
|
185
|
+
_p = on_progress or (lambda _: None)
|
|
186
|
+
log.info("Building index for repository at %s", repo_path)
|
|
187
|
+
|
|
188
|
+
self._chroma.delete_collection(_COLLECTION_NAME)
|
|
189
|
+
self._collection = self._chroma.create_collection(_COLLECTION_NAME)
|
|
190
|
+
|
|
191
|
+
_p("Scanning repository...")
|
|
192
|
+
scan = self._gather_files(repo_path)
|
|
193
|
+
files = scan.files
|
|
194
|
+
log.info(
|
|
195
|
+
"File scan: %d indexed, %d .gitignore, %d patterns, %d binary, %d oversized",
|
|
196
|
+
len(files),
|
|
197
|
+
scan.n_gitignore,
|
|
198
|
+
scan.n_pattern,
|
|
199
|
+
scan.n_binary,
|
|
200
|
+
scan.n_oversized,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
all_texts: list[str] = []
|
|
204
|
+
all_metas: list[dict] = []
|
|
205
|
+
all_ids: list[str] = []
|
|
206
|
+
|
|
207
|
+
for file_path in files:
|
|
208
|
+
rel = str(file_path.relative_to(repo_path))
|
|
209
|
+
try:
|
|
210
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
log.warning("Skipping %s: %s", rel, exc)
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
for idx, chunk in enumerate(self._splitter.split(content, rel)):
|
|
216
|
+
all_texts.append(chunk.content)
|
|
217
|
+
all_metas.append(
|
|
218
|
+
{
|
|
219
|
+
"file": rel,
|
|
220
|
+
"chunk": idx,
|
|
221
|
+
"language": chunk.language or "",
|
|
222
|
+
"node_type": chunk.node_type or "",
|
|
223
|
+
"symbol_name": chunk.symbol_name or "",
|
|
224
|
+
"start_line": chunk.start_line,
|
|
225
|
+
"end_line": chunk.end_line,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
all_ids.append(f"{rel}::chunk{idx}")
|
|
229
|
+
|
|
230
|
+
total_batches = max(1, len(all_texts) // _EMBED_BATCH_SIZE + 1)
|
|
231
|
+
_p(
|
|
232
|
+
f"Found {len(files)} files "
|
|
233
|
+
f"(excluded: {scan.n_gitignore} .gitignore · "
|
|
234
|
+
f"{scan.n_pattern} patterns · "
|
|
235
|
+
f"{scan.n_binary} binary · "
|
|
236
|
+
f"{scan.n_oversized} oversized) — {len(all_texts)} chunks"
|
|
237
|
+
)
|
|
238
|
+
log.info("Embedding %d chunks in batches of %d", len(all_texts), _EMBED_BATCH_SIZE)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
for batch_num, start in enumerate(range(0, len(all_texts), _EMBED_BATCH_SIZE), 1):
|
|
242
|
+
_p(f"Embedding chunks: batch {batch_num}/{total_batches}...")
|
|
243
|
+
batch_texts = all_texts[start : start + _EMBED_BATCH_SIZE]
|
|
244
|
+
batch_metas = all_metas[start : start + _EMBED_BATCH_SIZE]
|
|
245
|
+
batch_ids = all_ids[start : start + _EMBED_BATCH_SIZE]
|
|
246
|
+
|
|
247
|
+
resp = await self._embedding_client.embed(batch_texts)
|
|
248
|
+
self._collection.add(
|
|
249
|
+
documents=batch_texts,
|
|
250
|
+
embeddings=resp.embeddings, # type: ignore
|
|
251
|
+
metadatas=batch_metas, # type: ignore
|
|
252
|
+
ids=batch_ids,
|
|
253
|
+
)
|
|
254
|
+
log.debug("Indexed chunks %d–%d", start, start + len(batch_texts) - 1)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
raise IndexingError(f"Failed to build index: {exc}") from exc
|
|
257
|
+
|
|
258
|
+
count = self._collection.count()
|
|
259
|
+
if commit_hash is not None:
|
|
260
|
+
self._write_meta(repo_path, commit_hash)
|
|
261
|
+
_p(f"Index built: {count} chunks from {len(files)} files")
|
|
262
|
+
log.info("Index built: %d total chunks", count)
|
|
263
|
+
|
|
264
|
+
async def query(self, text: str, top_k: int = 5) -> list[ContextChunk]:
|
|
265
|
+
"""Retrieve the *top_k* most relevant chunks for a query.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
text: Query text; will be embedded before searching.
|
|
269
|
+
top_k: Number of results to return.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
List of :class:`ContextChunk` objects ordered by relevance.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
IndexingError: If the embedding call or ChromaDB query fails.
|
|
276
|
+
"""
|
|
277
|
+
if self._collection.count() == 0:
|
|
278
|
+
log.warning("Index is empty — no context will be retrieved")
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
resp = await self._embedding_client.embed([text])
|
|
283
|
+
results = self._collection.query(
|
|
284
|
+
query_embeddings=resp.embeddings, # type: ignore
|
|
285
|
+
n_results=min(top_k, self._collection.count()),
|
|
286
|
+
include=["documents", "metadatas", "distances"],
|
|
287
|
+
)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
raise IndexingError(f"Failed to query index: {exc}") from exc
|
|
290
|
+
|
|
291
|
+
def _str(v: object) -> str | None:
|
|
292
|
+
return str(v) if isinstance(v, str) and v else None
|
|
293
|
+
|
|
294
|
+
chunks: list[ContextChunk] = []
|
|
295
|
+
for doc, meta, dist in zip(
|
|
296
|
+
results["documents"][0], # type: ignore
|
|
297
|
+
results["metadatas"][0], # type: ignore
|
|
298
|
+
results["distances"][0], # type: ignore
|
|
299
|
+
strict=False,
|
|
300
|
+
):
|
|
301
|
+
chunks.append(
|
|
302
|
+
ContextChunk(
|
|
303
|
+
content=str(doc),
|
|
304
|
+
file_path=str(meta.get("file", "")),
|
|
305
|
+
chunk_index=int(meta.get("chunk", 0)), # type: ignore
|
|
306
|
+
distance=float(dist),
|
|
307
|
+
language=_str(meta.get("language")),
|
|
308
|
+
node_type=_str(meta.get("node_type")),
|
|
309
|
+
symbol_name=_str(meta.get("symbol_name")),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
return chunks
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
# Internal helpers
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _load_gitignore_spec(repo_path: Path) -> "pathspec.PathSpec | None":
|
|
320
|
+
"""Return a compiled gitignore spec for *repo_path*, or ``None``.
|
|
321
|
+
|
|
322
|
+
Reads the root ``.gitignore`` of the repository. If the file does not
|
|
323
|
+
exist the method returns ``None`` and no gitignore filtering is applied.
|
|
324
|
+
"""
|
|
325
|
+
gitignore = repo_path / ".gitignore"
|
|
326
|
+
if not gitignore.is_file():
|
|
327
|
+
return None
|
|
328
|
+
lines = gitignore.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
329
|
+
return pathspec.PathSpec.from_lines("gitignore", lines)
|
|
330
|
+
|
|
331
|
+
def _gather_files(self, repo_path: Path) -> _ScanResult:
|
|
332
|
+
"""Collect all indexable files under *repo_path*.
|
|
333
|
+
|
|
334
|
+
Files are excluded if they match any pattern in the repository's root
|
|
335
|
+
``.gitignore`` (via :meth:`_load_gitignore_spec`) or any segment of
|
|
336
|
+
their path matches an entry in :attr:`_excluded_patterns`.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
repo_path: Root directory to search.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
:class:`_ScanResult` with the accepted file list and per-reason
|
|
343
|
+
exclusion counts. Pass ``--verbose`` on the CLI to see per-file
|
|
344
|
+
``DEBUG`` log lines.
|
|
345
|
+
"""
|
|
346
|
+
gitspec = self._load_gitignore_spec(repo_path)
|
|
347
|
+
files: list[Path] = []
|
|
348
|
+
n_gitignore = n_pattern = n_oversized = n_binary = 0
|
|
349
|
+
for path in repo_path.rglob("*"):
|
|
350
|
+
if not path.is_file():
|
|
351
|
+
continue
|
|
352
|
+
rel = str(path.relative_to(repo_path))
|
|
353
|
+
if gitspec is not None and gitspec.match_file(rel):
|
|
354
|
+
n_gitignore += 1
|
|
355
|
+
log.debug("Excluded by .gitignore: %s", rel)
|
|
356
|
+
continue
|
|
357
|
+
if any(part in self._excluded_patterns for part in path.parts):
|
|
358
|
+
n_pattern += 1
|
|
359
|
+
log.debug("Excluded by pattern: %s", rel)
|
|
360
|
+
continue
|
|
361
|
+
if path.stat().st_size > self._max_file_size:
|
|
362
|
+
n_oversized += 1
|
|
363
|
+
log.debug("Skipped (too large): %s", rel)
|
|
364
|
+
continue
|
|
365
|
+
if self._is_binary(path):
|
|
366
|
+
n_binary += 1
|
|
367
|
+
log.debug("Skipped (binary): %s", rel)
|
|
368
|
+
continue
|
|
369
|
+
log.debug("Indexing: %s", rel)
|
|
370
|
+
files.append(path)
|
|
371
|
+
return _ScanResult(files, n_gitignore, n_pattern, n_oversized, n_binary)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _is_binary(path: Path) -> bool:
|
|
375
|
+
"""Return ``True`` if the file appears to contain binary content.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
path: File to inspect.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
``True`` when a null byte is found in the first 8 KiB.
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
with path.open("rb") as fh:
|
|
385
|
+
return b"\x00" in fh.read(8192)
|
|
386
|
+
except OSError:
|
|
387
|
+
return True
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Syntax-aware code chunking for RAG indexing.
|
|
2
|
+
|
|
3
|
+
Extension guide
|
|
4
|
+
---------------
|
|
5
|
+
To add syntax-aware splitting for a new language:
|
|
6
|
+
|
|
7
|
+
1. Write a class that implements :class:`LanguageSplitter` (one method: ``split``).
|
|
8
|
+
2. Add the file extension → language name mapping to :data:`EXTENSION_MAP`.
|
|
9
|
+
3. Add the language name → splitter instance mapping to :data:`LANGUAGE_SPLITTERS`.
|
|
10
|
+
|
|
11
|
+
Nothing else needs to change. :class:`CodeSplitter` automatically falls back to
|
|
12
|
+
character-based splitting for any extension not in the registry, or when a
|
|
13
|
+
registered splitter returns ``[]`` (indicating a parse failure).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SplitChunk:
|
|
24
|
+
"""A chunk of source text with optional symbol-level metadata.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
content: Raw text content of the chunk.
|
|
28
|
+
start_line: 1-based line number of the first line in the source file.
|
|
29
|
+
end_line: 1-based line number of the last line in the source file.
|
|
30
|
+
language: Detected language (e.g. ``"python"``), or ``None``.
|
|
31
|
+
node_type: AST node category (``"function"``, ``"class"``, ``"preamble"``),
|
|
32
|
+
or ``None`` when no syntax-aware split was performed.
|
|
33
|
+
symbol_name: Qualified name of the symbol (e.g. ``"MyClass.my_method"``),
|
|
34
|
+
or ``None``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
content: str
|
|
38
|
+
start_line: int
|
|
39
|
+
end_line: int
|
|
40
|
+
language: str | None = None
|
|
41
|
+
node_type: str | None = None
|
|
42
|
+
symbol_name: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _char_split(
|
|
46
|
+
content: str,
|
|
47
|
+
chunk_size: int,
|
|
48
|
+
chunk_overlap: int,
|
|
49
|
+
*,
|
|
50
|
+
language: str | None = None,
|
|
51
|
+
node_type: str | None = None,
|
|
52
|
+
symbol_name: str | None = None,
|
|
53
|
+
line_offset: int = 0,
|
|
54
|
+
) -> list[SplitChunk]:
|
|
55
|
+
"""Character-based line-boundary splitting with optional metadata tagging.
|
|
56
|
+
|
|
57
|
+
This is the universal fallback used for unsupported file types and for
|
|
58
|
+
oversized AST nodes that cannot fit in a single chunk.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
content: Source text to split.
|
|
62
|
+
chunk_size: Maximum characters per chunk.
|
|
63
|
+
chunk_overlap: Characters of overlap between consecutive chunks.
|
|
64
|
+
language: Language tag to attach to every produced chunk.
|
|
65
|
+
node_type: Node-type tag to attach to every produced chunk.
|
|
66
|
+
symbol_name: Symbol name to attach to every produced chunk.
|
|
67
|
+
line_offset: Number of source-file lines before the first line of
|
|
68
|
+
*content*. Use ``node.lineno - 1`` when splitting a node
|
|
69
|
+
extracted from a larger file so that reported line numbers are
|
|
70
|
+
relative to the original file, not the node's text.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of non-empty :class:`SplitChunk` objects.
|
|
74
|
+
"""
|
|
75
|
+
if not content.strip():
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
if len(content) <= chunk_size:
|
|
79
|
+
return [
|
|
80
|
+
SplitChunk(
|
|
81
|
+
content=content,
|
|
82
|
+
start_line=line_offset + 1,
|
|
83
|
+
end_line=line_offset + content.count("\n") + 1,
|
|
84
|
+
language=language,
|
|
85
|
+
node_type=node_type,
|
|
86
|
+
symbol_name=symbol_name,
|
|
87
|
+
)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
chunks: list[SplitChunk] = []
|
|
91
|
+
start = 0
|
|
92
|
+
while start < len(content):
|
|
93
|
+
end = min(start + chunk_size, len(content))
|
|
94
|
+
if end < len(content):
|
|
95
|
+
newline_idx = content.rfind("\n", start, end)
|
|
96
|
+
if newline_idx > start:
|
|
97
|
+
end = newline_idx + 1
|
|
98
|
+
chunk_text = content[start:end]
|
|
99
|
+
if chunk_text.strip():
|
|
100
|
+
chunks.append(
|
|
101
|
+
SplitChunk(
|
|
102
|
+
content=chunk_text,
|
|
103
|
+
start_line=line_offset + content.count("\n", 0, start) + 1,
|
|
104
|
+
end_line=line_offset + content.count("\n", 0, end) + 1,
|
|
105
|
+
language=language,
|
|
106
|
+
node_type=node_type,
|
|
107
|
+
symbol_name=symbol_name,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
start = max(start + 1, end - chunk_overlap)
|
|
111
|
+
|
|
112
|
+
return chunks
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@runtime_checkable
|
|
116
|
+
class LanguageSplitter(Protocol):
|
|
117
|
+
"""Interface for language-specific AST splitters.
|
|
118
|
+
|
|
119
|
+
Implementations receive raw source content and return semantically
|
|
120
|
+
meaningful chunks. Return an empty list to signal a parse failure —
|
|
121
|
+
:class:`CodeSplitter` will then fall back to :func:`_char_split`.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def split(self, content: str, chunk_size: int, chunk_overlap: int) -> list[SplitChunk]:
|
|
125
|
+
"""Split *content* into semantically meaningful chunks.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
content: Raw source text.
|
|
129
|
+
chunk_size: Maximum characters per chunk.
|
|
130
|
+
chunk_overlap: Characters of overlap when char-splitting oversized nodes.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Non-empty list of chunks, or ``[]`` to signal a parse failure.
|
|
134
|
+
"""
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PythonASTSplitter:
|
|
139
|
+
"""Splits Python source at function and class boundaries using :mod:`ast`.
|
|
140
|
+
|
|
141
|
+
Top-level :class:`ast.FunctionDef`, :class:`ast.AsyncFunctionDef`, and
|
|
142
|
+
:class:`ast.ClassDef` nodes each become their own chunk. All other
|
|
143
|
+
top-level statements (imports, assignments, module docstrings) are
|
|
144
|
+
batched into a single ``"preamble"`` chunk that precedes the first
|
|
145
|
+
function or class.
|
|
146
|
+
|
|
147
|
+
Nodes that exceed *chunk_size* are character-split while preserving
|
|
148
|
+
their ``language`` and ``node_type`` metadata.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def split(self, content: str, chunk_size: int, chunk_overlap: int) -> list[SplitChunk]:
|
|
152
|
+
"""Return AST-boundary chunks for *content*, or ``[]`` on :exc:`SyntaxError`.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
content: Python source text.
|
|
156
|
+
chunk_size: Maximum characters per chunk.
|
|
157
|
+
chunk_overlap: Characters of overlap when char-splitting oversized nodes.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
List of chunks, or ``[]`` if the source cannot be parsed.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
tree = ast.parse(content)
|
|
164
|
+
except SyntaxError:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
lines = content.splitlines(keepends=True)
|
|
168
|
+
result: list[SplitChunk] = []
|
|
169
|
+
preamble: list[str] = []
|
|
170
|
+
preamble_start: int = 1
|
|
171
|
+
|
|
172
|
+
def flush_preamble() -> None:
|
|
173
|
+
nonlocal preamble, preamble_start
|
|
174
|
+
text = "".join(preamble)
|
|
175
|
+
if text.strip():
|
|
176
|
+
result.append(
|
|
177
|
+
SplitChunk(
|
|
178
|
+
content=text,
|
|
179
|
+
start_line=preamble_start,
|
|
180
|
+
end_line=preamble_start + len(preamble) - 1,
|
|
181
|
+
language="python",
|
|
182
|
+
node_type="preamble",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
preamble = []
|
|
186
|
+
|
|
187
|
+
def emit(text: str, start: int, end: int, node_type: str, symbol_name: str) -> None:
|
|
188
|
+
if len(text) <= chunk_size:
|
|
189
|
+
result.append(
|
|
190
|
+
SplitChunk(
|
|
191
|
+
content=text,
|
|
192
|
+
start_line=start,
|
|
193
|
+
end_line=end,
|
|
194
|
+
language="python",
|
|
195
|
+
node_type=node_type,
|
|
196
|
+
symbol_name=symbol_name,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
result.extend(
|
|
201
|
+
_char_split(
|
|
202
|
+
text,
|
|
203
|
+
chunk_size,
|
|
204
|
+
chunk_overlap,
|
|
205
|
+
language="python",
|
|
206
|
+
node_type=node_type,
|
|
207
|
+
symbol_name=symbol_name,
|
|
208
|
+
line_offset=start - 1,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
for node in ast.iter_child_nodes(tree):
|
|
213
|
+
if not isinstance(node, ast.stmt):
|
|
214
|
+
continue
|
|
215
|
+
start: int = node.lineno
|
|
216
|
+
end: int = node.end_lineno or node.lineno
|
|
217
|
+
node_text = "".join(lines[start - 1 : end])
|
|
218
|
+
|
|
219
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
220
|
+
flush_preamble()
|
|
221
|
+
emit(node_text, start, end, "function", node.name)
|
|
222
|
+
elif isinstance(node, ast.ClassDef):
|
|
223
|
+
flush_preamble()
|
|
224
|
+
emit(node_text, start, end, "class", node.name)
|
|
225
|
+
else:
|
|
226
|
+
if not preamble:
|
|
227
|
+
preamble_start = start
|
|
228
|
+
preamble.extend(lines[start - 1 : end])
|
|
229
|
+
|
|
230
|
+
flush_preamble()
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Extension registries — the plug-in surface for new languages
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
# Maps file extension → language name.
|
|
239
|
+
# Add an entry here to teach CodeSplitter about a new file type.
|
|
240
|
+
EXTENSION_MAP: dict[str, str] = {
|
|
241
|
+
".py": "python",
|
|
242
|
+
# future: ".js": "javascript", ".ts": "typescript", ".go": "go", ...
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Maps language name → LanguageSplitter instance.
|
|
246
|
+
# Register a new LanguageSplitter implementation here; files whose language
|
|
247
|
+
# has no registered splitter fall back to character-based splitting automatically.
|
|
248
|
+
LANGUAGE_SPLITTERS: dict[str, LanguageSplitter] = {
|
|
249
|
+
"python": PythonASTSplitter(),
|
|
250
|
+
# future: "javascript": TreeSitterSplitter("javascript"), ...
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class CodeSplitter:
|
|
255
|
+
"""Dispatcher that routes each file to the appropriate language splitter.
|
|
256
|
+
|
|
257
|
+
Uses :data:`EXTENSION_MAP` to detect the language from the file extension,
|
|
258
|
+
then delegates to the matching entry in :data:`LANGUAGE_SPLITTERS`. If no
|
|
259
|
+
splitter is registered for the language (or the splitter signals a parse
|
|
260
|
+
failure by returning ``[]``), character-based line-boundary splitting is used.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
chunk_size: Maximum characters per chunk.
|
|
264
|
+
chunk_overlap: Characters of overlap between consecutive chunks.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(self, chunk_size: int, chunk_overlap: int) -> None:
|
|
268
|
+
self._chunk_size = chunk_size
|
|
269
|
+
self._chunk_overlap = chunk_overlap
|
|
270
|
+
|
|
271
|
+
def split(self, content: str, file_path: str) -> list[SplitChunk]:
|
|
272
|
+
"""Split *content* into chunks appropriate for the language of *file_path*.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
content: Raw file content.
|
|
276
|
+
file_path: Repository-relative path; used only for extension detection.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Non-empty list of :class:`SplitChunk` objects.
|
|
280
|
+
"""
|
|
281
|
+
ext = Path(file_path).suffix.lower()
|
|
282
|
+
language = EXTENSION_MAP.get(ext)
|
|
283
|
+
if language:
|
|
284
|
+
splitter = LANGUAGE_SPLITTERS.get(language)
|
|
285
|
+
if splitter:
|
|
286
|
+
chunks = splitter.split(content, self._chunk_size, self._chunk_overlap)
|
|
287
|
+
if chunks:
|
|
288
|
+
return chunks
|
|
289
|
+
return _char_split(
|
|
290
|
+
content,
|
|
291
|
+
self._chunk_size,
|
|
292
|
+
self._chunk_overlap,
|
|
293
|
+
language=language,
|
|
294
|
+
node_type=None,
|
|
295
|
+
symbol_name=None,
|
|
296
|
+
)
|
diffrag/py.typed
ADDED
|
File without changes
|