codegraph-py 1.0.0__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.
Files changed (44) hide show
  1. codegraph/__init__.py +12 -0
  2. codegraph/__main__.py +6 -0
  3. codegraph/cli.py +911 -0
  4. codegraph/codegraph.py +618 -0
  5. codegraph/context/__init__.py +216 -0
  6. codegraph/db/__init__.py +11 -0
  7. codegraph/db/connection.py +163 -0
  8. codegraph/db/queries.py +752 -0
  9. codegraph/db/schema.sql +151 -0
  10. codegraph/directory.py +160 -0
  11. codegraph/errors.py +101 -0
  12. codegraph/extraction/__init__.py +956 -0
  13. codegraph/extraction/languages/__init__.py +64 -0
  14. codegraph/extraction/languages/base.py +132 -0
  15. codegraph/extraction/languages/c_cfg.py +53 -0
  16. codegraph/extraction/languages/cpp_cfg.py +60 -0
  17. codegraph/extraction/languages/dart_cfg.py +53 -0
  18. codegraph/extraction/languages/go_cfg.py +70 -0
  19. codegraph/extraction/languages/java_cfg.py +62 -0
  20. codegraph/extraction/languages/javascript_cfg.py +84 -0
  21. codegraph/extraction/languages/kotlin_cfg.py +52 -0
  22. codegraph/extraction/languages/lua_cfg.py +65 -0
  23. codegraph/extraction/languages/python_cfg.py +75 -0
  24. codegraph/extraction/languages/ruby_cfg.py +48 -0
  25. codegraph/extraction/languages/rust_cfg.py +68 -0
  26. codegraph/extraction/languages/scala_cfg.py +59 -0
  27. codegraph/extraction/languages/swift_cfg.py +64 -0
  28. codegraph/extraction/languages/typescript_cfg.py +89 -0
  29. codegraph/extraction/tree_sitter_extractor.py +689 -0
  30. codegraph/graph/__init__.py +685 -0
  31. codegraph/installer/__init__.py +6 -0
  32. codegraph/mcp/__init__.py +668 -0
  33. codegraph/project_config.py +191 -0
  34. codegraph/resolution/__init__.py +337 -0
  35. codegraph/search/__init__.py +653 -0
  36. codegraph/sync/__init__.py +204 -0
  37. codegraph/types.py +334 -0
  38. codegraph/ui/__init__.py +11 -0
  39. codegraph/utils.py +218 -0
  40. codegraph_py-1.0.0.dist-info/METADATA +238 -0
  41. codegraph_py-1.0.0.dist-info/RECORD +44 -0
  42. codegraph_py-1.0.0.dist-info/WHEEL +5 -0
  43. codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
  44. codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,689 @@
