Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Services;
3
4namespace CascadeIDE.Features.Chat;
5
6/// <summary>
7/// Слэш Intercom → <see cref="IdeCommands"/> (ADR 0119).
8/// Источник: <c>[[command]]</c> → <c>[[command.slash]]</c> в <see cref="IntentMelodyAliases.BundledRelativePath"/> (якорь <c>command_id</c>, ADR 0109/0119).
9/// Оверлей TOML рядом с exe — без пересборки для новых команд.
10/// </summary>
11public static partial class ChatSlashCommandCatalog
12{
13 private static readonly Lazy<CatalogSnapshot> SnapshotLazy = new(static () => BuildSnapshot());
14
15 private static CatalogSnapshot Snapshot => SnapshotLazy.Value;
16
17 /// <summary>Резолв по каноническому пути и хвосту (ADR 0150): lookup в каталоге.</summary>
18 public static bool TryResolveCanonical(string canonicalPath, string? argTail, out ChatSlashCommandDescriptor descriptor)
19 {
20 descriptor = null!;
21 var path = IntentSlashCatalog.NormalizeSlashPath(canonicalPath);
22 if (Snapshot.ByPath.TryGetValue(path, out descriptor)
23 || CascadeIDE.Features.Forge.Infrastructure.ForgeSlashCatalogOverlay.TryGetDescriptor(path, out descriptor))
24 return satisfiesArgTailPolicy(path, argTail);
25
26 return false;
27 }
28
29 /// <summary>Единая точка резолва строки слэша: longest-path из intent-catalog (<see cref="SlashLineResolver"/>).</summary>
30 public static bool TryResolveInput(
31 string? rawInput,
32 out ChatSlashCommandDescriptor descriptor,
33 out string? resolvedArgTail)
34 {
35 descriptor = null!;
36 resolvedArgTail = "";
37 var trimmed = (rawInput ?? "").Trim();
38 if (trimmed.Length == 0 || trimmed[0] != '/')
39 return false;
40
41 if (!SlashLineResolver.TryResolveSlashLine(trimmed, out var line) || !line.IsCatalogMatch)
42 return false;
43
44 var path = IntentSlashCatalog.NormalizeSlashPath(line.CanonicalPath);
45 if (!Snapshot.ByPath.TryGetValue(path, out descriptor)
46 && !CascadeIDE.Features.Forge.Infrastructure.ForgeSlashCatalogOverlay.TryGetDescriptor(path, out descriptor))
47 return false;
48
49 resolvedArgTail = ChatSlashCommandPresentation.NormalizeArgsTail(line.ArgTail);
50 return true;
51 }
52
53 private static bool satisfiesArgTailPolicy(string slashPath, string? argTail)
54 {
55 return SlashRouteCatalogIndex.GetArgTailKind(slashPath) switch
56 {
57 SlashArgTailKind.Required => !string.IsNullOrWhiteSpace(argTail),
58 _ => true,
59 };
60 }
61
62 public static IReadOnlyList<ChatSlashSuggestion> AllSuggestions() =>
63 OrderDescriptors(Snapshot.Descriptors.Concat(ForgeOverlayDescriptors()))
64 .Select(e => new ChatSlashSuggestion(
65 e.SlashPath,
66 e.SlashPath,
67 e.Help,
68 ResolveGroup(e)))
69 .ToList();
70
71 public static IReadOnlyList<string> ListHelpLines(string? namespaceFilter = null)
72 {
73 var lines = new List<string>
74 {
75 string.IsNullOrWhiteSpace(namespaceFilter)
76 ? "Слэш-команды Intercom (Tab — autocomplete, Enter — выполнить):"
77 : $"Слэш-команды /{namespaceFilter.Trim()}:*",
78 };
79
80 foreach (var entry in OrderDescriptors(Snapshot.Descriptors))
81 {
82 if (!string.IsNullOrWhiteSpace(namespaceFilter)
83 && !entry.SlashPath.StartsWith("/" + namespaceFilter.Trim(), StringComparison.OrdinalIgnoreCase))
84 continue;
85
86 lines.Add($" {entry.SlashPath} — {entry.Help}");
87 }
88
89 return lines;
90 }
91
92 internal static string? GroupFor(string slashPath)
93 {
94 if (!IntentSlashCatalog.TryGetRoute(slashPath, out var route))
95 return ExtractNamespaceHead(slashPath);
96
97 return string.IsNullOrWhiteSpace(route.Group) ? ExtractNamespaceHead(slashPath) : route.Group;
98 }
99
100 internal static string SortKeyForSuggestion(ChatSlashCommandDescriptor descriptor) =>
101 SortKey(descriptor);
102
103 internal static string SortKeyForSuggestion(string slashPath) =>
104 IntentSlashCatalog.TryGetRoute(slashPath, out var route)
105 ? SortKey(ToDescriptor(route))
106 : slashPath;
107
108 internal static IEnumerable<ChatSlashCommandDescriptor> OrderDescriptors(
109 IEnumerable<ChatSlashCommandDescriptor> descriptors) =>
110 descriptors.OrderBy(SortKey, StringComparer.Ordinal);
111
112 private static string? ResolveGroup(ChatSlashCommandDescriptor descriptor) =>
113 string.IsNullOrWhiteSpace(descriptor.SlashGroup)
114 ? ExtractNamespaceHead(descriptor.SlashPath)
115 : descriptor.SlashGroup;
116
117 private static ChatSlashCommandDescriptor ToDescriptor(SlashRouteEntry route) =>
118 new(
119 route.SlashPath,
120 route.CommandId,
121 route.Help,
122 route.ExecutionKind,
123 route.MfdPage,
124 route.PrimarySurface,
125 route.MapLevel,
126 route.Group,
127 route.Completion,
128 route.MessageAudience,
129 route.AutoRunOnCommit,
130 route.AutoRunRequiresArgs);
131
132 internal static bool TryGetRoute(string slashPath, out SlashRouteEntry route) =>
133 SlashRouteCatalogIndex.TryGetRoute(slashPath, out route);
134
135 private static IEnumerable<ChatSlashCommandDescriptor> ForgeOverlayDescriptors() =>
136 CascadeIDE.Features.Forge.Infrastructure.ForgeSlashCatalogOverlay.AllRoutes.Select(ToDescriptor);
137
138 private static CatalogSnapshot BuildSnapshot()
139 {
140 var routes = IntentSlashCatalog.SlashRoutes;
141 var descriptors = routes.Values.Select(ToDescriptor).ToList();
142 var byPath = new Dictionary<string, ChatSlashCommandDescriptor>(StringComparer.OrdinalIgnoreCase);
143 foreach (var d in descriptors)
144 byPath[d.SlashPath] = d;
145
146 return new CatalogSnapshot(descriptors, byPath);
147 }
148
149 private static string SortKey(ChatSlashCommandDescriptor descriptor)
150 {
151 var group = ResolveGroup(descriptor) ?? "";
152 return $"{group}\u001f{descriptor.SlashPath}";
153 }
154
155 private static string? ExtractNamespaceHead(string slashPath)
156 {
157 if (slashPath.Length < 2 || slashPath[0] != '/')
158 return null;
159
160 var body = slashPath[1..];
161 var space = body.IndexOf(' ');
162 return space < 0 ? body : body[..space];
163 }
164
165 private sealed record CatalogSnapshot(
166 IReadOnlyList<ChatSlashCommandDescriptor> Descriptors,
167 Dictionary<string, ChatSlashCommandDescriptor> ByPath);
168}
169
View only · write via MCP/CIDE