| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Features.HybridIndex.Application; |
| 4 | using CascadeIDE.Models.Editor; |
| 5 | using CascadeIDE.Models.Intercom; |
| 6 | using Microsoft.Data.Sqlite; |
| 7 | |
| 8 | namespace CascadeIDE.Services.Intercom; |
| 9 | |
| 10 | /// <summary>SQLite sidecar: member/doc id → line range per file (ADR 0135 L2, colocated with HCI index_dir).</summary> |
| 11 | public 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> |
| 386 | public readonly record struct IntercomSymbolLineEntry(string LookupKind, string LookupKey, int LineStart, int LineEnd); |
| 387 | |