1
+ """
2
+ Tree-sitter-based code extraction engine.
3
+
4
+ Parses source files using tree-sitter and extracts structural information:
5
+ functions, classes, methods, imports, calls, and their relationships.
6
+ Replaces/enhances the regex-based parsers with full AST parsing.
7
+ """
8
+
9
+ import os
10
+ import time
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from tree_sitter import Language, Parser, Node as TSNode, Tree
16
+
17
+ from ..types import (
18
+ Language as LangEnum,
19
+ Node, Edge, NodeKind, EdgeKind,
20
+ ExtractionResult, ExtractionError, UnresolvedReference,
21
+ FileRecord,
22
+ )
23
+ from .languages.base import LanguageConfig, ImportInfo
24
+ from .languages import get_config
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _normalize_path(file_path: str) -> str:
30
+ """Normalize file path to use forward slashes."""
31
+ return file_path.replace('\\', '/')
32
+
33
+
34
+ def _make_id(kind: str, file_path: str, name: str) -> str:
35
+ """Create a deterministic node ID matching existing convention."""
36
+ clean = name.replace(' ', '_')
37
+ return f'{kind}:{_normalize_path(file_path)}::{clean}'
38
+
39
+
40
+ def _node_text(node: TSNode) -> str:
41
+ """Get text from a node safely."""
42
+ try:
43
+ t = node.text
44
+ if isinstance(t, bytes):
45
+ return t.decode('utf-8', errors='replace')
46
+ return str(t)
47
+ except Exception:
48
+ return ''
49
+
50
+
51
+ class TreeSitterExtractor:
52
+ """
53
+ Extracts code structure using tree-sitter AST parsing.
54
+
55
+ For each file, it:
56
+ 1. Detects the language config
57
+ 2. Parses with tree-sitter
58
+ 3. Walks the AST to extract symbols and relationships
59
+ 4. Returns ExtractionResult matching the existing type system
60
+ """
61
+
62
+ def __init__(self):
63
+ self._parsers: dict[str, Parser] = {}
64
+ self._languages: dict[str, Language] = {}
65
+
66
+ def _get_language(self, lang_id: str) -> Optional[Language]:
67
+ """Get or create a tree-sitter Language for the given language id."""
68
+ if lang_id not in self._languages:
69
+ try:
70
+ import importlib
71
+ mod_name = f'tree_sitter_{lang_id.replace("-", "_")}'
72
+ try:
73
+ mod = importlib.import_module(mod_name)
74
+ except ModuleNotFoundError:
75
+ logger.debug(f"Tree-sitter grammar not available: '{lang_id}'")
76
+ return None
77
+
78
+ # Try standard 'language()' function first
79
+ lang_func = getattr(mod, 'language', None)
80
+ if lang_func is not None:
81
+ self._languages[lang_id] = Language(lang_func())
82
+ else:
83
+ # Some packages use language_<grammar_name> (e.g. typescript)
84
+ specific_func = getattr(mod, f'language_{lang_id}', None)
85
+ if specific_func is not None:
86
+ self._languages[lang_id] = Language(specific_func())
87
+ else:
88
+ logger.debug(f"No language function found in '{mod_name}'")
89
+ return None
90
+ except Exception as e:
91
+ logger.debug(f"Failed to load tree-sitter language '{lang_id}': {e}")
92
+ return None
93
+ return self._languages.get(lang_id)
94
+
95
+ def _get_parser(self, lang_id: str) -> Optional[Parser]:
96
+ """Get or create a parser for the given language."""
97
+ if lang_id not in self._parsers:
98
+ ts_lang = self._get_language(lang_id)
99
+ if ts_lang is None:
100
+ return None
101
+ parser = Parser()
102
+ parser.language = ts_lang
103
+ self._parsers[lang_id] = parser
104
+ return self._parsers.get(lang_id)
105
+
106
+ def extract(
107
+ self, file_path: str, source: bytes, lang_enum: LangEnum
108
+ ) -> ExtractionResult:
109
+ """
110
+ Extract code structure from a source file using tree-sitter.
111
+
112
+ Args:
113
+ file_path: Normalized path (relative to project root)
114
+ source: Raw file content as bytes
115
+ lang_enum: Language enum value
116
+
117
+ Returns:
118
+ ExtractionResult with nodes, edges, etc.
119
+ """
120
+ start_time = time.time()
121
+ config = get_config(lang_enum)
122
+ if config is None:
123
+ return ExtractionResult(
124
+ nodes=[_make_file_node(file_path, source, lang_enum.value)],
125
+ duration_ms=(time.time() - start_time) * 1000,
126
+ )
127
+
128
+ parser = self._get_parser(config.language_id)
129
+ if parser is None:
130
+ return ExtractionResult(
131
+ nodes=[_make_file_node(file_path, source, lang_enum.value)],
132
+ errors=[ExtractionError(
133
+ message=f"Tree-sitter parser not available for {lang_enum.value}",
134
+ severity='warning',
135
+ )],
136
+ duration_ms=(time.time() - start_time) * 1000,
137
+ )
138
+
139
+ # Parse
140
+ try:
141
+ tree = parser.parse(source)
142
+ except Exception as e:
143
+ return ExtractionResult(
144
+ nodes=[_make_file_node(file_path, source, lang_enum.value)],
145
+ errors=[ExtractionError(
146
+ message=f"Parse error: {e}",
147
+ file_path=file_path,
148
+ severity='error',
149
+ )],
150
+ duration_ms=(time.time() - start_time) * 1000,
151
+ )
152
+
153
+ # Extract
154
+ extractor = _FileExtractor(file_path, source, config, tree, lang_enum)
155
+ result = extractor.extract()
156
+ result.duration_ms = (time.time() - start_time) * 1000
157
+ return result
158
+
159
+
160
+ def _make_file_node(file_path: str, source: bytes, language: str) -> Node:
161
+ """Create a basic file node."""
162
+ lines = source.splitlines() if source else []
163
+ nlines = max(len(lines), 1)
164
+ norm = _normalize_path(file_path)
165
+ return Node(
166
+ id=f'file:{norm}',
167
+ kind='file',
168
+ name=Path(file_path).name,
169
+ qualified_name=norm,
170
+ file_path=norm,
171
+ language=language,
172
+ start_line=1,
173
+ end_line=nlines,
174
+ start_column=0,
175
+ end_column=0,
176
+ )
177
+
178
+
179
+ class _FileExtractor:
180
+ """Extracts structure from a single file's AST."""
181
+
182
+ def __init__(
183
+ self,
184
+ file_path: str,
185
+ source: bytes,
186
+ config: LanguageConfig,
187
+ tree: Tree,
188
+ lang_enum: LangEnum,
189
+ ):
190
+ self.file_path = file_path
191
+ self.norm_path = _normalize_path(file_path)
192
+ self.source = source
193
+ self.config = config
194
+ self.tree = tree
195
+ self.lang = lang_enum.value
196
+
197
+ self.nodes: list[Node] = []
198
+ self.edges: list[Edge] = []
199
+ self.errors: list[ExtractionError] = []
200
+ self.unresolved_refs: list[UnresolvedReference] = []
201
+
202
+ # Node kind sets for fast dispatch
203
+ t = config
204
+ self._func_set = set(t.function_types)
205
+ self._class_set = set(t.class_types)
206
+ self._method_set = set(t.method_types)
207
+ self._interface_set = set(t.interface_types)
208
+ self._struct_set = set(t.struct_types)
209
+ self._enum_set = set(t.enum_types)
210
+ self._type_alias_set = set(t.type_alias_types)
211
+ self._call_set = set(t.call_types)
212
+ self._var_set = set(t.variable_types)
213
+
214
+ # Track scope for method detection
215
+ self._in_class_body = False
216
+ self._current_class_id: Optional[str] = None
217
+
218
+ # ------------------------------------------------------------------
219
+ # Helpers
220
+ # ------------------------------------------------------------------
221
+
222
+ def _pos(self, node: TSNode) -> tuple[int, int, int, int]:
223
+ """Return (start_line, end_line, start_col, end_col)."""
224
+ return (
225
+ node.start_point[0] + 1,
226
+ node.end_point[0] + 1,
227
+ node.start_point[1],
228
+ node.end_point[1],
229
+ )
230
+
231
+ def _node_name(self, node: TSNode) -> Optional[str]:
232
+ """Extract name via the configured name_field."""
233
+ name_node = node.child_by_field_name(self.config.name_field)
234
+ if name_node is not None:
235
+ return _node_text(name_node)
236
+ for child in node.named_children:
237
+ if child.type in ('identifier', 'property_identifier', 'type_identifier'):
238
+ return _node_text(child)
239
+ return None
240
+
241
+ def _get_lines(self) -> int:
242
+ return max(len(self.source.splitlines()), 1) if self.source else 1
243
+
244
+ # ------------------------------------------------------------------
245
+ # Main extraction
246
+ # ------------------------------------------------------------------
247
+
248
+ def extract(self) -> ExtractionResult:
249
+ """Run extraction and return result."""
250
+ root = self.tree.root_node
251
+ nlines = self._get_lines()
252
+
253
+ file_node = Node(
254
+ id=f'file:{self.norm_path}',
255
+ kind='file',
256
+ name=Path(self.file_path).name,
257
+ qualified_name=self.norm_path,
258
+ file_path=self.norm_path,
259
+ language=self.lang,
260
+ start_line=1,
261
+ end_line=nlines,
262
+ start_column=0,
263
+ end_column=0,
264
+ )
265
+ self.nodes.append(file_node)
266
+
267
+ # Visit top-level children
268
+ self._visit_children(root, file_node.id)
269
+
270
+ return ExtractionResult(
271
+ nodes=self.nodes,
272
+ edges=self.edges,
273
+ unresolved_references=self.unresolved_refs,
274
+ errors=self.errors,
275
+ )
276
+
277
+ # ------------------------------------------------------------------
278
+ # AST traversal
279
+ # ------------------------------------------------------------------
280
+
281
+ def _visit_children(self, parent: TSNode, parent_node_id: str):
282
+ """Visit all named children of a node."""
283
+ for child in parent.named_children:
284
+ self._visit_node(child, parent_node_id)
285
+
286
+ def _visit_node(self, node: TSNode, parent_node_id: str):
287
+ """Dispatch a single node based on type."""
288
+ if not node.is_named or self.config.should_skip_type(node.type):
289
+ return
290
+
291
+ t = node.type
292
+
293
+ if t in self._func_set and not self._in_class_body:
294
+ self._extract_function(node, parent_node_id)
295
+ elif t in self._method_set and self._in_class_body:
296
+ self._extract_method(node, parent_node_id)
297
+ elif t in self._class_set:
298
+ self._extract_class(node, parent_node_id)
299
+ elif t in self._interface_set:
300
+ self._extract_interface(node, parent_node_id)
301
+ elif t in self._struct_set:
302
+ self._extract_struct(node, parent_node_id)
303
+ elif t in self._enum_set:
304
+ self._extract_enum(node, parent_node_id)
305
+ elif t in self._type_alias_set:
306
+ self._extract_type_alias(node, parent_node_id)
307
+ elif t in self._call_set:
308
+ self._extract_call(node, parent_node_id)
309
+ elif self.config.extract_import(node, self.source) is not None:
310
+ self._extract_import(node, parent_node_id)
311
+ else:
312
+ # Continue traversing
313
+ self._visit_children(node, parent_node_id)
314
+
315
+ # ------------------------------------------------------------------
316
+ # Symbol extractors
317
+ # ------------------------------------------------------------------
318
+
319
+ def _extract_function(self, node: TSNode, parent_node_id: str):
320
+ """Extract a top-level function."""
321
+ name = self._node_name(node)
322
+ if not name:
323
+ self._visit_children(node, parent_node_id)
324
+ return
325
+
326
+ sl, el, sc, ec = self._pos(node)
327
+ nid = _make_id(self.lang, self.norm_path, name)
328
+
329
+ func_node = Node(
330
+ id=nid,
331
+ kind='function',
332
+ name=name,
333
+ qualified_name=f'{self.norm_path}::{name}',
334
+ file_path=self.norm_path,
335
+ language=self.lang,
336
+ start_line=sl, end_line=el,
337
+ start_column=sc, end_column=ec,
338
+ signature=self.config.get_signature(node, self.source),
339
+ is_async=self.config.is_async(node),
340
+ is_static=self.config.is_static(node),
341
+ is_exported=self.config.is_exported(node, self.source),
342
+ )
343
+ self.nodes.append(func_node)
344
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
345
+
346
+ self._visit_children(node, nid)
347
+
348
+ def _extract_method(self, node: TSNode, parent_node_id: str):
349
+ """Extract a method inside a class."""
350
+ name = self._node_name(node)
351
+ if not name:
352
+ self._visit_children(node, parent_node_id)
353
+ return
354
+
355
+ sl, el, sc, ec = self._pos(node)
356
+
357
+ # Build qualified name using class name if available
358
+ qname = name
359
+ if self._current_class_id:
360
+ qname = f'{self._current_class_id.split("::")[-1]}.{name}'
361
+ nid = _make_id('method', self.norm_path, qname)
362
+
363
+ method_node = Node(
364
+ id=nid,
365
+ kind='method',
366
+ name=name,
367
+ qualified_name=f'{self.norm_path}::{qname}',
368
+ file_path=self.norm_path,
369
+ language=self.lang,
370
+ start_line=sl, end_line=el,
371
+ start_column=sc, end_column=ec,
372
+ signature=self.config.get_signature(node, self.source),
373
+ is_async=self.config.is_async(node),
374
+ is_static=self.config.is_static(node),
375
+ is_exported=self.config.is_exported(node, self.source),
376
+ )
377
+ self.nodes.append(method_node)
378
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
379
+
380
+ self._visit_children(node, nid)
381
+
382
+ def _extract_class(self, node: TSNode, parent_node_id: str):
383
+ """Extract a class definition and its body."""
384
+ name = self._node_name(node)
385
+ if not name:
386
+ self._visit_children(node, parent_node_id)
387
+ return
388
+
389
+ sl, el, sc, ec = self._pos(node)
390
+ nid = _make_id('class', self.norm_path, name)
391
+
392
+ class_node = Node(
393
+ id=nid,
394
+ kind='class',
395
+ name=name,
396
+ qualified_name=f'{self.norm_path}::{name}',
397
+ file_path=self.norm_path,
398
+ language=self.lang,
399
+ start_line=sl, end_line=el,
400
+ start_column=sc, end_column=ec,
401
+ )
402
+ self.nodes.append(class_node)
403
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
404
+
405
+ # Visit body with class scope flag
406
+ old_class_id = self._current_class_id
407
+ old_in_class = self._in_class_body
408
+ self._current_class_id = nid
409
+ self._in_class_body = True
410
+
411
+ body = node.child_by_field_name(self.config.body_field)
412
+ if body:
413
+ self._visit_children(body, nid)
414
+ else:
415
+ self._visit_children(node, nid)
416
+
417
+ self._in_class_body = old_in_class
418
+ self._current_class_id = old_class_id
419
+
420
+ def _extract_interface(self, node: TSNode, parent_node_id: str):
421
+ """Extract an interface definition."""
422
+ name = self._node_name(node)
423
+ if not name:
424
+ self._visit_children(node, parent_node_id)
425
+ return
426
+
427
+ sl, el, sc, ec = self._pos(node)
428
+ nid = _make_id('interface', self.norm_path, name)
429
+
430
+ i_node = Node(
431
+ id=nid, kind='interface', name=name,
432
+ qualified_name=f'{self.norm_path}::{name}',
433
+ file_path=self.norm_path, language=self.lang,
434
+ start_line=sl, end_line=el, start_column=sc, end_column=ec,
435
+ )
436
+ self.nodes.append(i_node)
437
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
438
+
439
+ body = node.child_by_field_name(self.config.body_field)
440
+ if body:
441
+ self._visit_children(body, nid)
442
+ else:
443
+ self._visit_children(node, nid)
444
+
445
+ def _extract_struct(self, node: TSNode, parent_node_id: str):
446
+ """Extract a struct definition."""
447
+ name = self._node_name(node)
448
+ if not name:
449
+ self._visit_children(node, parent_node_id)
450
+ return
451
+
452
+ sl, el, sc, ec = self._pos(node)
453
+ nid = _make_id('struct', self.norm_path, name)
454
+
455
+ s_node = Node(
456
+ id=nid, kind='struct', name=name,
457
+ qualified_name=f'{self.norm_path}::{name}',
458
+ file_path=self.norm_path, language=self.lang,
459
+ start_line=sl, end_line=el, start_column=sc, end_column=ec,
460
+ )
461
+ self.nodes.append(s_node)
462
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
463
+
464
+ self._visit_children(node, nid)
465
+
466
+ def _extract_enum(self, node: TSNode, parent_node_id: str):
467
+ """Extract an enum definition."""
468
+ name = self._node_name(node)
469
+ if not name:
470
+ self._visit_children(node, parent_node_id)
471
+ return
472
+
473
+ sl, el, sc, ec = self._pos(node)
474
+ nid = _make_id('enum', self.norm_path, name)
475
+
476
+ e_node = Node(
477
+ id=nid, kind='enum', name=name,
478
+ qualified_name=f'{self.norm_path}::{name}',
479
+ file_path=self.norm_path, language=self.lang,
480
+ start_line=sl, end_line=el, start_column=sc, end_column=ec,
481
+ )
482
+ self.nodes.append(e_node)
483
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
484
+
485
+ self._visit_children(node, nid)
486
+
487
+ def _extract_type_alias(self, node: TSNode, parent_node_id: str):
488
+ """Extract a type alias."""
489
+ name = self._node_name(node)
490
+ if not name:
491
+ return
492
+
493
+ sl, el, sc, ec = self._pos(node)
494
+ nid = _make_id('type_alias', self.norm_path, name)
495
+
496
+ t_node = Node(
497
+ id=nid, kind='type_alias', name=name,
498
+ qualified_name=f'{self.norm_path}::{name}',
499
+ file_path=self.norm_path, language=self.lang,
500
+ start_line=sl, end_line=el, start_column=sc, end_column=ec,
501
+ )
502
+ self.nodes.append(t_node)
503
+ self.edges.append(Edge(source=parent_node_id, target=nid, kind='contains'))
504
+
505
+ def _extract_import(self, node: TSNode, parent_node_id: str):
506
+ """Extract an import statement and create import nodes/edges."""
507
+ info = self.config.extract_import(node, self.source)
508
+ if info is None:
509
+ return
510
+
511
+ sl, el, sc, ec = self._pos(node)
512
+
513
+ # Parse import info to create import nodes
514
+ # info.signature contains full import text
515
+ sig = info.signature
516
+ module_name = info.module_name
517
+
518
+ if node.type == 'import_from_statement':
519
+ # e.g. from os.path import join, exists
520
+ # Find the import names via the names field in tree-sitter
521
+ names = []
522
+ names_node = node.child_by_field_name('names')
523
+ if names_node:
524
+ for child in names_node.named_children:
525
+ name_text = _node_text(child)
526
+ if name_text:
527
+ names.append(name_text)
528
+ if not names:
529
+ # Fallback: parse from signature
530
+ if ' import ' in sig:
531
+ parts = sig.split(' import ', 1)
532
+ if len(parts) == 2:
533
+ names = [n.strip() for n in parts[1].split(',')]
534
+
535
+ for imp_name in names:
536
+ imp_node = self._make_import_node(
537
+ imp_name,
538
+ f'{module_name}.{imp_name}',
539
+ sl, el, sc, ec
540
+ )
541
+ self.nodes.append(imp_node)
542
+ self.edges.append(Edge(
543
+ source=parent_node_id, target=imp_node.id, kind='contains'
544
+ ))
545
+ # Create imports edge to module
546
+ self.unresolved_refs.append(UnresolvedReference(
547
+ from_node_id=imp_node.id,
548
+ reference_name=module_name,
549
+ reference_kind='imports',
550
+ line=sl,
551
+ column=sc,
552
+ file_path=self.norm_path,
553
+ language=self.lang,
554
+ candidates=None,
555
+ ))
556
+ elif node.type == 'import_statement':
557
+ # e.g. import os, sys
558
+ names = node.child_by_field_name('names')
559
+ if names:
560
+ for child in names.named_children:
561
+ name_text = _node_text(child)
562
+ if name_text:
563
+ imp_node = self._make_import_node(
564
+ name_text, name_text,
565
+ sl, el, sc, ec
566
+ )
567
+ self.nodes.append(imp_node)
568
+ self.edges.append(Edge(
569
+ source=parent_node_id, target=imp_node.id, kind='contains'
570
+ ))
571
+ self.unresolved_refs.append(UnresolvedReference(
572
+ from_node_id=imp_node.id,
573
+ reference_name=name_text,
574
+ reference_kind='imports',
575
+ line=sl,
576
+ column=sc,
577
+ file_path=self.norm_path,
578
+ language=self.lang,
579
+ candidates=None,
580
+ ))
581
+
582
+ def _make_import_node(self, name: str, qualified_name: str,
583
+ start_line: int, end_line: int,
584
+ start_col: int, end_col: int) -> Node:
585
+ """Create an import node."""
586
+ nid = _make_id('import', self.norm_path, qualified_name)
587
+ return Node(
588
+ id=nid,
589
+ kind='import',
590
+ name=name,
591
+ qualified_name=qualified_name,
592
+ file_path=self.norm_path,
593
+ language=self.lang,
594
+ start_line=start_line,
595
+ end_line=end_line,
596
+ start_column=start_col,
597
+ end_column=end_col,
598
+ )
599
+
600
+ def _extract_call(self, node: TSNode, parent_node_id: str):
601
+ """Extract a function call (as unresolved reference)."""
602
+ func = node.child_by_field_name('function')
603
+ if func is None:
604
+ return
605
+
606
+ name = _node_text(func)
607
+ if not name:
608
+ return
609
+
610
+ sl, el, sc, ec = self._pos(node)
611
+ self.unresolved_refs.append(UnresolvedReference(
612
+ from_node_id=parent_node_id,
613
+ reference_name=name,
614
+ reference_kind='call',
615
+ line=sl,
616
+ column=sc,
617
+ file_path=self.norm_path,
618
+ language=self.lang,
619
+ ))
620
+
621
+
622
+ # =============================================================================
623
+ # Convenience: single-file parse function (drop-in for parse_with_treesitter)
624
+ # =============================================================================
625
+
626
+ _singleton_extractor: Optional[TreeSitterExtractor] = None
627
+
628
+
629
+ def get_ts_extractor() -> TreeSitterExtractor:
630
+ """Get or create the singleton TreeSitterExtractor."""
631
+ global _singleton_extractor
632
+ if _singleton_extractor is None:
633
+ _singleton_extractor = TreeSitterExtractor()
634
+ return _singleton_extractor
635
+
636
+
637
+ def parse_file(file_path: str, content: str, language: str) -> ExtractionResult:
638
+ """
639
+ Parse a single file using tree-sitter.
640
+
641
+ Args:
642
+ file_path: Path (relative to project root, normalized)
643
+ content: File content as string
644
+ language: Language identifier string
645
+
646
+ Returns:
647
+ ExtractionResult
648
+ """
649
+ from . import detect_language as detect_lang
650
+
651
+ # Map string language to enum
652
+ lang_map = {
653
+ 'python': LangEnum.PYTHON,
654
+ 'javascript': LangEnum.JAVASCRIPT,
655
+ 'jsx': LangEnum.JSX,
656
+ 'typescript': LangEnum.TYPESCRIPT,
657
+ 'tsx': LangEnum.TSX,
658
+ 'arkts': LangEnum.ARKTS,
659
+ 'go': LangEnum.GO,
660
+ 'rust': LangEnum.RUST,
661
+ 'java': LangEnum.JAVA,
662
+ 'c': LangEnum.C,
663
+ 'cpp': LangEnum.CPP,
664
+ 'csharp': LangEnum.CSHARP,
665
+ 'php': LangEnum.PHP,
666
+ 'ruby': LangEnum.RUBY,
667
+ 'swift': LangEnum.SWIFT,
668
+ 'kotlin': LangEnum.KOTLIN,
669
+ 'dart': LangEnum.DART,
670
+ 'scala': LangEnum.SCALA,
671
+ 'lua': LangEnum.LUA,
672
+ 'luau': LangEnum.LUAU,
673
+ 'solidity': LangEnum.SOLIDITY,
674
+ 'nix': LangEnum.NIX,
675
+ 'terraform': LangEnum.TERRAFORM,
676
+ 'pascal': LangEnum.PASCAL,
677
+ 'objc': LangEnum.OBJC,
678
+ 'r': LangEnum.R,
679
+ 'svelte': LangEnum.SVELTE,
680
+ 'vue': LangEnum.VUE,
681
+ 'astro': LangEnum.ASTRO,
682
+ 'liquid': LangEnum.LIQUID,
683
+ 'razor': LangEnum.RAZOR,
684
+ 'unknown': LangEnum.UNKNOWN,
685
+ }
686
+
687
+ lang_enum = lang_map.get(language, LangEnum.UNKNOWN)
688
+ extractor = get_ts_extractor()
689
+ return extractor.extract(file_path, content.encode('utf-8'), lang_enum)