| 1 | #nullable enable |
| 2 | using CascadeIDE.Services; |
| 3 | |
| 4 | namespace 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> |
| 11 | public 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 | |