File size: 19,518 Bytes
60b97da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3dfd51
 
 
 
 
 
 
60b97da
 
d3dfd51
60b97da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3dfd51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60b97da
 
 
 
 
 
 
 
 
d3dfd51
 
 
 
60b97da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3dfd51
60b97da
d3dfd51
60b97da
d3dfd51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60b97da
 
 
 
d3dfd51
60b97da
 
 
 
d3dfd51
 
 
 
 
 
 
60b97da
 
 
d3dfd51
 
 
 
 
 
 
 
 
60b97da
 
 
 
 
 
d3dfd51
 
 
 
 
 
60b97da
 
 
d3dfd51
 
 
60b97da
 
d3dfd51
 
 
 
60b97da
 
 
d3dfd51
60b97da
 
 
 
 
 
d3dfd51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60b97da
 
d3dfd51
 
60b97da
 
 
d3dfd51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60b97da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3dfd51
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
import re
from pathlib import Path
from typing import Dict, List, Optional

from tree_sitter_languages import get_parser


LANGUAGE_BY_EXTENSION = {
    ".py": "python",
    ".js": "javascript",
    ".jsx": "javascript",
    ".ts": "typescript",
    ".tsx": "tsx",
    ".java": "java",
    ".go": "go",
    ".rs": "rust",
    ".c": "c",
    ".h": "c",
    ".cc": "cpp",
    ".cpp": "cpp",
    ".cxx": "cpp",
    ".hpp": "cpp",
    ".hh": "cpp",
}

# Node types that make a good standalone retrieval unit (function/method-sized).
SYMBOL_NODE_TYPES = {
    "python": {"function_definition", "class_definition"},
    "javascript": {
        "function_declaration",
        "class_declaration",
        "method_definition",
        "generator_function_declaration",
        "lexical_declaration",
        "variable_declaration",
    },
    "typescript": {
        "function_declaration",
        "class_declaration",
        "method_definition",
        "interface_declaration",
        "type_alias_declaration",
        "lexical_declaration",
        "variable_statement",
    },
    "tsx": {
        "function_declaration",
        "class_declaration",
        "method_definition",
        "interface_declaration",
        "type_alias_declaration",
        "lexical_declaration",
        "variable_statement",
    },
    "java": {
        "class_declaration",
        "method_declaration",
        "interface_declaration",
        "enum_declaration",
    },
    "go": {
        "function_declaration",
        "method_declaration",
        "type_declaration",
    },
    "rust": {
        "function_item",
        "impl_item",
        "struct_item",
        "enum_item",
        "trait_item",
    },
    "c": {
        "function_definition",
        "struct_specifier",
        "enum_specifier",
        "type_definition",
    },
    "cpp": {
        "function_definition",
        "class_specifier",
        "struct_specifier",
        "enum_specifier",
        "namespace_definition",
        "type_definition",
    },
}

# Node types that are "containers": they hold nested members (methods, fields)
# that are themselves worth indexing as their own chunks. For these, we emit a
# compact *overview* chunk (signature + docstring + head-of-body + member
# list) instead of dumping the entire body into one chunk. Without this, a
# large class becomes a single multi-hundred-line chunk whose embedding is a
# blurry average of everything inside it (hurts hit rate for method-specific
# queries) and whose content is silently cut off by the ~1500 char preview
# used when the chunk is fed to the LLM (hurts faithfulness, since the method
# the user actually asked about may fall outside the truncated window).
CONTAINER_NODE_TYPES = {
    "python": {"class_definition"},
    "javascript": {"class_declaration"},
    "typescript": {"class_declaration"},
    "tsx": {"class_declaration"},
    "java": {"class_declaration", "interface_declaration", "enum_declaration"},
    "rust": {"impl_item", "trait_item"},
    "cpp": {"class_specifier", "namespace_definition"},
}

IDENTIFIER_TYPES = {
    "identifier",
    "property_identifier",
    "type_identifier",
    "field_identifier",
}

MAX_OVERVIEW_BODY_LINES = 40
MAX_OVERVIEW_CHARS = 1200
MAX_MEMBERS_LISTED = 25


