srcodex 0.2.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.
- srcodex/__init__.py +0 -0
- srcodex/backend/__init__.py +0 -0
- srcodex/backend/chat.py +79 -0
- srcodex/backend/main.py +98 -0
- srcodex/backend/services/__init__.py +0 -0
- srcodex/backend/services/claude_service.py +754 -0
- srcodex/backend/services/config_loader.py +113 -0
- srcodex/backend/services/file_access_tools.py +279 -0
- srcodex/backend/services/file_tree.py +480 -0
- srcodex/backend/services/graph_tools.py +874 -0
- srcodex/backend/services/logger_setup.py +91 -0
- srcodex/backend/services/session_manager.py +81 -0
- srcodex/backend/services/status_tracker.py +91 -0
- srcodex/cli.py +255 -0
- srcodex/core/__init__.py +0 -0
- srcodex/core/config.py +113 -0
- srcodex/core/logger.py +23 -0
- srcodex/indexer/__init__.py +0 -0
- srcodex/indexer/cscope_client.py +183 -0
- srcodex/indexer/ctags_compat.py +223 -0
- srcodex/indexer/ctags_parser.py +456 -0
- srcodex/indexer/explorer.py +135 -0
- srcodex/indexer/field_access_analyzer.py +436 -0
- srcodex/indexer/indexer.py +664 -0
- srcodex/indexer/reference_ingestor.py +293 -0
- srcodex/indexer/reference_resolver.py +544 -0
- srcodex/tui/__init__.py +0 -0
- srcodex/tui/app.py +103 -0
- srcodex/tui/app.tcss +24 -0
- srcodex/tui/components/__init__.py +0 -0
- srcodex/tui/components/bars/__init__.py +0 -0
- srcodex/tui/components/bars/chat_header.py +48 -0
- srcodex/tui/components/bars/code_tab_bar.py +157 -0
- srcodex/tui/components/bars/footer_bar.py +128 -0
- srcodex/tui/components/bars/left_tab.py +54 -0
- srcodex/tui/components/logger.py +57 -0
- srcodex/tui/components/panels/__init__.py +0 -0
- srcodex/tui/components/panels/chat_panel.py +523 -0
- srcodex/tui/components/panels/code_panel.py +229 -0
- srcodex/tui/components/panels/side_panel.py +128 -0
- srcodex/tui/components/views/__init__.py +0 -0
- srcodex/tui/components/views/explorer_view.py +20 -0
- srcodex/tui/components/views/search_view.py +148 -0
- srcodex/tui/components/widgets/__init__.py +0 -0
- srcodex/tui/components/widgets/file_browser.py +16 -0
- srcodex/tui/components/widgets/find_box.py +85 -0
- srcodex-0.2.0.dist-info/METADATA +170 -0
- srcodex-0.2.0.dist-info/RECORD +52 -0
- srcodex-0.2.0.dist-info/WHEEL +5 -0
- srcodex-0.2.0.dist-info/entry_points.txt +2 -0
- srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
- srcodex-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Reference Resolver - Convert raw references to semantic graph edges
|
|
4
|
+
Resolves (file, function) names → symbol IDs and stores typed edges in symbol_edges
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, List, Tuple, Dict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ResolutionStats:
|
|
18
|
+
"""Statistics for resolution process"""
|
|
19
|
+
total_raw_refs: int = 0
|
|
20
|
+
resolved_edges: int = 0
|
|
21
|
+
unresolved_src: int = 0
|
|
22
|
+
unresolved_dst: int = 0
|
|
23
|
+
ambiguous_dst: int = 0
|
|
24
|
+
skipped_parsing: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReferenceResolver:
|
|
28
|
+
"""Resolves raw cscope references into semantic graph edges with symbol IDs"""
|
|
29
|
+
|
|
30
|
+
# C keywords to exclude from callee extraction
|
|
31
|
+
C_KEYWORDS = {
|
|
32
|
+
'if', 'for', 'while', 'switch', 'return', 'sizeof', 'typeof',
|
|
33
|
+
'do', 'else', 'case', 'break', 'continue', 'goto', 'default'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def __init__(self, db_conn: sqlite3.Connection):
|
|
37
|
+
"""
|
|
38
|
+
Initialize reference resolver
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
db_conn: SQLite database connection (must have raw_references and symbols tables)
|
|
42
|
+
"""
|
|
43
|
+
self.conn = db_conn
|
|
44
|
+
self.stats = ResolutionStats()
|
|
45
|
+
|
|
46
|
+
# Symbol lookup caches for performance
|
|
47
|
+
self.symbol_cache = {} # (name, type) → [(id, file_path), ...]
|
|
48
|
+
self._build_symbol_cache()
|
|
49
|
+
|
|
50
|
+
def _build_symbol_cache(self):
|
|
51
|
+
"""Build in-memory cache of all symbols for fast lookup"""
|
|
52
|
+
print("Building symbol lookup cache...")
|
|
53
|
+
cursor = self.conn.cursor()
|
|
54
|
+
cursor.execute("SELECT id, name, type, file_path FROM symbols")
|
|
55
|
+
|
|
56
|
+
for row in cursor.fetchall():
|
|
57
|
+
sym_id, name, sym_type, file_path = row['id'], row['name'], row['type'], row['file_path']
|
|
58
|
+
key = (name, sym_type)
|
|
59
|
+
if key not in self.symbol_cache:
|
|
60
|
+
self.symbol_cache[key] = []
|
|
61
|
+
self.symbol_cache[key].append((sym_id, file_path))
|
|
62
|
+
|
|
63
|
+
print(f" Cached {len(self.symbol_cache)} unique (name, type) pairs")
|
|
64
|
+
|
|
65
|
+
def _extract_callee_from_line(self, line_text: str) -> Optional[str]:
|
|
66
|
+
"""
|
|
67
|
+
Extract callee function name from line_text using IDENT( pattern
|
|
68
|
+
|
|
69
|
+
Looks for patterns like: function_name(...)
|
|
70
|
+
Excludes: keywords, macro-ish ALL_CAPS (optional)
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
line_text: Raw line content from cscope
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Callee function name or None if not found/excluded
|
|
77
|
+
"""
|
|
78
|
+
# Pattern: identifier followed by '('
|
|
79
|
+
# Match: alphanumeric + underscore, then opening paren
|
|
80
|
+
pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\('
|
|
81
|
+
|
|
82
|
+
matches = re.findall(pattern, line_text)
|
|
83
|
+
|
|
84
|
+
for match in matches:
|
|
85
|
+
# Exclude C keywords
|
|
86
|
+
if match in self.C_KEYWORDS:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Optional: exclude macro-ish ALL_CAPS
|
|
90
|
+
# Uncomment to enable:
|
|
91
|
+
# if match.isupper() and len(match) > 2:
|
|
92
|
+
# continue
|
|
93
|
+
|
|
94
|
+
# Return first valid match
|
|
95
|
+
return match
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def _resolve_src_symbol(self, query_symbol: str, source_file: str) -> Optional[int]:
|
|
100
|
+
"""
|
|
101
|
+
Resolve source symbol (caller) by query_symbol name
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
query_symbol: Function name that was queried (caller)
|
|
105
|
+
source_file: File where caller is defined (for disambiguation)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
symbol_id or None if not found/ambiguous
|
|
109
|
+
"""
|
|
110
|
+
# Fast lookup in cache (no database query)
|
|
111
|
+
results = self.symbol_cache.get((query_symbol, 'function'), [])
|
|
112
|
+
|
|
113
|
+
if len(results) == 0:
|
|
114
|
+
return None # Not found
|
|
115
|
+
|
|
116
|
+
if len(results) == 1:
|
|
117
|
+
return results[0][0] # Unique match
|
|
118
|
+
|
|
119
|
+
# Multiple matches: prefer same file
|
|
120
|
+
for row in results:
|
|
121
|
+
if row[1] == source_file:
|
|
122
|
+
return row[0]
|
|
123
|
+
|
|
124
|
+
# Still ambiguous: return None (could pick first, but being strict)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _resolve_dst_symbol(self, callee_name: str, source_file: str) -> Optional[int]:
|
|
128
|
+
"""
|
|
129
|
+
Resolve destination symbol (callee) by function name with disambiguation
|
|
130
|
+
|
|
131
|
+
Disambiguation rules:
|
|
132
|
+
1. Accept if unique
|
|
133
|
+
2. Prefer same file
|
|
134
|
+
3. Prefer .c file over .h (definition vs declaration)
|
|
135
|
+
4. If still multiple: return None (unresolved)
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
callee_name: Function name being called
|
|
139
|
+
source_file: File where call occurs (for disambiguation)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
symbol_id or None if not found/ambiguous
|
|
143
|
+
"""
|
|
144
|
+
# Fast lookup in cache (no database query)
|
|
145
|
+
results = self.symbol_cache.get((callee_name, 'function'), [])
|
|
146
|
+
|
|
147
|
+
if len(results) == 0:
|
|
148
|
+
return None # Not found
|
|
149
|
+
|
|
150
|
+
if len(results) == 1:
|
|
151
|
+
return results[0][0] # Unique match
|
|
152
|
+
|
|
153
|
+
# Multiple matches: prefer same file first
|
|
154
|
+
for row in results:
|
|
155
|
+
if row[1] == source_file:
|
|
156
|
+
return row[0]
|
|
157
|
+
|
|
158
|
+
# Still ambiguous: prefer .c file over .h (definition vs declaration)
|
|
159
|
+
c_files = [row for row in results if row[1].endswith('.c')]
|
|
160
|
+
if len(c_files) == 1:
|
|
161
|
+
return c_files[0][0]
|
|
162
|
+
|
|
163
|
+
# Still ambiguous: unresolved
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def resolve_callees(self, clear_existing: bool = False) -> Dict[str, int]:
|
|
167
|
+
"""
|
|
168
|
+
Resolve callgraph edges: raw_references (query_type='callees') → symbol_edges
|
|
169
|
+
|
|
170
|
+
For each raw reference:
|
|
171
|
+
1. Extract callee from line_text (IDENT( pattern)
|
|
172
|
+
2. Resolve src_symbol_id (query_symbol → symbol.id)
|
|
173
|
+
3. Resolve dst_symbol_id (callee → symbol.id)
|
|
174
|
+
4. Insert into symbol_edges with edge_type='CALLS'
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
clear_existing: If True, delete existing CALLS edges before resolution
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Dictionary with resolution statistics
|
|
181
|
+
"""
|
|
182
|
+
if clear_existing:
|
|
183
|
+
print("Clearing existing CALLS edges...")
|
|
184
|
+
self.conn.execute("DELETE FROM symbol_edges WHERE edge_type = 'CALLS'")
|
|
185
|
+
self.conn.commit()
|
|
186
|
+
|
|
187
|
+
# Fetch all raw references with query_type='callees'
|
|
188
|
+
cursor = self.conn.cursor()
|
|
189
|
+
cursor.execute(
|
|
190
|
+
"""SELECT id, query_symbol, source_file, source_function, line_number, line_text
|
|
191
|
+
FROM raw_references
|
|
192
|
+
WHERE query_type = 'callees'
|
|
193
|
+
ORDER BY id"""
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
raw_refs = cursor.fetchall()
|
|
197
|
+
self.stats.total_raw_refs = len(raw_refs)
|
|
198
|
+
|
|
199
|
+
print(f"Found {self.stats.total_raw_refs} raw references to resolve")
|
|
200
|
+
|
|
201
|
+
if not raw_refs:
|
|
202
|
+
print("Warning: No raw references found")
|
|
203
|
+
return self._stats_dict()
|
|
204
|
+
|
|
205
|
+
# Prepare batch insert for resolved edges
|
|
206
|
+
edges_batch = []
|
|
207
|
+
batch_size = 10000 # Commit every 10k edges for safety
|
|
208
|
+
|
|
209
|
+
# Track unresolved reasons for reporting
|
|
210
|
+
unresolved_reasons = Counter()
|
|
211
|
+
|
|
212
|
+
print("Resolving symbol IDs...")
|
|
213
|
+
for i, row in enumerate(tqdm(raw_refs, desc="Resolving edges")):
|
|
214
|
+
raw_id, query_symbol, source_file, source_function, line_number, line_text = row
|
|
215
|
+
|
|
216
|
+
# Step 1: Extract callee from line_text
|
|
217
|
+
callee_name = self._extract_callee_from_line(line_text)
|
|
218
|
+
if not callee_name:
|
|
219
|
+
self.stats.skipped_parsing += 1
|
|
220
|
+
unresolved_reasons['no_callee_in_line'] += 1
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Step 2: Resolve src_symbol_id (caller)
|
|
224
|
+
src_symbol_id = self._resolve_src_symbol(query_symbol, source_file)
|
|
225
|
+
if not src_symbol_id:
|
|
226
|
+
self.stats.unresolved_src += 1
|
|
227
|
+
unresolved_reasons['src_not_found'] += 1
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Step 3: Resolve dst_symbol_id (callee)
|
|
231
|
+
dst_symbol_id = self._resolve_dst_symbol(callee_name, source_file)
|
|
232
|
+
if not dst_symbol_id:
|
|
233
|
+
self.stats.unresolved_dst += 1
|
|
234
|
+
unresolved_reasons['dst_not_found_or_ambiguous'] += 1
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Both resolved: add to batch
|
|
238
|
+
edges_batch.append((
|
|
239
|
+
'CALLS', # edge_type
|
|
240
|
+
src_symbol_id, # src_symbol_id (caller)
|
|
241
|
+
dst_symbol_id, # dst_symbol_id (callee)
|
|
242
|
+
source_file, # source_file (where edge occurs)
|
|
243
|
+
line_number, # line_number
|
|
244
|
+
))
|
|
245
|
+
|
|
246
|
+
# Batch commit every N edges for safety (prevents losing all progress)
|
|
247
|
+
if len(edges_batch) >= batch_size:
|
|
248
|
+
cursor.executemany(
|
|
249
|
+
"""INSERT OR IGNORE INTO symbol_edges
|
|
250
|
+
(edge_type, src_symbol_id, dst_symbol_id, source_file, line_number)
|
|
251
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
252
|
+
edges_batch
|
|
253
|
+
)
|
|
254
|
+
self.conn.commit()
|
|
255
|
+
self.stats.resolved_edges += len(edges_batch)
|
|
256
|
+
edges_batch.clear()
|
|
257
|
+
|
|
258
|
+
# Insert remaining edges
|
|
259
|
+
if edges_batch:
|
|
260
|
+
cursor.executemany(
|
|
261
|
+
"""INSERT OR IGNORE INTO symbol_edges
|
|
262
|
+
(edge_type, src_symbol_id, dst_symbol_id, source_file, line_number)
|
|
263
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
264
|
+
edges_batch
|
|
265
|
+
)
|
|
266
|
+
self.conn.commit()
|
|
267
|
+
self.stats.resolved_edges += len(edges_batch)
|
|
268
|
+
|
|
269
|
+
print(f"Inserted {self.stats.resolved_edges} CALLS edges")
|
|
270
|
+
|
|
271
|
+
# Print resolution statistics
|
|
272
|
+
self._print_stats(unresolved_reasons)
|
|
273
|
+
|
|
274
|
+
return self._stats_dict()
|
|
275
|
+
|
|
276
|
+
def _stats_dict(self) -> Dict[str, int]:
|
|
277
|
+
"""Convert stats to dictionary"""
|
|
278
|
+
return {
|
|
279
|
+
'total_raw_refs': self.stats.total_raw_refs,
|
|
280
|
+
'resolved_edges': self.stats.resolved_edges,
|
|
281
|
+
'unresolved_src': self.stats.unresolved_src,
|
|
282
|
+
'unresolved_dst': self.stats.unresolved_dst,
|
|
283
|
+
'skipped_parsing': self.stats.skipped_parsing,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def _print_stats(self, unresolved_reasons: Counter):
|
|
287
|
+
"""Print resolution statistics"""
|
|
288
|
+
print()
|
|
289
|
+
print("=" * 60)
|
|
290
|
+
print("Resolution Statistics")
|
|
291
|
+
print("=" * 60)
|
|
292
|
+
print(f"Total raw references: {self.stats.total_raw_refs}")
|
|
293
|
+
print(f"Resolved edges: {self.stats.resolved_edges}")
|
|
294
|
+
print(f"Unresolved (src): {self.stats.unresolved_src}")
|
|
295
|
+
print(f"Unresolved (dst): {self.stats.unresolved_dst}")
|
|
296
|
+
print(f"Skipped (no callee): {self.stats.skipped_parsing}")
|
|
297
|
+
|
|
298
|
+
if unresolved_reasons:
|
|
299
|
+
print()
|
|
300
|
+
print("Unresolved breakdown:")
|
|
301
|
+
for reason, count in unresolved_reasons.most_common():
|
|
302
|
+
print(f" {reason}: {count}")
|
|
303
|
+
|
|
304
|
+
# Calculate resolution rate
|
|
305
|
+
if self.stats.total_raw_refs > 0:
|
|
306
|
+
rate = (self.stats.resolved_edges / self.stats.total_raw_refs) * 100
|
|
307
|
+
print()
|
|
308
|
+
print(f"Resolution rate: {rate:.1f}%")
|
|
309
|
+
|
|
310
|
+
def resolve_includes(self, clear_existing: bool = False) -> Dict[str, int]:
|
|
311
|
+
"""
|
|
312
|
+
Resolve raw includes references into file_edges (file-to-file INCLUDES relationships)
|
|
313
|
+
|
|
314
|
+
For each raw_references row with query_type='includes':
|
|
315
|
+
- source_file: file that includes the header
|
|
316
|
+
- query_symbol: header basename (e.g., "power.h")
|
|
317
|
+
- Resolve query_symbol → canonical repo-relative header path
|
|
318
|
+
- Insert INCLUDES edge into file_edges
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
clear_existing: If True, delete existing INCLUDES edges before resolution
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Dictionary with resolution statistics
|
|
325
|
+
"""
|
|
326
|
+
if clear_existing:
|
|
327
|
+
print("Clearing existing INCLUDES file edges...")
|
|
328
|
+
self.conn.execute("DELETE FROM file_edges WHERE edge_type = 'INCLUDES'")
|
|
329
|
+
self.conn.commit()
|
|
330
|
+
|
|
331
|
+
# Get all includes raw references
|
|
332
|
+
cursor = self.conn.cursor()
|
|
333
|
+
cursor.execute(
|
|
334
|
+
"""SELECT id, query_symbol, source_file, line_number
|
|
335
|
+
FROM raw_references
|
|
336
|
+
WHERE query_type = 'includes'
|
|
337
|
+
ORDER BY id"""
|
|
338
|
+
)
|
|
339
|
+
raw_refs = cursor.fetchall()
|
|
340
|
+
|
|
341
|
+
print(f"Found {len(raw_refs)} raw includes references to resolve")
|
|
342
|
+
|
|
343
|
+
if not raw_refs:
|
|
344
|
+
return {'total_raw_refs': 0, 'resolved_edges': 0, 'unresolved': 0, 'ambiguous': 0}
|
|
345
|
+
|
|
346
|
+
# Resolution statistics
|
|
347
|
+
stats = {
|
|
348
|
+
'total_raw_refs': len(raw_refs),
|
|
349
|
+
'resolved_edges': 0,
|
|
350
|
+
'unresolved': 0,
|
|
351
|
+
'ambiguous': 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
edges_batch = []
|
|
355
|
+
batch_size = 5000 # Commit every 5k edges
|
|
356
|
+
unresolved_headers = Counter()
|
|
357
|
+
|
|
358
|
+
print("Resolving header paths...")
|
|
359
|
+
for i, row in enumerate(tqdm(raw_refs, desc="Resolving includes")):
|
|
360
|
+
query_symbol = row['query_symbol'] # e.g., "power.h" or "common/power.h"
|
|
361
|
+
source_file = row['source_file']
|
|
362
|
+
line_number = row['line_number']
|
|
363
|
+
|
|
364
|
+
# Resolve header basename to canonical repo-relative path using directory proximity
|
|
365
|
+
resolved_path = self._resolve_header_path(query_symbol, source_file)
|
|
366
|
+
|
|
367
|
+
if resolved_path is None:
|
|
368
|
+
stats['unresolved'] += 1
|
|
369
|
+
unresolved_headers[query_symbol] += 1
|
|
370
|
+
continue
|
|
371
|
+
elif isinstance(resolved_path, list):
|
|
372
|
+
# Ambiguous: multiple matches (even after proximity heuristic)
|
|
373
|
+
stats['ambiguous'] += 1
|
|
374
|
+
unresolved_headers[f"{query_symbol} (ambiguous: {len(resolved_path)} matches)"] += 1
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
# Valid resolution: add to batch
|
|
378
|
+
edges_batch.append((
|
|
379
|
+
'INCLUDES', # edge_type
|
|
380
|
+
source_file, # src_file (includer)
|
|
381
|
+
resolved_path, # dst_file (included header)
|
|
382
|
+
line_number # line_number
|
|
383
|
+
))
|
|
384
|
+
|
|
385
|
+
# Batch commit every N edges
|
|
386
|
+
if len(edges_batch) >= batch_size:
|
|
387
|
+
cursor.executemany(
|
|
388
|
+
"""INSERT OR IGNORE INTO file_edges
|
|
389
|
+
(edge_type, src_file, dst_file, line_number)
|
|
390
|
+
VALUES (?, ?, ?, ?)""",
|
|
391
|
+
edges_batch
|
|
392
|
+
)
|
|
393
|
+
self.conn.commit()
|
|
394
|
+
stats['resolved_edges'] += len(edges_batch)
|
|
395
|
+
edges_batch.clear()
|
|
396
|
+
|
|
397
|
+
# Insert remaining edges
|
|
398
|
+
if edges_batch:
|
|
399
|
+
cursor.executemany(
|
|
400
|
+
"""INSERT OR IGNORE INTO file_edges
|
|
401
|
+
(edge_type, src_file, dst_file, line_number)
|
|
402
|
+
VALUES (?, ?, ?, ?)""",
|
|
403
|
+
edges_batch
|
|
404
|
+
)
|
|
405
|
+
self.conn.commit()
|
|
406
|
+
stats['resolved_edges'] += len(edges_batch)
|
|
407
|
+
|
|
408
|
+
print(f"Inserted {stats['resolved_edges']} INCLUDES edges")
|
|
409
|
+
|
|
410
|
+
# Print resolution statistics
|
|
411
|
+
print()
|
|
412
|
+
print("=" * 60)
|
|
413
|
+
print("Includes Resolution Statistics")
|
|
414
|
+
print("=" * 60)
|
|
415
|
+
print(f"Total raw includes: {stats['total_raw_refs']}")
|
|
416
|
+
print(f"Resolved edges: {stats['resolved_edges']}")
|
|
417
|
+
print(f"Unresolved headers: {stats['unresolved']}")
|
|
418
|
+
print(f"Ambiguous headers: {stats['ambiguous']}")
|
|
419
|
+
|
|
420
|
+
if unresolved_headers:
|
|
421
|
+
print()
|
|
422
|
+
print("Unresolved breakdown:")
|
|
423
|
+
for header, count in unresolved_headers.most_common(10):
|
|
424
|
+
print(f" {header}: {count}")
|
|
425
|
+
|
|
426
|
+
# Calculate resolution rate
|
|
427
|
+
if stats['total_raw_refs'] > 0:
|
|
428
|
+
rate = (stats['resolved_edges'] / stats['total_raw_refs']) * 100
|
|
429
|
+
print()
|
|
430
|
+
print(f"Resolution rate: {rate:.1f}%")
|
|
431
|
+
|
|
432
|
+
return stats
|
|
433
|
+
|
|
434
|
+
def _resolve_header_path(self, query_symbol: str, source_file: str) -> Optional[str]:
|
|
435
|
+
"""
|
|
436
|
+
Resolve header basename to canonical repo-relative path using directory proximity
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
query_symbol: Header name (e.g., "power.h" or "common/power.h")
|
|
440
|
+
source_file: Source file that includes this header (e.g., "mp1/src/app/acpi.c")
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
- Canonical path string if exactly 1 match or best proximity match
|
|
444
|
+
- None if 0 matches (unresolved)
|
|
445
|
+
- List of paths if >1 match and can't disambiguate (ambiguous)
|
|
446
|
+
"""
|
|
447
|
+
# If query_symbol contains '/', treat as path candidate
|
|
448
|
+
if '/' in query_symbol:
|
|
449
|
+
# Check if this exact path exists
|
|
450
|
+
cursor = self.conn.cursor()
|
|
451
|
+
cursor.execute("SELECT path FROM files WHERE path = ?", (query_symbol,))
|
|
452
|
+
result = cursor.fetchone()
|
|
453
|
+
if result:
|
|
454
|
+
return result['path']
|
|
455
|
+
else:
|
|
456
|
+
return None # Path with '/' but doesn't exist
|
|
457
|
+
|
|
458
|
+
# Otherwise, search for basename match
|
|
459
|
+
cursor = self.conn.cursor()
|
|
460
|
+
cursor.execute(
|
|
461
|
+
"""SELECT path FROM files
|
|
462
|
+
WHERE path LIKE ? OR path = ?
|
|
463
|
+
ORDER BY path""",
|
|
464
|
+
(f'%/{query_symbol}', query_symbol)
|
|
465
|
+
)
|
|
466
|
+
matches = cursor.fetchall()
|
|
467
|
+
|
|
468
|
+
if len(matches) == 0:
|
|
469
|
+
return None # Unresolved
|
|
470
|
+
elif len(matches) == 1:
|
|
471
|
+
return matches[0]['path'] # Exact match
|
|
472
|
+
else:
|
|
473
|
+
# Ambiguous: use directory proximity heuristic
|
|
474
|
+
candidates = [row['path'] for row in matches]
|
|
475
|
+
best_match = self._pick_closest_header(source_file, candidates)
|
|
476
|
+
return best_match if best_match else candidates # Return best or list if can't decide
|
|
477
|
+
|
|
478
|
+
def _pick_closest_header(self, source_file: str, candidates: List[str]) -> Optional[str]:
|
|
479
|
+
"""
|
|
480
|
+
Pick the closest header file using directory proximity heuristic
|
|
481
|
+
|
|
482
|
+
Mimics C compiler include resolution:
|
|
483
|
+
1. Same directory (highest priority)
|
|
484
|
+
2. Subdirectory of source
|
|
485
|
+
3. Parent directory
|
|
486
|
+
4. Closest common ancestor
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
source_file: Source file path (e.g., "mp1/src/app/acpi.c")
|
|
490
|
+
candidates: List of candidate header paths
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Best match path, or None if can't disambiguate
|
|
494
|
+
"""
|
|
495
|
+
from pathlib import Path
|
|
496
|
+
|
|
497
|
+
source_dir = str(Path(source_file).parent) # e.g., "mp1/src/app"
|
|
498
|
+
|
|
499
|
+
# Priority 1: Same directory
|
|
500
|
+
for candidate in candidates:
|
|
501
|
+
candidate_dir = str(Path(candidate).parent)
|
|
502
|
+
if candidate_dir == source_dir:
|
|
503
|
+
return candidate
|
|
504
|
+
|
|
505
|
+
# Priority 2: Subdirectory of source directory
|
|
506
|
+
for candidate in candidates:
|
|
507
|
+
if candidate.startswith(source_dir + "/"):
|
|
508
|
+
return candidate
|
|
509
|
+
|
|
510
|
+
# Priority 3: Parent directory
|
|
511
|
+
source_parent = str(Path(source_dir).parent)
|
|
512
|
+
for candidate in candidates:
|
|
513
|
+
candidate_dir = str(Path(candidate).parent)
|
|
514
|
+
if candidate_dir == source_parent:
|
|
515
|
+
return candidate
|
|
516
|
+
|
|
517
|
+
# Priority 4: Closest common ancestor (minimum path distance)
|
|
518
|
+
def path_distance(path1: str, path2: str) -> int:
|
|
519
|
+
"""Calculate directory distance between two paths"""
|
|
520
|
+
parts1 = path1.split('/')
|
|
521
|
+
parts2 = path2.split('/')
|
|
522
|
+
|
|
523
|
+
# Find common prefix length
|
|
524
|
+
common = 0
|
|
525
|
+
for p1, p2 in zip(parts1, parts2):
|
|
526
|
+
if p1 == p2:
|
|
527
|
+
common += 1
|
|
528
|
+
else:
|
|
529
|
+
break
|
|
530
|
+
|
|
531
|
+
# Distance = steps up + steps down
|
|
532
|
+
return (len(parts1) - common) + (len(parts2) - common)
|
|
533
|
+
|
|
534
|
+
best = None
|
|
535
|
+
min_distance = float('inf')
|
|
536
|
+
|
|
537
|
+
for candidate in candidates:
|
|
538
|
+
candidate_dir = str(Path(candidate).parent)
|
|
539
|
+
distance = path_distance(source_dir, candidate_dir)
|
|
540
|
+
if distance < min_distance:
|
|
541
|
+
min_distance = distance
|
|
542
|
+
best = candidate
|
|
543
|
+
|
|
544
|
+
return best
|
srcodex/tui/__init__.py
ADDED
|
File without changes
|
srcodex/tui/app.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from textual.app import App
|
|
3
|
+
from textual.widgets import Label, DirectoryTree
|
|
4
|
+
from textual.containers import Container
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# Add parent directory to path for imports
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
10
|
+
# Add backend to path for config loader
|
|
11
|
+
backend_path = Path(__file__).parent.parent / "backend"
|
|
12
|
+
sys.path.insert(0, str(backend_path))
|
|
13
|
+
|
|
14
|
+
from components.panels.side_panel import SidePanel
|
|
15
|
+
from components.panels.code_panel import CodePanel
|
|
16
|
+
from components.panels.chat_panel import ChatPanel
|
|
17
|
+
from components.views.search_view import SearchView
|
|
18
|
+
from components.bars.footer_bar import FooterBar
|
|
19
|
+
from services.config_loader import get_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SrcodexApp(App):
|
|
24
|
+
CSS_PATH = "app.tcss"
|
|
25
|
+
|
|
26
|
+
# Panel width state (fractional units: left=0.8, middle=2, right=1.4, sum=4.2)
|
|
27
|
+
left_width = 0.8
|
|
28
|
+
middle_width = 2
|
|
29
|
+
right_width = 1.4
|
|
30
|
+
|
|
31
|
+
def __init__(self, *args, **kwargs):
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
# Load project configuration at runtime
|
|
34
|
+
config = get_config()
|
|
35
|
+
self.SOURCE_ROOT = str(config.source_root)
|
|
36
|
+
self.PROJECT_ROOT = str(config.project_root)
|
|
37
|
+
|
|
38
|
+
def compose(self):
|
|
39
|
+
yield SidePanel(self.SOURCE_ROOT, id="left")
|
|
40
|
+
yield CodePanel(self.SOURCE_ROOT, id="middle")
|
|
41
|
+
yield ChatPanel(id="right")
|
|
42
|
+
yield FooterBar(self.PROJECT_ROOT)
|
|
43
|
+
|
|
44
|
+
def on_key(self, event):
|
|
45
|
+
"""Handle keyboard shortcuts for panel resizing"""
|
|
46
|
+
# Ctrl+Shift+Left: Grow chat panel, shrink code viewer
|
|
47
|
+
if event.key == "ctrl+shift+left":
|
|
48
|
+
if self.middle_width > 0.5:
|
|
49
|
+
self.right_width += 0.1
|
|
50
|
+
self.middle_width -= 0.1
|
|
51
|
+
self._update_panel_widths()
|
|
52
|
+
event.prevent_default()
|
|
53
|
+
|
|
54
|
+
# Ctrl+Shift+Right: Shrink chat panel, grow code viewer
|
|
55
|
+
elif event.key == "ctrl+shift+right":
|
|
56
|
+
if self.right_width > 0.5:
|
|
57
|
+
self.right_width -= 0.1
|
|
58
|
+
self.middle_width += 0.1
|
|
59
|
+
self._update_panel_widths()
|
|
60
|
+
event.prevent_default()
|
|
61
|
+
|
|
62
|
+
def _update_panel_widths(self):
|
|
63
|
+
"""Update panel widths dynamically"""
|
|
64
|
+
left_panel = self.query_one("#left")
|
|
65
|
+
middle_panel = self.query_one("#middle")
|
|
66
|
+
right_panel = self.query_one("#right")
|
|
67
|
+
|
|
68
|
+
# Update styles with new fractional widths
|
|
69
|
+
left_panel.styles.width = f"{self.left_width}fr"
|
|
70
|
+
middle_panel.styles.width = f"{self.middle_width}fr"
|
|
71
|
+
right_panel.styles.width = f"{self.right_width}fr"
|
|
72
|
+
|
|
73
|
+
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
|
|
74
|
+
"""Handle file selection from FileBrowser explorer view"""
|
|
75
|
+
code_panel = self.query_one("#middle", CodePanel)
|
|
76
|
+
|
|
77
|
+
absolute_path = Path(event.path)
|
|
78
|
+
source_root = Path(self.SOURCE_ROOT)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
relative_path = absolute_path.relative_to(source_root)
|
|
82
|
+
code_panel.open_file(str(relative_path))
|
|
83
|
+
except ValueError:
|
|
84
|
+
self.notify(f"Cannot open file outside source root: {event.path}", severity="error")
|
|
85
|
+
|
|
86
|
+
async def on_search_view_symbol_selected(self, event: SearchView.SymbolSelected):
|
|
87
|
+
"""Handle symbol selection from search results"""
|
|
88
|
+
code_panel = self.query_one("#middle", CodePanel)
|
|
89
|
+
side_panel = self.query_one("#left", SidePanel)
|
|
90
|
+
|
|
91
|
+
# Open file directly with line number
|
|
92
|
+
code_panel.open_file(event.file_path, line_number=event.line_number)
|
|
93
|
+
|
|
94
|
+
# Also navigate tree (async) for visual feedback
|
|
95
|
+
await side_panel.on_search_view_file_selected(
|
|
96
|
+
SearchView.FileSelected(event.file_path)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
app = SrcodexApp()
|
|
103
|
+
app.run()
|
srcodex/tui/app.tcss
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* Main screen layout - 3 columns */
|
|
2
|
+
Screen {
|
|
3
|
+
layout: horizontal;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/* Left panel - File Browser */
|
|
7
|
+
#left {
|
|
8
|
+
width: 0.8fr;
|
|
9
|
+
height: 100%;
|
|
10
|
+
border: grey;
|
|
11
|
+
overflow-y: auto;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Middle panel - Code Viewer */
|
|
15
|
+
#middle {
|
|
16
|
+
width: 2fr;
|
|
17
|
+
border: grey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Right panel - AI Chat */
|
|
21
|
+
#right {
|
|
22
|
+
width: 1.4fr;
|
|
23
|
+
border: grey;
|
|
24
|
+
}
|
|
File without changes
|
|
File without changes
|