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,956 @@
1
+ """
2
+ CodeGraph Extraction Layer
3
+
4
+ Handles file scanning, language detection, and code parsing for indexing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Set
15
+
16
+ from codegraph.db.queries import QueryBuilder
17
+ from codegraph.types import (
18
+ Edge,
19
+ ExtractionError,
20
+ ExtractionResult,
21
+ FileRecord,
22
+ Language,
23
+ Node,
24
+ )
25
+
26
+ # =============================================================================
27
+ # Language Detection
28
+ # =============================================================================
29
+
30
+ # Mapping from file extensions to language identifiers
31
+ LANGUAGES: Dict[str, str] = {
32
+ # TypeScript/JavaScript
33
+ '.ts': 'typescript',
34
+ '.tsx': 'tsx',
35
+ '.js': 'javascript',
36
+ '.jsx': 'jsx',
37
+ '.mjs': 'javascript',
38
+ '.cjs': 'javascript',
39
+ '.mts': 'typescript',
40
+ '.cts': 'typescript',
41
+ # ArkTS (HarmonyOS)
42
+ '.ets': 'arkts',
43
+ # Python
44
+ '.py': 'python',
45
+ '.pyi': 'python',
46
+ # Go
47
+ '.go': 'go',
48
+ # Rust
49
+ '.rs': 'rust',
50
+ # Java/Kotlin
51
+ '.java': 'java',
52
+ '.kt': 'kotlin',
53
+ '.kts': 'kotlin',
54
+ # C/C++
55
+ '.c': 'c',
56
+ '.h': 'c',
57
+ '.cpp': 'cpp',
58
+ '.cc': 'cpp',
59
+ '.cxx': 'cpp',
60
+ '.hpp': 'cpp',
61
+ '.hxx': 'cpp',
62
+ # C#
63
+ '.cs': 'csharp',
64
+ # PHP
65
+ '.php': 'php',
66
+ # Ruby
67
+ '.rb': 'ruby',
68
+ '.rake': 'ruby',
69
+ # Swift
70
+ '.swift': 'swift',
71
+ # Dart
72
+ '.dart': 'dart',
73
+ # Svelte
74
+ '.svelte': 'svelte',
75
+ # Vue
76
+ '.vue': 'vue',
77
+ # Astro
78
+ '.astro': 'astro',
79
+ # Liquid
80
+ '.liquid': 'liquid',
81
+ # Pascal
82
+ '.pas': 'pascal',
83
+ '.pp': 'pascal',
84
+ # Scala
85
+ '.scala': 'scala',
86
+ # Lua
87
+ '.lua': 'lua',
88
+ '.luau': 'luau',
89
+ # Objective-C
90
+ '.m': 'objc',
91
+ '.mm': 'objc',
92
+ # R
93
+ '.r': 'r',
94
+ '.R': 'r',
95
+ # Solidity
96
+ '.sol': 'solidity',
97
+ # Nix
98
+ '.nix': 'nix',
99
+ # Terraform
100
+ '.tf': 'terraform',
101
+ '.tfvars': 'terraform',
102
+ # YAML
103
+ '.yaml': 'yaml',
104
+ '.yml': 'yaml',
105
+ # Twig
106
+ '.twig': 'twig',
107
+ # XML
108
+ '.xml': 'xml',
109
+ # Properties
110
+ '.properties': 'properties',
111
+ # CFML/CFScript
112
+ '.cfm': 'cfml',
113
+ '.cfc': 'cfscript',
114
+ # CFQuery
115
+ '.cfquery': 'cfquery',
116
+ # COBOL
117
+ '.cbl': 'cobol',
118
+ '.cob': 'cobol',
119
+ # VB.NET
120
+ '.vb': 'vbnet',
121
+ # Erlang
122
+ '.erl': 'erlang',
123
+ # Razor
124
+ '.cshtml': 'razor',
125
+ '.razor': 'razor',
126
+ }
127
+
128
+ # Extensions that should be treated as source files
129
+ SOURCE_EXTENSIONS: Set[str] = set(LANGUAGES.keys())
130
+
131
+
132
+ def detect_language(file_path: str) -> str:
133
+ """
134
+ Detect the programming language of a file based on its extension.
135
+
136
+ Args:
137
+ file_path: Path to the file (can be absolute or relative)
138
+
139
+ Returns:
140
+ Language identifier string, or 'unknown' if not detected
141
+ """
142
+ ext = Path(file_path).suffix.lower()
143
+ return LANGUAGES.get(ext, 'unknown')
144
+
145
+
146
+ def is_source_file(file_path: str) -> bool:
147
+ """
148
+ Check if a file is a source file that should be indexed.
149
+
150
+ Args:
151
+ file_path: Path to the file
152
+
153
+ Returns:
154
+ True if the file has a recognized source extension
155
+ """
156
+ ext = Path(file_path).suffix.lower()
157
+ return ext in SOURCE_EXTENSIONS
158
+
159
+
160
+ # =============================================================================
161
+ # Progress & Result Types
162
+ # =============================================================================
163
+
164
+ @dataclass
165
+ class IndexProgress:
166
+ """Progress information during indexing."""
167
+ total_files: int = 0
168
+ processed_files: int = 0
169
+ current_file: Optional[str] = None
170
+ errors: List[str] = field(default_factory=list)
171
+
172
+ @property
173
+ def percentage(self) -> float:
174
+ if self.total_files == 0:
175
+ return 0.0
176
+ return (self.processed_files / self.total_files) * 100
177
+
178
+
179
+ @dataclass
180
+ class IndexResult:
181
+ """Result from indexing a single file."""
182
+ file_path: str
183
+ success: bool
184
+ nodes_count: int = 0
185
+ edges_count: int = 0
186
+ errors: List[ExtractionError] = field(default_factory=list)
187
+ duration_ms: float = 0.0
188
+ skipped: bool = False
189
+ skipped_reason: Optional[str] = None
190
+
191
+
192
+ @dataclass
193
+ class SyncResult:
194
+ """Result from a full sync/indexing operation."""
195
+ indexed_files: List[str] = field(default_factory=list)
196
+ skipped_files: List[str] = field(default_factory=list)
197
+ deleted_files: List[str] = field(default_factory=list)
198
+ total_nodes: int = 0
199
+ total_edges: int = 0
200
+ total_errors: int = 0
201
+ duration_ms: float = 0.0
202
+ progress: Optional[IndexProgress] = None
203
+
204
+
205
+ # =============================================================================
206
+ # GitIgnore Support
207
+ # =============================================================================
208
+
209
+ class GitIgnore:
210
+ """Simple .gitignore pattern matcher."""
211
+
212
+ def __init__(self, root_path: str):
213
+ self.root_path = Path(root_path).resolve()
214
+ self._patterns: List[tuple] = []
215
+ self._load_gitignore()
216
+
217
+ def _load_gitignore(self) -> None:
218
+ """Load .gitignore patterns from the root directory."""
219
+ gitignore_path = self.root_path / '.gitignore'
220
+ if not gitignore_path.exists():
221
+ return
222
+
223
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
224
+ for line in f:
225
+ line = line.strip()
226
+ if not line or line.startswith('#'):
227
+ continue
228
+ self._patterns.append(self._parse_pattern(line))
229
+
230
+ def _parse_pattern(self, pattern: str) -> tuple:
231
+ """Parse a single gitignore pattern."""
232
+ # Handle directory patterns
233
+ if pattern.endswith('/'):
234
+ return ('dir', pattern[:-1])
235
+
236
+ # Handle negation
237
+ is_negation = pattern.startswith('!')
238
+ if is_negation:
239
+ pattern = pattern[1:]
240
+
241
+ # Handle wildcards
242
+ if '*' in pattern:
243
+ return ('wildcard', pattern, is_negation)
244
+
245
+ if '**' in pattern:
246
+ return ('glob', pattern, is_negation)
247
+
248
+ return ('exact', pattern, is_negation)
249
+
250
+ def matches(self, path: str) -> bool:
251
+ """
252
+ Check if a path matches any gitignore pattern.
253
+
254
+ Args:
255
+ path: Path to check (relative to root)
256
+
257
+ Returns:
258
+ True if the path should be ignored
259
+ """
260
+ import fnmatch
261
+
262
+ rel_path = Path(path)
263
+ if not rel_path.is_absolute():
264
+ try:
265
+ rel_path = Path(path).resolve().relative_to(self.root_path)
266
+ except ValueError:
267
+ rel_path = Path(path)
268
+
269
+ path_str = str(rel_path)
270
+ parts = path_str.split(os.sep)
271
+
272
+ # Check each pattern
273
+ ignored = False
274
+ for pattern_type, *pattern_args in self._patterns:
275
+ if pattern_type == 'exact':
276
+ pattern, is_negation = pattern_args
277
+ if pattern in parts or path_str == pattern:
278
+ ignored = not is_negation if is_negation else True
279
+
280
+ elif pattern_type == 'dir':
281
+ pattern = pattern_args[0]
282
+ if pattern in parts:
283
+ ignored = True
284
+
285
+ elif pattern_type == 'wildcard':
286
+ pattern, is_negation = pattern_args
287
+ # Simple wildcard matching
288
+ for part in parts:
289
+ if fnmatch.fnmatch(part, pattern):
290
+ ignored = not is_negation if is_negation else True
291
+
292
+ elif pattern_type == 'glob':
293
+ pattern, is_negation = pattern_args
294
+ if fnmatch.fnmatch(path_str, pattern):
295
+ ignored = not is_negation if is_negation else True
296
+
297
+ return ignored
298
+
299
+
300
+ # =============================================================================
301
+ # Python Parser (regex-based fallback)
302
+ # =============================================================================
303
+
304
+ import re
305
+
306
+ # Regex patterns for Python code elements
307
+ # Note: ^ at line start with re.MULTILINE - we allow leading whitespace via (?P<indent> *)
308
+ # to match methods inside classes
309
+ PY_FUNCTION_RE = re.compile(
310
+ r'^(?P<indent> *)(?P<decorators>(?:@\w+(?:\([^)]*\))?\s*\n\s*)*)'
311
+ r'(?:async\s+)?def\s+(?P<name>\w+)\s*\((?P<params>[^)]*)\)\s*(?:->\s*(?P<return_type>[^:]+))?\s*:',
312
+ re.MULTILINE
313
+ )
314
+
315
+ PY_CLASS_RE = re.compile(
316
+ r'^(?P<indent> *)class\s+(?P<name>\w+)\s*(?:\((?P<bases>[^)]*)\))?\s*:',
317
+ re.MULTILINE
318
+ )
319
+
320
+ PY_DECORATOR_RE = re.compile(r'^\s*@(\w+)', re.MULTILINE)
321
+ PY_IMPORT_RE = re.compile(
322
+ r'^import\s+(?P<modules>[^#\n]+)'
323
+ r'|^from\s+(?P<from_module>[^#\n\s]+)\s+import\s+(?P<imports>[^#\n]+)',
324
+ re.MULTILINE
325
+ )
326
+ PY_DOCSTRING_RE = re.compile(r'^\s*"""(.+?)"""', re.DOTALL | re.MULTILINE)
327
+
328
+
329
+ def _make_node_id(file_path: str, kind: str, name: str) -> str:
330
+ """Create a deterministic node ID."""
331
+ clean = name.replace(' ', '_')
332
+ return f'{kind}:{file_path}::{clean}'
333
+
334
+
335
+ def _normalize_path(file_path: str) -> str:
336
+ """Normalize file path to use forward slashes."""
337
+ return file_path.replace('\\', '/')
338
+
339
+
340
+ def parse_python(file_path: str, content: str) -> ExtractionResult:
341
+ """Parse Python source code using regex."""
342
+ from codegraph.types import Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference
343
+
344
+ nodes = []
345
+ edges = []
346
+ errors = []
347
+ unresolved = []
348
+ lines = content.splitlines()
349
+ nlines = len(lines)
350
+ norm_path = _normalize_path(file_path)
351
+
352
+ # File node
353
+ file_node = Node(
354
+ id=f'file:{norm_path}',
355
+ kind='file',
356
+ name=Path(file_path).name,
357
+ qualified_name=norm_path,
358
+ file_path=norm_path,
359
+ language='python',
360
+ start_line=1,
361
+ end_line=nlines,
362
+ start_column=0,
363
+ end_column=0,
364
+ )
365
+ nodes.append(file_node)
366
+
367
+ # Extract classes
368
+ seen_decorators: List[str] = []
369
+ for match in PY_DECORATOR_RE.finditer(content):
370
+ seen_decorators.append(match.group(1))
371
+
372
+ for match in PY_CLASS_RE.finditer(content):
373
+ name = match.group('name')
374
+ bases = match.group('bases') or ''
375
+ start_line = content[:match.start()].count('\n') + 1
376
+
377
+ # Find class end line
378
+ end_line = _find_block_end(lines, start_line)
379
+
380
+ class_node = Node(
381
+ id=_make_node_id(norm_path, 'class', name),
382
+ kind='class',
383
+ name=name,
384
+ qualified_name=f'{norm_path}::{name}',
385
+ file_path=norm_path,
386
+ language='python',
387
+ start_line=start_line,
388
+ end_line=end_line,
389
+ start_column=0,
390
+ end_column=0,
391
+ is_exported=True,
392
+ )
393
+ nodes.append(class_node)
394
+ edges.append(Edge(source=file_node.id, target=class_node.id, kind='contains'))
395
+
396
+ # Extract docstring
397
+ doc_match = PY_DOCSTRING_RE.search(content, match.end())
398
+ if doc_match:
399
+ class_node.docstring = doc_match.group(1).strip()
400
+
401
+ # Extract bases (extends edges)
402
+ if bases.strip():
403
+ for base in bases.split(','):
404
+ base = base.strip()
405
+ if base and base != 'object':
406
+ # Create base class node reference
407
+ base_id = _make_node_id(norm_path, 'class', base.split('.')[-1])
408
+ edges.append(Edge(
409
+ source=class_node.id, target=base_id, kind='extends'
410
+ ))
411
+
412
+ # Extract methods inside the class
413
+ class_body = '\n'.join(lines[start_line - 1:end_line])
414
+ method_offset = start_line - 1
415
+ for m in PY_FUNCTION_RE.finditer(class_body):
416
+ # Get the indentation of the matched function
417
+ raw_indent = m.group('indent') or ''
418
+ indent = len(raw_indent)
419
+ if indent <= 0:
420
+ continue # not indented = top-level, skip
421
+
422
+ is_async = 'async' in m.group(0)[:10]
423
+ fname = m.group('name')
424
+ fparams = m.group('params') or ''
425
+ fret = m.group('return_type')
426
+
427
+ fstart = method_offset + class_body[:m.start()].count('\n') + 1
428
+ # Clamp fstart to valid range
429
+ fstart = max(1, min(fstart, len(lines)))
430
+ fend = _find_block_end(lines, fstart)
431
+ fend = max(fstart, fend)
432
+
433
+ # Only include if within class bounds
434
+ if fstart > end_line:
435
+ continue
436
+
437
+ # Check for @staticmethod, @classmethod
438
+ pre_line = lines[fstart - 2].strip() if fstart > 1 else ''
439
+ is_static = pre_line == '@staticmethod'
440
+ is_classmethod = pre_line == '@classmethod'
441
+
442
+ method_node = Node(
443
+ id=_make_node_id(norm_path, 'method', f'{name}.{fname}'),
444
+ kind='method',
445
+ name=fname,
446
+ qualified_name=f'{norm_path}::{name}.{fname}',
447
+ file_path=norm_path,
448
+ language='python',
449
+ start_line=fstart,
450
+ end_line=fend,
451
+ start_column=0,
452
+ end_column=0,
453
+ signature=f'({fparams})' + (f' -> {fret}' if fret else ''),
454
+ is_async=is_async,
455
+ is_static=is_static,
456
+ is_exported=True,
457
+ decorators=[pre_line.replace('@', '')] if pre_line.startswith('@') else None,
458
+ )
459
+ nodes.append(method_node)
460
+ edges.append(Edge(source=class_node.id, target=method_node.id, kind='contains'))
461
+
462
+ # Extract top-level functions
463
+ for match in PY_FUNCTION_RE.finditer(content):
464
+ fname = match.group('name')
465
+ fparams = match.group('params') or ''
466
+ fret = match.group('return_type')
467
+
468
+ start_line = content[:match.start()].count('\n') + 1
469
+
470
+ # Skip if inside a class (the line itself is indented)
471
+ actual_line = lines[start_line - 1] if start_line <= len(lines) else ''
472
+ actual_indent = len(actual_line) - len(actual_line.lstrip())
473
+ if actual_indent > 0:
474
+ continue
475
+
476
+ ftype = match.group(0).strip().startswith('async')
477
+ end_line = _find_block_end(lines, start_line)
478
+
479
+ # Check decorators
480
+ decorators = []
481
+ line_idx = start_line - 2
482
+ while line_idx >= 0:
483
+ l = lines[line_idx].strip()
484
+ if l.startswith('@'):
485
+ decorators.insert(0, l[1:])
486
+ line_idx -= 1
487
+ else:
488
+ break
489
+
490
+ is_exported = not fname.startswith('_')
491
+ is_async = 'async' in match.group(0)[:10]
492
+
493
+ func_node = Node(
494
+ id=_make_node_id(norm_path, 'function', fname),
495
+ kind='function',
496
+ name=fname,
497
+ qualified_name=f'{norm_path}::{fname}',
498
+ file_path=norm_path,
499
+ language='python',
500
+ start_line=start_line,
501
+ end_line=end_line,
502
+ start_column=0,
503
+ end_column=0,
504
+ signature=f'({fparams})' + (f' -> {fret}' if fret else ''),
505
+ is_async=is_async,
506
+ is_exported=is_exported,
507
+ decorators=decorators if decorators else None,
508
+ )
509
+ nodes.append(func_node)
510
+ edges.append(Edge(source=file_node.id, target=func_node.id, kind='contains'))
511
+
512
+ # Add decorator edges
513
+ for dec in decorators:
514
+ edges.append(Edge(source=func_node.id, target=dec, kind='decorates'))
515
+
516
+ # Extract imports
517
+ for match in PY_IMPORT_RE.finditer(content):
518
+ start_line = content[:match.start()].count('\n') + 1
519
+
520
+ if match.group('modules'):
521
+ modules = match.group('modules')
522
+ for mod in modules.split(','):
523
+ mod = mod.strip()
524
+ if mod:
525
+ imp_node = Node(
526
+ id=_make_node_id(norm_path, 'import', mod),
527
+ kind='import',
528
+ name=mod,
529
+ qualified_name=mod,
530
+ file_path=norm_path,
531
+ language='python',
532
+ start_line=start_line,
533
+ end_line=start_line,
534
+ start_column=0,
535
+ end_column=0,
536
+ )
537
+ nodes.append(imp_node)
538
+ edges.append(Edge(source=file_node.id, target=imp_node.id, kind='contains'))
539
+
540
+ if match.group('from_module'):
541
+ from_mod = match.group('from_module').strip()
542
+ imports = match.group('imports')
543
+ for imp in imports.split(','):
544
+ imp = imp.strip()
545
+ if imp:
546
+ imp_node = Node(
547
+ id=_make_node_id(norm_path, 'import', f'{from_mod}.{imp}'),
548
+ kind='import',
549
+ name=imp,
550
+ qualified_name=f'{from_mod}.{imp}',
551
+ file_path=norm_path,
552
+ language='python',
553
+ start_line=start_line,
554
+ end_line=start_line,
555
+ start_column=0,
556
+ end_column=0,
557
+ )
558
+ nodes.append(imp_node)
559
+ edges.append(Edge(source=file_node.id, target=imp_node.id, kind='contains'))
560
+ # Create import edge pointing to the module
561
+ edges.append(Edge(source=imp_node.id, target=from_mod, kind='imports'))
562
+
563
+ return ExtractionResult(
564
+ nodes=nodes,
565
+ edges=edges,
566
+ errors=errors,
567
+ unresolved_references=unresolved,
568
+ )
569
+
570
+
571
+ def _find_block_end(lines: List[str], start_line: int) -> int:
572
+ """Find the end line of a Python block (returns to previous indentation)."""
573
+ if start_line > len(lines):
574
+ return len(lines)
575
+
576
+ # Find the indentation of the first line
577
+ first_line = lines[start_line - 1] if start_line > 0 else ''
578
+ indent = len(first_line) - len(first_line.lstrip())
579
+
580
+ # Handle single-line blocks (e.g., decorators, one-liners)
581
+ if indent == 0 and start_line <= len(lines):
582
+ stripped = lines[start_line - 1].strip()
583
+ if stripped.endswith(':') and start_line < len(lines):
584
+ next_line = lines[start_line].strip()
585
+ if next_line and not next_line.startswith(('#', '@', '"""', "'")):
586
+ if len(lines[start_line]) - len(lines[start_line].lstrip()) <= indent:
587
+ return start_line
588
+
589
+ for i in range(start_line, len(lines) + 1):
590
+ if i >= len(lines):
591
+ return len(lines)
592
+ line = lines[i]
593
+ if line.strip() == '':
594
+ continue
595
+ current_indent = len(line) - len(line.lstrip())
596
+ if current_indent <= indent and line.strip() and not line.strip().startswith('#'):
597
+ # Check if it's a continuation of a decorator
598
+ if line.strip().startswith('@'):
599
+ continue
600
+ # Check for class/function/method at same level
601
+ stripped = line.strip()
602
+ if stripped.startswith(('def ', 'class ', 'async def ', '@')):
603
+ return i
604
+ return i
605
+
606
+ return len(lines)
607
+
608
+
609
+ # =============================================================================
610
+ # Parser Dispatch
611
+ # =============================================================================
612
+
613
+ def parse_with_treesitter(file_path: str, content: str, language: str) -> ExtractionResult:
614
+ """
615
+ Parse a source file and extract code symbols.
616
+
617
+ Uses tree-sitter AST parsing for supported languages (Python, JavaScript,
618
+ TypeScript, Go, Java, Rust), with regex-based Python parser as fallback.
619
+ For unsupported languages, creates a basic file node.
620
+
621
+ Args:
622
+ file_path: Path to the source file (relative to project root)
623
+ content: File content
624
+ language: Programming language
625
+
626
+ Returns:
627
+ ExtractionResult with parsed nodes and edges
628
+ """
629
+ # Try tree-sitter first
630
+ from .tree_sitter_extractor import parse_file
631
+ result = parse_file(file_path, content, language)
632
+
633
+ # If tree-sitter returned useful nodes, we're done
634
+ if len(result.nodes) > 0 or len(result.errors) == 0:
635
+ # For python, regex parser has better decorator/docstring support
636
+ if language == 'python' and len(result.nodes) <= 1:
637
+ return parse_python(file_path, content)
638
+ return result
639
+
640
+ # Fallback for Python
641
+ if language == 'python':
642
+ return parse_python(file_path, content)
643
+
644
+ # Fallback: create a basic file node
645
+ from codegraph.types import Node, Edge, ExtractionResult
646
+ nodes = []
647
+ lines = content.splitlines()
648
+ nlines = len(lines)
649
+ norm_path = _normalize_path(file_path)
650
+
651
+ file_node = Node(
652
+ id=f'file:{norm_path}',
653
+ kind='file',
654
+ name=Path(file_path).name,
655
+ qualified_name=norm_path,
656
+ file_path=norm_path,
657
+ language=language,
658
+ start_line=1,
659
+ end_line=nlines,
660
+ start_column=0,
661
+ end_column=0,
662
+ )
663
+ nodes.append(file_node)
664
+
665
+ return ExtractionResult(nodes=nodes)
666
+
667
+
668
+ # =============================================================================
669
+ # Extraction Orchestrator
670
+ # =============================================================================
671
+
672
+ class ExtractionOrchestrator:
673
+ """
674
+ Orchestrates the extraction of code elements from source files.
675
+
676
+ Handles:
677
+ - File scanning (respecting .gitignore)
678
+ - Language detection
679
+ - Code parsing (via tree-sitter)
680
+ - Database storage of results
681
+ """
682
+
683
+ def __init__(
684
+ self,
685
+ root_path: str,
686
+ db: QueryBuilder,
687
+ ignore_patterns: Optional[List[str]] = None,
688
+ ):
689
+ """
690
+ Initialize the extraction orchestrator.
691
+
692
+ Args:
693
+ root_path: Root directory to scan
694
+ db: Database query builder for storing results
695
+ ignore_patterns: Additional patterns to ignore
696
+ """
697
+ self.root_path = Path(root_path).resolve()
698
+ self.db = db
699
+ self.ignore_patterns = ignore_patterns or []
700
+ self._gitignore = GitIgnore(str(self.root_path))
701
+
702
+ def scan_files(
703
+ self,
704
+ extensions: Optional[List[str]] = None,
705
+ ) -> List[str]:
706
+ """
707
+ Scan for source files in the root path.
708
+
709
+ Args:
710
+ extensions: Optional list of extensions to filter (e.g., ['.py', '.ts'])
711
+
712
+ Returns:
713
+ List of relative file paths
714
+ """
715
+ files: List[str] = []
716
+
717
+ for root, dirs, filenames in os.walk(self.root_path):
718
+ # Skip hidden directories and common ignore paths
719
+ dirs[:] = [
720
+ d for d in dirs
721
+ if not d.startswith('.')
722
+ and d not in ('node_modules', '__pycache__', 'venv', '.venv')
723
+ and d not in self.ignore_patterns
724
+ ]
725
+
726
+ for filename in filenames:
727
+ if filename.startswith('.'):
728
+ continue
729
+
730
+ file_path = os.path.join(root, filename)
731
+
732
+ # Check gitignore
733
+ try:
734
+ rel_path = os.path.relpath(file_path, self.root_path)
735
+ except ValueError:
736
+ continue
737
+
738
+ if self._gitignore.matches(rel_path):
739
+ continue
740
+
741
+ # Check extension filter
742
+ if extensions:
743
+ ext = Path(filename).suffix.lower()
744
+ if ext not in extensions:
745
+ continue
746
+
747
+ # Check if it's a source file
748
+ if is_source_file(file_path):
749
+ files.append(rel_path)
750
+
751
+ return sorted(files)
752
+
753
+ def index_file(
754
+ self,
755
+ file_path: str,
756
+ force: bool = False,
757
+ ) -> IndexResult:
758
+ """
759
+ Index a single file.
760
+
761
+ Args:
762
+ file_path: Path to the file (relative to root)
763
+ force: Force re-indexing even if file hasn't changed
764
+
765
+ Returns:
766
+ IndexResult with extraction results
767
+ """
768
+ start_time = time.time()
769
+
770
+ # Resolve full path
771
+ full_path = self.root_path / file_path
772
+
773
+ if not full_path.exists():
774
+ return IndexResult(
775
+ file_path=file_path,
776
+ success=False,
777
+ skipped=True,
778
+ skipped_reason='file_not_found',
779
+ )
780
+
781
+ # Detect language
782
+ language = detect_language(file_path)
783
+
784
+ if language == 'unknown':
785
+ return IndexResult(
786
+ file_path=file_path,
787
+ success=False,
788
+ skipped=True,
789
+ skipped_reason='unknown_language',
790
+ )
791
+
792
+ # Check if file has changed
793
+ try:
794
+ content = full_path.read_text(encoding='utf-8')
795
+ content_hash = hashlib.md5(content.encode()).hexdigest()
796
+ file_stat = full_path.stat()
797
+ file_size = file_stat.st_size
798
+ modified_at = int(file_stat.st_mtime * 1000)
799
+ except (OSError, UnicodeDecodeError) as e:
800
+ return IndexResult(
801
+ file_path=file_path,
802
+ success=False,
803
+ errors=[ExtractionError(
804
+ message=f"Failed to read file: {str(e)}",
805
+ file_path=file_path,
806
+ severity='error',
807
+ )],
808
+ )
809
+
810
+ # Check existing file record
811
+ existing_file = self.db.get_file(file_path)
812
+ if existing_file and not force:
813
+ if existing_file.content_hash == content_hash:
814
+ return IndexResult(
815
+ file_path=file_path,
816
+ success=True,
817
+ nodes_count=existing_file.node_count,
818
+ skipped=True,
819
+ skipped_reason='unchanged',
820
+ )
821
+
822
+ # Delete existing nodes and edges for this file
823
+ self.db.delete_nodes_by_file(file_path)
824
+ self.db.delete_edges_for_file(file_path)
825
+ self.db.delete_unresolved_refs_for_file(file_path)
826
+
827
+ # Parse the file - use normalized relative path
828
+ import os as _os
829
+ rel_path = _os.path.relpath(str(full_path), str(self.root_path)).replace('\\', '/')
830
+ result = parse_with_treesitter(rel_path, content, language)
831
+
832
+ # Store nodes
833
+ if result.nodes:
834
+ self.db.insert_nodes(result.nodes)
835
+
836
+ # Store edges
837
+ if result.edges:
838
+ self.db.insert_edges(result.edges)
839
+
840
+ # Store unresolved references
841
+ if result.unresolved_references:
842
+ for ref in result.unresolved_references:
843
+ self.db.insert_unresolved_ref(ref)
844
+
845
+ # Upsert file record
846
+ indexed_at = int(time.time() * 1000)
847
+ file_record = FileRecord(
848
+ path=file_path,
849
+ content_hash=content_hash,
850
+ language=language,
851
+ size=file_size,
852
+ modified_at=modified_at,
853
+ indexed_at=indexed_at,
854
+ node_count=len(result.nodes),
855
+ errors=result.errors if result.errors else None,
856
+ )
857
+ self.db.upsert_file(file_record)
858
+
859
+ duration_ms = (time.time() - start_time) * 1000
860
+
861
+ return IndexResult(
862
+ file_path=file_path,
863
+ success=True,
864
+ nodes_count=len(result.nodes),
865
+ edges_count=len(result.edges),
866
+ errors=result.errors,
867
+ duration_ms=duration_ms,
868
+ )
869
+
870
+ def sync(
871
+ self,
872
+ force: bool = False,
873
+ extensions: Optional[List[str]] = None,
874
+ ) -> SyncResult:
875
+ """
876
+ Perform a full sync: scan, index, and clean up deleted files.
877
+
878
+ Args:
879
+ force: Force re-indexing of all files
880
+ extensions: Optional list of extensions to filter
881
+
882
+ Returns:
883
+ SyncResult with sync statistics
884
+ """
885
+ start_time = time.time()
886
+
887
+ # Scan for files
888
+ files_to_index = self.scan_files(extensions=extensions)
889
+
890
+ # Get existing files from database
891
+ existing_files = set(self.db.get_all_file_paths())
892
+ files_to_index_set = set(files_to_index)
893
+
894
+ # Find deleted files
895
+ deleted_files = existing_files - files_to_index_set
896
+
897
+ # Remove deleted files from database
898
+ for file_path in deleted_files:
899
+ self.db.delete_file(file_path)
900
+ self.db.delete_nodes_by_file(file_path)
901
+ self.db.delete_edges_for_file(file_path)
902
+
903
+ # Index files
904
+ progress = IndexProgress(total_files=len(files_to_index))
905
+ indexed_files: List[str] = []
906
+ skipped_files: List[str] = []
907
+ total_nodes = 0
908
+ total_edges = 0
909
+ total_errors = 0
910
+
911
+ for file_path in files_to_index:
912
+ progress.current_file = file_path
913
+
914
+ result = self.index_file(file_path, force=force)
915
+
916
+ if result.success:
917
+ if result.skipped:
918
+ skipped_files.append(file_path)
919
+ else:
920
+ indexed_files.append(file_path)
921
+ total_nodes += result.nodes_count
922
+ total_edges += result.edges_count
923
+ total_errors += len(result.errors)
924
+ else:
925
+ progress.errors.append(f"{file_path}: {result.errors}")
926
+ total_errors += 1
927
+
928
+ progress.processed_files += 1
929
+
930
+ progress.current_file = None
931
+ duration_ms = (time.time() - start_time) * 1000
932
+
933
+ return SyncResult(
934
+ indexed_files=indexed_files,
935
+ skipped_files=skipped_files,
936
+ deleted_files=list(deleted_files),
937
+ total_nodes=total_nodes,
938
+ total_edges=total_edges,
939
+ total_errors=total_errors,
940
+ duration_ms=duration_ms,
941
+ progress=progress,
942
+ )
943
+
944
+
945
+ __all__ = [
946
+ # Language detection
947
+ 'LANGUAGES',
948
+ 'detect_language',
949
+ 'is_source_file',
950
+ # Types
951
+ 'IndexProgress',
952
+ 'IndexResult',
953
+ 'SyncResult',
954
+ # Orchestrator
955
+ 'ExtractionOrchestrator',
956
+ ]