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.
@@ -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
@@ -0,0 +1,5 @@
1
+ """Review orchestration: diff → RAG → LLM → structured report."""
2
+
3
+ from .reviewer import CodeReview, CodeReviewer, FileReview
4
+
5
+ __all__ = ["CodeReview", "CodeReviewer", "FileReview"]