class CodeParser:
    def __init__(self):
        self.parsers = {}

    def detect_language(self, file_path: str) -> str:
        return LANGUAGE_BY_EXTENSION.get(Path(file_path).suffix.lower(), "text")

    def _get_parser(self, language: str):
        if language == "text":
            return None
        if language not in self.parsers:
            self.parsers[language] = get_parser(language)
        return self.parsers[language]

    def chunk_file(self, file_path: str, repo_root: str) -> List[Dict]:
        language = self.detect_language(file_path)
        source = Path(file_path).read_text(encoding="utf-8", errors="ignore")
        relative_path = str(Path(file_path).resolve().relative_to(Path(repo_root).resolve()))

        if not source.strip():
            return []

        parser = self._get_parser(language)
        if parser is None:
            return self._fallback_chunks(source, relative_path, language)

        tree = parser.parse(bytes(source, "utf-8"))
        lines = source.splitlines()
        chunks = []
        capture_types = SYMBOL_NODE_TYPES.get(language, set())
        container_types = CONTAINER_NODE_TYPES.get(language, set())

        def visit(node, context_name: Optional[str] = None):
            if node.type in capture_types:
                if node.type in container_types:
                    overview = self._build_container_overview(
                        node, source, lines, relative_path, language, context_name, capture_types
                    )
                    if overview:
                        chunks.append(overview)

                    own_name = self._extract_own_name(node, source)
                    nested_context = own_name
                    if context_name and own_name:
                        nested_context = f"{context_name}.{own_name}"
                    elif context_name and not own_name:
                        nested_context = context_name

                    for child in node.children:
                        visit(child, nested_context)
                    return

                chunk = self._build_chunk(node, source, lines, relative_path, language, context_name)
                if chunk:
                    chunks.append(chunk)
                    return
            for child in node.children:
                visit(child, context_name)

        visit(tree.root_node)

        if not chunks:
            chunks = self._fallback_chunks(source, relative_path, language)

        file_overview = self._build_file_overview(
            tree.root_node, source, lines, relative_path, language, chunks
        )
        if file_overview:
            chunks.insert(0, file_overview)

        return chunks

    def _build_chunk(
        self,
        node,
        source: str,
        lines: List[str],
        relative_path: str,
        language: str,
        context_name: Optional[str] = None,
    ) -> Optional[Dict]:
        start_line = node.start_point[0] + 1
        end_line = node.end_point[0] + 1
        snippet = "\n".join(lines[start_line - 1 : end_line]).strip()
        if len(snippet.splitlines()) < 2:
            return None

        own_name = self._extract_own_name(node, source)
        qualified_name = own_name
        if context_name and own_name:
            qualified_name = f"{context_name}.{own_name}"
        elif context_name and not own_name:
            qualified_name = context_name

        signature = lines[start_line - 1].strip() if start_line - 1 < len(lines) else ""
        searchable_text = "\n".join(
            part
            for part in [relative_path, context_name or "", qualified_name or "", signature, snippet]
            if part
        )

        metadata = {"parser": "tree-sitter"}
        if context_name:
            metadata["parent"] = context_name

        return {
            "file_path": relative_path,
            "language": language,
            "symbol_name": qualified_name or relative_path.split("/")[-1],
            "symbol_type": node.type,
            "line_start": start_line,
            "line_end": end_line,
            "signature": signature,
            "content": snippet,
            "searchable_text": searchable_text,
            "metadata_json": metadata,
        }

    def _build_container_overview(
        self,
        node,
        source: str,
        lines: List[str],
        relative_path: str,
        language: str,
        context_name: Optional[str],
        capture_types: set,
    ) -> Optional[Dict]:
        start_line = node.start_point[0] + 1
        end_line = node.end_point[0] + 1
        body_lines = lines[start_line - 1 : end_line]
        if not body_lines:
            return None

        own_name = self._extract_own_name(node, source)
        qualified_name = own_name
        if context_name and own_name:
            qualified_name = f"{context_name}.{own_name}"
        elif context_name and not own_name:
            qualified_name = context_name
        if not qualified_name:
            qualified_name = relative_path.split("/")[-1]

        # Head-of-body preview: naturally captures the signature, docstring,
        # and field declarations that come before the first nested method,
        # so simple data classes / structs keep their field list even though
        # we no longer store the entire body verbatim.
        preview_lines = body_lines[:MAX_OVERVIEW_BODY_LINES]
        content = "\n".join(preview_lines).strip()
        truncated_body = len(body_lines) > MAX_OVERVIEW_BODY_LINES
        if len(content) > MAX_OVERVIEW_CHARS:
            content = content[:MAX_OVERVIEW_CHARS].rstrip()
            truncated_body = True

        member_names = self._collect_member_names(node, source, capture_types)
        members_line = ""
        if member_names:
            shown = member_names[:MAX_MEMBERS_LISTED]
            members_line = f"Members: {', '.join(shown)}"
            remaining = len(member_names) - len(shown)
            if remaining > 0:
                members_line += f" (+{remaining} more, indexed separately)"
        elif truncated_body:
            members_line = "(body truncated; see file for full contents)"

        content_parts = [part for part in [content, members_line] if part]
        full_content = "\n\n".join(content_parts)

        signature = body_lines[0].strip() if body_lines else ""
        searchable_text = "\n".join(
            part
            for part in [relative_path, context_name or "", qualified_name, signature, full_content]
            if part
        )

        return {
            "file_path": relative_path,
            "language": language,
            "symbol_name": qualified_name,
            "symbol_type": f"{node.type}_overview",
            "line_start": start_line,
            "line_end": end_line,
            "signature": signature,
            "content": full_content,
            "searchable_text": searchable_text,
            "metadata_json": {
                "parser": "tree-sitter",
                "kind": "container_overview",
                **({"parent": context_name} if context_name else {}),
            },
        }

    def _build_file_overview(self, root_node, source: str, lines: List[str], relative_path: str, language: str, symbol_chunks: List[Dict]) -> Optional[Dict]:
        if language == "text":
            return None
        path = Path(relative_path)
        parts = list(path.parts)
        directory = str(path.parent) if str(path.parent) != "." else "repository root"
        filename = path.name
        component = parts[1] if len(parts) >= 2 and parts[0] in {"packages", "apps"} else (parts[-2] if len(parts) >= 2 else "")
        role = self._infer_file_role(relative_path)
        imports, exports, export_targets = self._extract_module_edges(root_node, source)
        symbols, seen = [], set()
        for chunk in symbol_chunks:
            name = chunk.get("symbol_name")
            if name and name not in seen and chunk.get("symbol_type") != "fallback_chunk":
                seen.add(name); symbols.append(name)
            if len(symbols) >= 40:
                break
        overview = [f"File: {relative_path}", f"Filename: {filename}", f"Directory: {directory}", f"Language: {language}"]
        if component: overview.append(f"Package/component: {component}")
        overview.append(f"Role: {role}")
        if imports: overview.append("Imports: " + ", ".join(imports[:30]))
        if exports: overview.append("Exports: " + ", ".join(exports[:40]))
        if export_targets: overview.append("Export targets: " + ", ".join(export_targets[:30]))
        if symbols: overview.append("Symbols: " + ", ".join(symbols))
        content = "\n".join(overview)
        path_terms = " ".join(t for t in re.split(r"[\/._:-]+", relative_path) if t)
        return {
            "file_path": relative_path, "language": language,
            "symbol_name": f"{filename}:module-overview", "symbol_type": "file_overview",
            "line_start": 1, "line_end": 1, "signature": f"module {relative_path}",
            "content": content,
            "searchable_text": content + f"\nPath terms: {path_terms}\nModule: {path.stem}",
            "metadata_json": {"parser": "tree-sitter", "kind": "file_overview", "directory": directory, "component": component, "role": role, "imports": imports[:30], "exports": exports[:40], "export_targets": export_targets[:30]},
        }

    @staticmethod
    def _infer_file_role(relative_path: str) -> str:
        path = relative_path.lower().replace("\\", "/")
        name = Path(path).name
        if name in {"index.ts", "index.tsx", "index.js", "index.jsx"}:
            return "module/package entry point or barrel export"
        for needle, role in [("router", "router/API composition"), ("route", "request route/endpoint"), ("handler", "handler/job execution"), ("controller", "request controller"), ("service", "service/business logic"), ("transport", "transport/integration adapter"), ("adapter", "integration adapter"), ("repository", "data repository"), ("config", "configuration"), ("schema", "schema/type definition"), ("test", "test"), ("spec", "test/specification")]:
            if needle in name or f"/{needle}" in path:
                return role
        return "source module"

    def _extract_module_edges(self, root_node, source: str):
        imports, exports, targets = [], [], []
        for child in root_node.children:
            text = source[child.start_byte:child.end_byte].strip()
            if not text: continue
            if child.type == "import_statement" or text.startswith("import "):
                target = self._module_target(text)
                if target and target not in imports: imports.append(target)
            if "export" in child.type or text.startswith("export ") or text.startswith("module.exports"):
                target = self._module_target(text)
                if target and target not in targets: targets.append(target)
                for name in self._exported_names(text):
                    if name not in exports: exports.append(name)
        return imports, exports, targets

    @staticmethod
    def _module_target(statement: str) -> Optional[str]:
        for pattern in [r"from\s+[\"']([^\"']+)[\"']", r"(?:import|export)\s+[\"']([^\"']+)[\"']", r"require\(\s*[\"']([^\"']+)[\"']\s*\)"]:
            match = re.search(pattern, statement)
            if match: return match.group(1)
        return None

    @staticmethod
    def _exported_names(statement: str) -> List[str]:
        names = []
        match = re.search(r"export\s*\{([^}]+)\}", statement, re.S)
        if match:
            for part in match.group(1).split(","):
                item = part.strip()
                if item: names.append(re.split(r"\s+as\s+", item)[-1].strip())
        match = re.search(r"export\s+(?:default\s+)?(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)", statement)
        if match: names.append(match.group(1))
        if statement.startswith("export default") and not match: names.append("default")
        if statement.startswith("export *"): names.append("*")
        return names

    def _collect_member_names(self, node, source: str, capture_types: set) -> List[str]:
        """Collect names of direct member symbols (methods/fields) inside a
        container without descending into nested containers, so a class's
        member list doesn't pick up grandchildren from an inner class."""
        names = []
        seen = set()

        def walk(current):
            for child in current.children:
                if child.type in capture_types:
                    name = self._extract_own_name(child, source)
                    if name and name not in seen:
                        seen.add(name)
                        names.append(name)
                    # Don't descend further into this member's own body.
                    continue
                walk(child)

        walk(node)
        return names

    @staticmethod
    def _extract_own_name(node, source: str) -> Optional[str]:
        name_node = node.child_by_field_name("name")
        if name_node is not None:
            candidate = source[name_node.start_byte : name_node.end_byte].strip()
            if candidate:
                return candidate
        return CodeParser._find_identifier(node, source)

    @staticmethod
    def _find_identifier(node, source: str) -> Optional[str]:
        stack = list(node.children)
        while stack:
            current = stack.pop(0)
            if current.type in IDENTIFIER_TYPES:
                return source[current.start_byte : current.end_byte].strip()
            stack.extend(current.children)
        return None

    def _fallback_chunks(self, source: str, relative_path: str, language: str) -> List[Dict]:
        blocks = []
        lines = source.splitlines()
        buffer = []
        start_line = 1
        for index, line in enumerate(lines, start=1):
            if not buffer:
                start_line = index
            buffer.append(line)
            trigger = False
            if language == "text":
                trigger = len(buffer) >= 60 or (line.startswith("#") and len(buffer) > 8)
            else:
                trigger = (
                    re.match(r"^\s*(def |class |function |const |export |interface |type )", line)
                    and len(buffer) > 8
                ) or len(buffer) >= 80

            if trigger:
                chunk_text = "\n".join(buffer).strip()
                if chunk_text:
                    blocks.append(
                        {
                            "file_path": relative_path,
                            "language": language,
                            "symbol_name": f"{Path(relative_path).name}:{start_line}",
                            "symbol_type": "fallback_chunk",
                            "line_start": start_line,
                            "line_end": index,
                            "signature": buffer[0].strip(),
                            "content": chunk_text,
                            "searchable_text": f"{relative_path}\n{chunk_text}",
                            "metadata_json": {
                                "parser": "fallback",
                            },
                        }
                    )
                buffer = []

        if buffer:
            chunk_text = "\n".join(buffer).strip()
            if chunk_text:
                blocks.append(
                    {
                        "file_path": relative_path,
                        "language": language,
                        "symbol_name": f"{Path(relative_path).name}:{start_line}",
                        "symbol_type": "fallback_chunk",
                        "line_start": start_line,
                        "line_end": len(lines),
                        "signature": buffer[0].strip(),
                        "content": chunk_text,
                        "searchable_text": f"{relative_path}\n{chunk_text}",
                        "metadata_json": {
                            "parser": "fallback",
                        },
                    }
                )
        return blocks