Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Services;
3
4namespace CascadeIDE.Features.Chat;
5
6/// <summary>Индекс slash по domain → object → intent (ADR 0154) для popup autocomplete.</summary>
7internal static class SlashSemanticCatalogIndex
8{
9 private static readonly Lazy<Snapshot> Lazy = new(static () => Build(IntentSlashCatalog.SlashRoutes));
10
11 internal enum CompletionStep
12 {
13 Domain,
14 Object,
15 Intent,
16 Arg,
17 }
18
19 internal readonly record struct CompletionState(
20 CompletionStep Step,
21 string? Domain,
22 string? Object,
23 string PartialToken);
24
25 internal static IReadOnlyList<ChatSlashSuggestion> GetSegmentSuggestions(
26 IReadOnlyList<string> tokens,
27 bool endsWithSpace,
28 string typedBody)
29 {
30 if (SlashLineResolver.TryResolveBody(typedBody, out var line) && line.ShouldHideSegmentSuggestions)
31 return [];
32
33 var state = ResolveCompletionState(tokens, endsWithSpace);
34 return state.Step switch
35 {
36 CompletionStep.Domain => buildDomainSuggestions(state.PartialToken),
37 CompletionStep.Object => buildObjectSuggestions(state.Domain!, state.PartialToken, tokens, endsWithSpace),
38 CompletionStep.Intent => buildIntentSuggestions(
39 state.Domain!,
40 state.Object ?? "",
41 state.PartialToken,
42 tokens,
43 endsWithSpace),
44 _ => [],
45 };
46 }
47
48 internal static bool TryResolveHierarchy(
49 IReadOnlyList<string> tokens,
50 bool endsWithSpace,
51 out SlashSemanticFields fields,
52 out string matchedPath)
53 {
54 fields = default;
55 matchedPath = "";
56 if (tokens.Count == 0)
57 return false;
58
59 if (!tryMatchPrefixByPath(tokens, endsWithSpace, out matchedPath))
60 return false;
61
62 if (SlashRouteCatalogIndex.TryGetRoute(matchedPath, out var route))
63 fields = route.SemanticFields;
64 else
65 fields = SlashRouteSemantics.Resolve(matchedPath);
66
67 return true;
68 }
69
70 private static CompletionState ResolveCompletionState(IReadOnlyList<string> tokens, bool endsWithSpace)
71 {
72 var snap = Lazy.Value;
73 if (tokens.Count == 0)
74 return new(CompletionStep.Domain, null, null, "");
75
76 if (!endsWithSpace)
77 {
78 if (tokens.Count == 1)
79 {
80 var t = tokens[0];
81 if (snap.DomainsWithCanonicalPrefix.Contains(t))
82 return new(CompletionStep.Object, t, null, "");
83
84 if (snap.ElisionObjectToDomain.TryGetValue(t, out var elisionDomain))
85 return new(CompletionStep.Intent, elisionDomain, t, "");
86
87 return new(CompletionStep.Domain, null, null, t);
88 }
89
90 if (tryResolvePrefix(prefixTokens(tokens, dropLast: 1), endsWithSpace: true, out var domain, out var obj)
91 && !string.IsNullOrEmpty(obj))
92 return new(CompletionStep.Intent, domain, obj, tokens[^1]);
93
94 if (tokens.Count >= 2
95 && snap.DomainsWithCanonicalPrefix.Contains(tokens[0])
96 && tryResolvePrefix([tokens[0]], endsWithSpace: true, out var domainOnly, out var emptyObj)
97 && string.IsNullOrEmpty(emptyObj))
98 {
99 return tokens.Count == 2
100 ? new(CompletionStep.Object, domainOnly, null, tokens[1])
101 : new(CompletionStep.Intent, domainOnly, "", tokens[^1]);
102 }
103
104 return new(CompletionStep.Domain, null, null, tokens[^1]);
105 }
106
107 if (tokens.Count == 1)
108 {
109 var t0 = tokens[0];
110 if (snap.DomainsWithCanonicalPrefix.Contains(t0))
111 return new(CompletionStep.Object, t0, null, "");
112
113 if (snap.ElisionObjectToDomain.TryGetValue(t0, out var elisionDomain))
114 return new(CompletionStep.Intent, elisionDomain, t0, "");
115
116 return new(CompletionStep.Domain, null, null, "");
117 }
118
119 if (tokens.Count == 2 && tryResolvePrefix(tokens, endsWithSpace: true, out var d2, out var o2))
120 {
121 if (!string.IsNullOrEmpty(o2))
122 return new(CompletionStep.Intent, d2, o2, "");
123
124 return new(CompletionStep.Arg, d2, "", "");
125 }
126
127 if (tokens.Count >= 3 && tryResolvePrefix(tokens, endsWithSpace: true, out var d3, out var o3))
128 return new(CompletionStep.Arg, d3, o3, "");
129
130 return new(CompletionStep.Arg, null, null, "");
131 }
132
133 private static bool tryResolvePrefix(
134 IReadOnlyList<string> tokens,
135 bool endsWithSpace,
136 out string? domain,
137 out string? obj)
138 {
139 domain = null;
140 obj = null;
141 if (tokens.Count == 0)
142 return false;
143
144 var snap = Lazy.Value;
145 var t0 = tokens[0];
146
147 if (snap.ElisionObjectToDomain.TryGetValue(t0, out var elisionDomain))
148 {
149 domain = elisionDomain;
150 obj = t0;
151 if (tokens.Count == 1)
152 return true;
153
154 return endsWithSpace;
155 }
156
157 if (!snap.DomainsWithCanonicalPrefix.Contains(t0))
158 return false;
159
160 domain = t0;
161 if (tokens.Count == 1)
162 {
163 obj = "";
164 return true;
165 }
166
167 obj = tokens[1];
168 return true;
169 }
170
171 private static IReadOnlyList<ChatSlashSuggestion> buildDomainSuggestions(string partial)
172 {
173 var snap = Lazy.Value;
174 var buckets = new Dictionary<string, ChatSlashSuggestion>(StringComparer.OrdinalIgnoreCase);
175
176 foreach (var domain in snap.DomainsWithCanonicalPrefix)
177 {
178 if (!matchesPartial(domain, partial))
179 continue;
180
181 addSuggestion(
182 buckets,
183 domain,
184 $"/{domain} ",
185 $"/{domain}",
186 snap.BestHelpForDomain(domain));
187 }
188
189 foreach (var (starter, elisionDomain) in snap.ElisionObjectToDomain)
190 {
191 if (!matchesPartial(starter, partial))
192 continue;
193
194 addSuggestion(
195 buckets,
196 starter,
197 $"/{starter} ",
198 $"/{starter}",
199 snap.BestHelpForElisionStarter(starter, elisionDomain));
200 }
201
202 return order(buckets.Values);
203 }
204
205 private static IReadOnlyList<ChatSlashSuggestion> buildObjectSuggestions(
206 string domain,
207 string partial,
208 IReadOnlyList<string> tokens,
209 bool endsWithSpace)
210 {
211 var snap = Lazy.Value;
212 var buckets = new Dictionary<string, ChatSlashSuggestion>(StringComparer.OrdinalIgnoreCase);
213
214 if (snap.ObjectsByDomain.TryGetValue(domain, out var objects))
215 {
216 foreach (var obj in objects)
217 {
218 if (string.IsNullOrEmpty(obj) || !matchesPartial(obj, partial))
219 continue;
220
221 var insertPath = $"/{domain} {obj} ";
222 addSuggestion(
223 buckets,
224 obj,
225 insertPath,
226 insertPath.TrimEnd(),
227 snap.BestHelpForObject(domain, obj));
228 }
229 }
230
231 if (snap.FlatIntentsByDomain.TryGetValue(domain, out var flatIntents))
232 {
233 foreach (var (intent, route) in flatIntents)
234 {
235 if (!matchesPartial(intent, partial))
236 continue;
237
238 var pathSegs = route.PathSegments;
239 var insert = buildInsertFromTyped(tokens, endsWithSpace, pathSegs, pathSegs.Count - 1, intent);
240 addSuggestion(buckets, intent, insert, route.SlashPath, route.Help, route.Group);
241 }
242 }
243
244 return order(buckets.Values);
245 }
246
247 private static IReadOnlyList<ChatSlashSuggestion> buildIntentSuggestions(
248 string domain,
249 string obj,
250 string partial,
251 IReadOnlyList<string> tokens,
252 bool endsWithSpace)
253 {
254 var snap = Lazy.Value;
255 var key = (domain, obj);
256 if (!snap.RoutesBySemantic.TryGetValue(key, out var routes))
257 return [];
258
259 var buckets = new Dictionary<string, ChatSlashSuggestion>(StringComparer.OrdinalIgnoreCase);
260 var segmentIndex = endsWithSpace ? tokens.Count : Math.Max(0, tokens.Count - 1);
261 foreach (var route in routes)
262 {
263 var pathSegs = route.PathSegments;
264 if (segmentIndex >= pathSegs.Count)
265 continue;
266
267 if (!pathPrefixMatches(pathSegs, tokens, endsWithSpace))
268 continue;
269
270 var segmentValue = pathSegs[segmentIndex];
271 if (!matchesPartial(segmentValue, partial))
272 continue;
273
274 var insert = buildInsertFromTyped(tokens, endsWithSpace, pathSegs, segmentIndex, segmentValue);
275 addSuggestion(buckets, segmentValue, insert, route.SlashPath, route.Help, route.Group);
276 }
277
278 return order(buckets.Values);
279 }
280
281 private static void addSuggestion(
282 Dictionary<string, ChatSlashSuggestion> buckets,
283 string listTitle,
284 string insert,
285 string slashPath,
286 string help,
287 string? group = null)
288 {
289 if (!buckets.TryGetValue(listTitle, out var existing)
290 || slashPath.Length > existing.SlashPath.Length)
291 {
292 buckets[listTitle] = new ChatSlashSuggestion(insert, slashPath, help, group, listTitle);
293 }
294 }
295
296 private static IReadOnlyList<ChatSlashSuggestion> order(IEnumerable<ChatSlashSuggestion> items) =>
297 items.OrderBy(s => ChatSlashCommandCatalog.SortKeyForSuggestion(s.SlashPath)).ToList();
298
299 private static bool matchesPartial(string value, string partial) =>
300 partial.Length == 0
301 || value.StartsWith(partial, StringComparison.OrdinalIgnoreCase);
302
303 private static string buildInsertFromTyped(
304 IReadOnlyList<string> typedTokens,
305 bool endsWithSpace,
306 IReadOnlyList<string> pathSegs,
307 int completeSegmentIndex,
308 string segmentValue)
309 {
310 var resultSegs = new List<string>(completeSegmentIndex + 1);
311 for (var i = 0; i < completeSegmentIndex; i++)
312 resultSegs.Add(i < typedTokens.Count ? typedTokens[i] : pathSegs[i]);
313
314 resultSegs.Add(segmentValue);
315 var slashPath = "/" + string.Join(" ", resultSegs);
316 if (completeSegmentIndex + 1 < pathSegs.Count
317 || segmentNeedsArgTail(slashPath))
318 slashPath += " ";
319
320 return slashPath;
321 }
322
323 private static bool segmentNeedsArgTail(string slashPath)
324 {
325 if (ChatSlashCommandParser.ShouldAutoExecuteAfterAutocompleteCommit(slashPath))
326 return false;
327
328 return SlashRouteCatalogIndex.GetArgTailKind(slashPath) != SlashArgTailKind.None;
329 }
330
331 private static bool pathPrefixMatches(
332 IReadOnlyList<string> pathSegs,
333 IReadOnlyList<string> tokens,
334 bool endsWithSpace)
335 {
336 if (tokens.Count == 0)
337 return true;
338
339 if (endsWithSpace)
340 {
341 if (tokens.Count >= pathSegs.Count)
342 return false;
343
344 for (var i = 0; i < tokens.Count; i++)
345 {
346 if (!pathSegs[i].Equals(tokens[i], StringComparison.OrdinalIgnoreCase))
347 return false;
348 }
349
350 return true;
351 }
352
353 if (tokens.Count > pathSegs.Count)
354 return false;
355
356 for (var i = 0; i < tokens.Count - 1; i++)
357 {
358 if (!pathSegs[i].Equals(tokens[i], StringComparison.OrdinalIgnoreCase))
359 return false;
360 }
361
362 return pathSegs[tokens.Count - 1].StartsWith(tokens[^1], StringComparison.OrdinalIgnoreCase);
363 }
364
365 private static bool tryMatchPrefixByPath(
366 IReadOnlyList<string> tokens,
367 bool endsWithSpace,
368 out string matchedPath)
369 {
370 matchedPath = "";
371 var bestLen = -1;
372
373 foreach (var route in Lazy.Value.AllRoutes)
374 {
375 if (!pathPrefixMatches(route.PathSegments, tokens, endsWithSpace))
376 continue;
377
378 if (route.PathSegments.Count <= bestLen)
379 continue;
380
381 bestLen = route.PathSegments.Count;
382 matchedPath = route.SlashPath;
383 }
384
385 foreach (var route in CascadeIDE.Features.Forge.Infrastructure.ForgeSlashCatalogOverlay.AllRoutes)
386 {
387 var pathSegs = splitPath(route.SlashPath);
388 if (pathSegs.Count == 0)
389 continue;
390
391 if (!pathPrefixMatches(pathSegs, tokens, endsWithSpace))
392 continue;
393
394 if (pathSegs.Count <= bestLen)
395 continue;
396
397 bestLen = pathSegs.Count;
398 matchedPath = route.SlashPath;
399 }
400
401 return bestLen >= 0;
402 }
403
404 private static Snapshot Build(IReadOnlyDictionary<string, SlashRouteEntry> routes)
405 {
406 var allRoutes = new List<IndexedRoute>();
407 var domainsWithCanonicalPrefix = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
408 var elisionObjectToDomain = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
409 var objectsByDomain = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
410 var flatIntentsByDomain = new Dictionary<string, Dictionary<string, IndexedRoute>>(StringComparer.OrdinalIgnoreCase);
411 var routesBySemantic = new Dictionary<(string Domain, string Object), List<IndexedRoute>>();
412
413 var helpDomain = new Dictionary<string, (string Help, int Len)>(StringComparer.OrdinalIgnoreCase);
414 var helpElision = new Dictionary<string, (string Help, int Len)>(StringComparer.OrdinalIgnoreCase);
415 var helpObject = new Dictionary<(string, string), (string Help, int Len)>();
416
417 foreach (var route in routes.Values)
418 {
419 var sem = route.SemanticFields;
420 var pathSegs = splitPath(route.SlashPath);
421 if (pathSegs.Count == 0)
422 continue;
423
424 var indexed = new IndexedRoute(route, sem, pathSegs);
425 allRoutes.Add(indexed);
426
427 var domain = sem.Domain;
428 var obj = sem.Object ?? "";
429 var key = (domain, obj);
430
431 if (!routesBySemantic.TryGetValue(key, out var list))
432 {
433 list = [];
434 routesBySemantic[key] = list;
435 }
436
437 list.Add(indexed);
438
439 if (sem.DomainOmittedInPath && !string.IsNullOrEmpty(obj))
440 elisionObjectToDomain[obj] = domain;
441 else if (!string.IsNullOrEmpty(domain))
442 domainsWithCanonicalPrefix.Add(domain);
443
444 if (!objectsByDomain.TryGetValue(domain, out var objects))
445 {
446 objects = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
447 objectsByDomain[domain] = objects;
448 }
449
450 if (!string.IsNullOrEmpty(obj))
451 objects.Add(obj);
452
453 if (string.IsNullOrEmpty(obj) && !string.IsNullOrEmpty(sem.Intent))
454 {
455 if (!flatIntentsByDomain.TryGetValue(domain, out var flat))
456 {
457 flat = new Dictionary<string, IndexedRoute>(StringComparer.OrdinalIgnoreCase);
458 flatIntentsByDomain[domain] = flat;
459 }
460
461 if (!flat.TryGetValue(sem.Intent, out var existing)
462 || route.SlashPath.Length > existing.SlashPath.Length)
463 flat[sem.Intent] = indexed;
464 }
465
466 trackHelp(helpDomain, domain, route);
467 if (sem.DomainOmittedInPath && !string.IsNullOrEmpty(obj))
468 trackHelp(helpElision, obj, route);
469 if (!string.IsNullOrEmpty(obj))
470 trackHelp(helpObject, (domain, obj), route);
471 }
472
473 return new Snapshot(
474 allRoutes,
475 domainsWithCanonicalPrefix,
476 elisionObjectToDomain,
477 objectsByDomain,
478 flatIntentsByDomain,
479 routesBySemantic,
480 helpDomain,
481 helpElision,
482 helpObject);
483 }
484
485 private static void trackHelp<T>(
486 Dictionary<T, (string Help, int Len)> map,
487 T key,
488 SlashRouteEntry route)
489 where T : notnull
490 {
491 if (!map.TryGetValue(key, out var existing) || route.SlashPath.Length > existing.Len)
492 map[key] = (route.Help, route.SlashPath.Length);
493 }
494
495 private static IReadOnlyList<string> prefixTokens(IReadOnlyList<string> tokens, int dropLast)
496 {
497 if (dropLast <= 0)
498 return tokens;
499
500 var count = tokens.Count - dropLast;
501 if (count <= 0)
502 return [];
503
504 var result = new List<string>(count);
505 for (var i = 0; i < count; i++)
506 result.Add(tokens[i]);
507
508 return result;
509 }
510
511 private static List<string> splitPath(string slashPath)
512 {
513 if (slashPath.Length < 2 || slashPath[0] != '/')
514 return [];
515
516 return slashPath[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
517 }
518
519 private sealed record IndexedRoute(
520 SlashRouteEntry Route,
521 SlashSemanticFields Semantics,
522 List<string> PathSegments)
523 {
524 public string SlashPath => Route.SlashPath;
525 public string Help => Route.Help;
526 public string? Group => Route.Group;
527 }
528
529 private sealed class Snapshot
530 {
531 public Snapshot(
532 List<IndexedRoute> allRoutes,
533 HashSet<string> domainsWithCanonicalPrefix,
534 Dictionary<string, string> elisionObjectToDomain,
535 Dictionary<string, HashSet<string>> objectsByDomain,
536 Dictionary<string, Dictionary<string, IndexedRoute>> flatIntentsByDomain,
537 Dictionary<(string Domain, string Object), List<IndexedRoute>> routesBySemantic,
538 Dictionary<string, (string Help, int Len)> helpDomain,
539 Dictionary<string, (string Help, int Len)> helpElision,
540 Dictionary<(string, string), (string Help, int Len)> helpObject)
541 {
542 AllRoutes = allRoutes;
543 DomainsWithCanonicalPrefix = domainsWithCanonicalPrefix;
544 ElisionObjectToDomain = elisionObjectToDomain;
545 ObjectsByDomain = objectsByDomain;
546 FlatIntentsByDomain = flatIntentsByDomain;
547 RoutesBySemantic = routesBySemantic;
548 _helpDomain = helpDomain;
549 _helpElision = helpElision;
550 _helpObject = helpObject;
551 }
552
553 public List<IndexedRoute> AllRoutes { get; }
554 public HashSet<string> DomainsWithCanonicalPrefix { get; }
555 public Dictionary<string, string> ElisionObjectToDomain { get; }
556 public Dictionary<string, HashSet<string>> ObjectsByDomain { get; }
557 public Dictionary<string, Dictionary<string, IndexedRoute>> FlatIntentsByDomain { get; }
558 public Dictionary<(string Domain, string Object), List<IndexedRoute>> RoutesBySemantic { get; }
559
560 private readonly Dictionary<string, (string Help, int Len)> _helpDomain;
561 private readonly Dictionary<string, (string Help, int Len)> _helpElision;
562 private readonly Dictionary<(string, string), (string Help, int Len)> _helpObject;
563
564 public string BestHelpForDomain(string domain) =>
565 _helpDomain.TryGetValue(domain, out var h) ? h.Help : domain;
566
567 public string BestHelpForElisionStarter(string starter, string domain) =>
568 _helpElision.TryGetValue(starter, out var h) ? h.Help : $"{starter} ({domain})";
569
570 public string BestHelpForObject(string domain, string obj) =>
571 _helpObject.TryGetValue((domain, obj), out var h) ? h.Help : obj;
572 }
573}
574
View only · write via MCP/CIDE