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.
- codegraph/__init__.py +12 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +911 -0
- codegraph/codegraph.py +618 -0
- codegraph/context/__init__.py +216 -0
- codegraph/db/__init__.py +11 -0
- codegraph/db/connection.py +163 -0
- codegraph/db/queries.py +752 -0
- codegraph/db/schema.sql +151 -0
- codegraph/directory.py +160 -0
- codegraph/errors.py +101 -0
- codegraph/extraction/__init__.py +956 -0
- codegraph/extraction/languages/__init__.py +64 -0
- codegraph/extraction/languages/base.py +132 -0
- codegraph/extraction/languages/c_cfg.py +53 -0
- codegraph/extraction/languages/cpp_cfg.py +60 -0
- codegraph/extraction/languages/dart_cfg.py +53 -0
- codegraph/extraction/languages/go_cfg.py +70 -0
- codegraph/extraction/languages/java_cfg.py +62 -0
- codegraph/extraction/languages/javascript_cfg.py +84 -0
- codegraph/extraction/languages/kotlin_cfg.py +52 -0
- codegraph/extraction/languages/lua_cfg.py +65 -0
- codegraph/extraction/languages/python_cfg.py +75 -0
- codegraph/extraction/languages/ruby_cfg.py +48 -0
- codegraph/extraction/languages/rust_cfg.py +68 -0
- codegraph/extraction/languages/scala_cfg.py +59 -0
- codegraph/extraction/languages/swift_cfg.py +64 -0
- codegraph/extraction/languages/typescript_cfg.py +89 -0
- codegraph/extraction/tree_sitter_extractor.py +689 -0
- codegraph/graph/__init__.py +685 -0
- codegraph/installer/__init__.py +6 -0
- codegraph/mcp/__init__.py +668 -0
- codegraph/project_config.py +191 -0
- codegraph/resolution/__init__.py +337 -0
- codegraph/search/__init__.py +653 -0
- codegraph/sync/__init__.py +204 -0
- codegraph/types.py +334 -0
- codegraph/ui/__init__.py +11 -0
- codegraph/utils.py +218 -0
- codegraph_py-1.0.0.dist-info/METADATA +238 -0
- codegraph_py-1.0.0.dist-info/RECORD +44 -0
- codegraph_py-1.0.0.dist-info/WHEEL +5 -0
- codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|