Forge
csharp4405de34
1#nullable enable
2
3using CascadeIDE.Features.HybridIndex.Application;
4using CascadeIDE.Models.Editor;
5using CascadeIDE.Models.Intercom;
6using Microsoft.Data.Sqlite;
7
8namespace CascadeIDE.Services.Intercom;
9
10/// <summary>SQLite sidecar: member/doc id → line range per file (ADR 0135 L2, colocated with HCI index_dir).</summary>
11public static class IntercomSymbolLineIndex
12{
13 private const string LookupKindDocId = "docid";
14 private const string LookupKindSimple = "simple";
15
16 public static bool TryResolveMemberLines(
17 in IntercomAttachResolveCacheContext cache,
18 string absoluteFilePath,
19 string? memberKey,
20 out LineRange lines,
21 out string detail)
22 {
23 lines = default;
24 detail = "";
25
26 if (string.IsNullOrWhiteSpace(cache.WorkspaceRoot)
27 || string.IsNullOrWhiteSpace(cache.RelativePath)
28 || string.IsNullOrWhiteSpace(memberKey))
29 {
30 return false;
31 }
32
33 if (!File.Exists(absoluteFilePath))
34 {
35 detail = "file_not_found";
36 return false;
37 }
38
39 long mtimeTicks;
40 try
41 {
42 mtimeTicks = File.GetLastWriteTimeUtc(absoluteFilePath).Ticks;
43 }
44 catch (Exception ex)
45 {
46 detail = "mtime_error: " + ex.Message;
47 return false;
48 }
49
50 var rel = cache.RelativePath.Replace('\\', '/');
51 var scopeKey = cache.ScopeKey;
52 var dbPath = resolveDatabasePath(cache.WorkspaceRoot, cache.IndexDirectoryRelative);
53
54 try
55 {
56 using var conn = openConnection(dbPath);
57 ensureSchema(conn);
58
59 if (tryQuery(conn, scopeKey, rel, LookupKindDocId, memberKey.Trim(), mtimeTicks, out lines))
60 {
61 detail = "symbol_cache_docid";
62 return true;
63 }
64
65 var simple = memberKey.Contains('.') ? memberKey.Split('.')[^1] : memberKey.Trim();
66 if (tryQuery(conn, scopeKey, rel, LookupKindSimple, simple, mtimeTicks, out lines))
67 {
68 detail = "symbol_cache_simple";
69 return true;
70 }
71
72 detail = "symbol_cache_miss";
73 return false;
74 }
75 catch (Exception ex)
76 {
77 detail = "symbol_cache_error: " + ex.Message;
78 return false;
79 }
80 }
81
82 public static void UpsertMemberLines(
83 in IntercomAttachResolveCacheContext cache,
84 string absoluteFilePath,
85 string? memberKey,
86 LineRange lines)
87 {
88 if (string.IsNullOrWhiteSpace(cache.WorkspaceRoot)
89 || string.IsNullOrWhiteSpace(cache.RelativePath)
90 || string.IsNullOrWhiteSpace(memberKey))
91 {
92 return;
93 }
94
95 if (!File.Exists(absoluteFilePath))
96 return;
97
98 long mtimeTicks;
99 try
100 {
101 mtimeTicks = File.GetLastWriteTimeUtc(absoluteFilePath).Ticks;
102 }
103 catch
104 {
105 return;
106 }
107
108 var rel = cache.RelativePath.Replace('\\', '/');
109 var scopeKey = cache.ScopeKey;
110 var dbPath = resolveDatabasePath(cache.WorkspaceRoot, cache.IndexDirectoryRelative);
111
112 try
113 {
114 using var conn = openConnection(dbPath);
115 ensureSchema(conn);
116 upsertRow(conn, scopeKey, rel, LookupKindDocId, memberKey.Trim(), lines, mtimeTicks);
117
118 var simple = memberKey.Contains('.') ? memberKey.Split('.')[^1] : memberKey.Trim();
119 upsertRow(conn, scopeKey, rel, LookupKindSimple, simple, lines, mtimeTicks);
120 }
121 catch
122 {
123 // best-effort sidecar
124 }
125 }
126
127 public static void ReplaceFileSymbols(
128 in IntercomAttachResolveCacheContext cache,
129 string relativePath,
130 long fileMtimeTicks,
131 IReadOnlyList<IntercomSymbolLineEntry> entries)
132 {
133 if (string.IsNullOrWhiteSpace(cache.WorkspaceRoot) || string.IsNullOrWhiteSpace(relativePath))
134 return;
135
136 var rel = relativePath.Replace('\\', '/');
137 var scopeKey = cache.ScopeKey;
138 var dbPath = resolveDatabasePath(cache.WorkspaceRoot, cache.IndexDirectoryRelative);
139
140 try
141 {
142 using var conn = openConnection(dbPath);
143 ensureSchema(conn);
144
145 using var tx = conn.BeginTransaction();
146 using (var del = conn.CreateCommand())
147 {
148 del.Transaction = tx;
149 del.CommandText =
150 "DELETE FROM symbol_line WHERE scope_key = $scope AND relative_path = $path;";
151 del.Parameters.AddWithValue("$scope", scopeKey);
152 del.Parameters.AddWithValue("$path", rel);
153 del.ExecuteNonQuery();
154 }
155
156 foreach (var e in entries)
157 {
158 if (!LineNumber.TryCreate(e.LineStart, out var lineStart)
159 || !LineNumber.TryCreate(e.LineEnd, out var lineEnd)
160 || !LineRange.TryCreate(lineStart, lineEnd, out var range))
161 {
162 continue;
163 }
164 upsertRow(conn, scopeKey, rel, e.LookupKind, e.LookupKey, range, fileMtimeTicks, tx);
165 }
166
167 tx.Commit();
168 }
169 catch
170 {
171 // best-effort background index
172 }
173 }
174
175 public static void RemoveScope(string? workspaceRoot, string indexDirectoryRelative, string scopeKey)
176 {
177 if (string.IsNullOrWhiteSpace(workspaceRoot))
178 return;
179
180 var dbPath = resolveDatabasePath(workspaceRoot, indexDirectoryRelative);
181 if (!File.Exists(dbPath))
182 return;
183
184 try
185 {
186 using var conn = openConnection(dbPath);
187 ensureSchema(conn);
188 using var cmd = conn.CreateCommand();
189 cmd.CommandText = "DELETE FROM symbol_line WHERE scope_key = $scope;";
190 cmd.Parameters.AddWithValue("$scope", scopeKey);
191 cmd.ExecuteNonQuery();
192 }
193 catch
194 {
195 // ignore
196 }
197 }
198
199 private static bool tryQuery(
200 SqliteConnection conn,
201 string scopeKey,
202 string relativePath,
203 string lookupKind,
204 string lookupKey,
205 long fileMtimeTicks,
206 out LineRange lines)
207 {
208 lines = default;
209 using var cmd = conn.CreateCommand();
210 cmd.CommandText =
211 """
212 SELECT line_start, line_end FROM symbol_line
213 WHERE scope_key = $scope AND relative_path = $path
214 AND lookup_kind = $kind AND lookup_key = $key
215 AND file_mtime_ticks = $mtime
216 LIMIT 1;
217 """;
218 cmd.Parameters.AddWithValue("$scope", scopeKey);
219 cmd.Parameters.AddWithValue("$path", relativePath);
220 cmd.Parameters.AddWithValue("$kind", lookupKind);
221 cmd.Parameters.AddWithValue("$key", lookupKey);
222 cmd.Parameters.AddWithValue("$mtime", fileMtimeTicks);
223
224 using var reader = cmd.ExecuteReader();
225 if (!reader.Read())
226 return false;
227
228 if (!LineNumber.TryCreate(reader.GetInt32(0), out var lineStart)
229 || !LineNumber.TryCreate(reader.GetInt32(1), out var lineEnd))
230 {
231 return false;
232 }
233
234 return LineRange.TryCreate(lineStart, lineEnd, out lines);
235 }
236
237 private static void upsertRow(
238 SqliteConnection conn,
239 string scopeKey,
240 string relativePath,
241 string lookupKind,
242 string lookupKey,
243 LineRange lines,
244 long fileMtimeTicks,
245 SqliteTransaction? tx = null)
246 {
247 using var cmd = conn.CreateCommand();
248 if (tx is not null)
249 cmd.Transaction = tx;
250 cmd.CommandText =
251 """
252 INSERT INTO symbol_line(scope_key, relative_path, lookup_kind, lookup_key, line_start, line_end, file_mtime_ticks)
253 VALUES ($scope, $path, $kind, $key, $start, $end, $mtime)
254 ON CONFLICT(scope_key, relative_path, lookup_kind, lookup_key) DO UPDATE SET
255 line_start = excluded.line_start,
256 line_end = excluded.line_end,
257 file_mtime_ticks = excluded.file_mtime_ticks;
258 """;
259 cmd.Parameters.AddWithValue("$scope", scopeKey);
260 cmd.Parameters.AddWithValue("$path", relativePath);
261 cmd.Parameters.AddWithValue("$kind", lookupKind);
262 cmd.Parameters.AddWithValue("$key", lookupKey);
263 cmd.Parameters.AddWithValue("$start", lines.Start.Value);
264 cmd.Parameters.AddWithValue("$end", lines.End.Value);
265 cmd.Parameters.AddWithValue("$mtime", fileMtimeTicks);
266 cmd.ExecuteNonQuery();
267 }
268
269 private static string resolveDatabasePath(string workspaceRoot, string indexDirectoryRelative)
270 {
271 var dir = HybridIndexIndexDirectoryRelative.ResolveOrDefault(indexDirectoryRelative);
272 var fullDir = Path.Combine(workspaceRoot, dir);
273 Directory.CreateDirectory(fullDir);
274 return Path.Combine(fullDir, "intercom-symbol-lines.sqlite");
275 }
276
277 private static SqliteConnection openConnection(string dbPath)
278 {
279 var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString());
280 conn.Open();
281 return conn;
282 }
283
284 private static void ensureSchema(SqliteConnection conn)
285 {
286 using var cmd = conn.CreateCommand();
287 cmd.CommandText =
288 """
289 CREATE TABLE IF NOT EXISTS symbol_line (
290 scope_key TEXT NOT NULL,
291 relative_path TEXT NOT NULL,
292 lookup_kind TEXT NOT NULL,
293 lookup_key TEXT NOT NULL,
294 line_start INTEGER NOT NULL,
295 line_end INTEGER NOT NULL,
296 file_mtime_ticks INTEGER NOT NULL,
297 PRIMARY KEY (scope_key, relative_path, lookup_kind, lookup_key)
298 );
299 CREATE INDEX IF NOT EXISTS idx_symbol_line_lookup
300 ON symbol_line(scope_key, relative_path, lookup_kind, lookup_key);
301 CREATE INDEX IF NOT EXISTS idx_symbol_line_member_lookup
302 ON symbol_line(scope_key, lookup_kind, lookup_key);
303 """;
304 cmd.ExecuteNonQuery();
305 }
306
307 /// <summary>Все workspace-relative файлы, где sidecar знает <paramref name="memberKey"/>.</summary>
308 public static bool TryFindRelativePathsForMember(
309 in IntercomAttachResolveCacheContext cache,
310 string? memberKey,
311 out IReadOnlyList<string> relativePaths)
312 {
313 relativePaths = [];
314 if (string.IsNullOrWhiteSpace(cache.WorkspaceRoot) || string.IsNullOrWhiteSpace(memberKey))
315 return false;
316
317 var dbPath = resolveDatabasePath(cache.WorkspaceRoot, cache.IndexDirectoryRelative);
318 if (!File.Exists(dbPath))
319 return false;
320
321 var keys = lookupKeysForMember(memberKey);
322 if (keys.Count == 0)
323 return false;
324
325 try
326 {
327 using var conn = openConnection(dbPath);
328 ensureSchema(conn);
329 var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
330 foreach (var (kind, key) in keys)
331 {
332 foreach (var path in queryPaths(conn, cache.ScopeKey, kind, key))
333 set.Add(path);
334 }
335
336 if (set.Count == 0)
337 return false;
338
339 relativePaths = set.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
340 return true;
341 }
342 catch
343 {
344 return false;
345 }
346 }
347
348 private static IReadOnlyList<(string Kind, string Key)> lookupKeysForMember(string memberKey)
349 {
350 var trimmed = memberKey.Trim();
351 if (trimmed.Length == 0)
352 return [];
353
354 var list = new List<(string, string)> { (LookupKindDocId, trimmed) };
355 var simple = trimmed.Contains('.') ? trimmed.Split('.')[^1] : trimmed;
356 if (!string.Equals(simple, trimmed, StringComparison.Ordinal))
357 list.Add((LookupKindSimple, simple));
358
359 return list;
360 }
361
362 private static IEnumerable<string> queryPaths(
363 SqliteConnection conn,
364 string scopeKey,
365 string lookupKind,
366 string lookupKey)
367 {
368 using var cmd = conn.CreateCommand();
369 cmd.CommandText =
370 """
371 SELECT DISTINCT relative_path FROM symbol_line
372 WHERE scope_key = $scope AND lookup_kind = $kind AND lookup_key = $key
373 ORDER BY relative_path;
374 """;
375 cmd.Parameters.AddWithValue("$scope", scopeKey);
376 cmd.Parameters.AddWithValue("$kind", lookupKind);
377 cmd.Parameters.AddWithValue("$key", lookupKey);
378
379 using var reader = cmd.ExecuteReader();
380 while (reader.Read())
381 yield return reader.GetString(0).Replace('\\', '/');
382 }
383}
384
385/// <summary>Строка symbol sidecar при индексации файла.</summary>
386public readonly record struct IntercomSymbolLineEntry(string LookupKind, string LookupKey, int LineStart, int LineEnd);
387
View only · write via MCP/CIDE