| 1 | using CascadeIDE.Features.Chat; |
| 2 | using Tomlyn; |
| 3 | using Tomlyn.Model; |
| 4 | |
| 5 | namespace CascadeIDE.Services; |
| 6 | |
| 7 | /// <summary> |
| 8 | /// Единый intent-каталог: <c>command_id</c> и формы <b>melody</b> (<c>c:</c>) / <b>slash</b> (<c>/</c>). |
| 9 | /// Источник: <see cref="BundledRelativePath"/> (оверлей рядом с exe, иначе embedded), ADR 0109, ADR 0119. |
| 10 | /// </summary> |
| 11 | public static class IntentMelodyAliases |
| 12 | { |
| 13 | /// <summary>Относительно <see cref="AppContext.BaseDirectory"/>; оверлей поверх встроенного TOML.</summary> |
| 14 | public const string BundledRelativePath = "IntentMelody/intent-catalog.toml"; |
| 15 | |
| 16 | /// <summary>Устаревшее имя файла оверлея (читается, если <see cref="BundledRelativePath"/> отсутствует на диске).</summary> |
| 17 | public const string LegacyBundledRelativePath = "IntentMelody/intent-melody-aliases.toml"; |
| 18 | |
| 19 | #if DEBUG |
| 20 | private static Lazy<IntentMelodyBundleState>? _bundleLazy; |
| 21 | #else |
| 22 | private static readonly Lazy<IntentMelodyBundleState> BundleLazyFixed = new(LoadBundle, LazyThreadSafetyMode.ExecutionAndPublication); |
| 23 | #endif |
| 24 | |
| 25 | internal static Lazy<IntentMelodyBundleState> BundleLazy => |
| 26 | #if DEBUG |
| 27 | _bundleLazy ??= new Lazy<IntentMelodyBundleState>(LoadBundle, LazyThreadSafetyMode.ExecutionAndPublication); |
| 28 | #else |
| 29 | BundleLazyFixed; |
| 30 | #endif |
| 31 | |
| 32 | internal sealed class IntentMelodyTomlRoot |
| 33 | { |
| 34 | public int? IntentCatalogSchemaVersion { get; set; } |
| 35 | public int? MelodyCatalogSchemaVersion { get; set; } |
| 36 | public int? SlashCatalogSchemaVersion { get; set; } |
| 37 | public Dictionary<string, string>? Aliases { get; set; } |
| 38 | public List<CommandToml>? Command { get; set; } |
| 39 | public List<MelodyRootToml>? MelodyRoot { get; set; } |
| 40 | public List<SlashRouteToml>? SlashRoute { get; set; } |
| 41 | public List<TailWireClassToml>? TailWireClass { get; set; } |
| 42 | } |
| 43 | |
| 44 | /// <remarks>Legacy: <c>[[melody_root]]</c>.</remarks> |
| 45 | internal sealed class MelodyRootToml |
| 46 | { |
| 47 | public string? Slug { get; set; } |
| 48 | public string? CommandId { get; set; } |
| 49 | public string? Shape { get; set; } |
| 50 | public bool? ShowUsageHintIfBareSlug { get; set; } |
| 51 | public string? TailSignature { get; set; } |
| 52 | public string? WireClass { get; set; } |
| 53 | public string? ChordCommit { get; set; } |
| 54 | public string? PaletteHintSlug { get; set; } |
| 55 | public string? PaletteUsageHint { get; set; } |
| 56 | public string? PaletteUsageCategory { get; set; } |
| 57 | } |
| 58 | |
| 59 | /// <remarks>Legacy: <c>[[slash_route]]</c>.</remarks> |
| 60 | internal sealed class SlashRouteToml |
| 61 | { |
| 62 | public string? Path { get; set; } |
| 63 | public string? CommandId { get; set; } |
| 64 | public string? Help { get; set; } |
| 65 | public string? Group { get; set; } |
| 66 | public string? MfdPage { get; set; } |
| 67 | public string? PrimarySurface { get; set; } |
| 68 | public string? Kind { get; set; } |
| 69 | } |
| 70 | |
| 71 | #pragma warning disable CA1812 |
| 72 | internal sealed class TailWireClassToml |
| 73 | { |
| 74 | public string? Id { get; set; } |
| 75 | public string? Kind { get; set; } |
| 76 | public string[]? BetweenSlotsAnyOf { get; set; } |
| 77 | } |
| 78 | #pragma warning restore CA1812 |
| 79 | |
| 80 | internal sealed record IntentMelodyBundleState(Dictionary<string, string> AliasToCommandId, IntentMelodyCatalogSnapshot Catalog); |
| 81 | |
| 82 | private static Dictionary<string, string> AliasToCommandId => BundleLazy.Value.AliasToCommandId; |
| 83 | |
| 84 | internal static IntentMelodyCatalogSnapshot GetCatalogSnapshot() => BundleLazy.Value.Catalog; |
| 85 | |
| 86 | #if DEBUG |
| 87 | internal static void ResetForTests() => Interlocked.Exchange(ref _bundleLazy, null); |
| 88 | #else |
| 89 | internal static void ResetForTests() { } |
| 90 | #endif |
| 91 | |
| 92 | private static IntentMelodyBundleState LoadBundle() |
| 93 | { |
| 94 | if ((!BundledAppContent.TryReadDiskThenEmbedded(BundledRelativePath, out var text) |
| 95 | && !BundledAppContent.TryReadDiskThenEmbedded(LegacyBundledRelativePath, out text)) |
| 96 | || string.IsNullOrWhiteSpace(text)) |
| 97 | { |
| 98 | throw new InvalidOperationException( |
| 99 | $"Missing {BundledRelativePath} (file under AppContext.BaseDirectory or embedded resource in CascadeIDE assembly; legacy disk name: {LegacyBundledRelativePath})."); |
| 100 | } |
| 101 | |
| 102 | var root = ParseIntentCatalogToRoot(text.Trim(), BundledRelativePath); |
| 103 | |
| 104 | return Build(root); |
| 105 | } |
| 106 | |
| 107 | private static IntentMelodyTomlRoot ParseIntentCatalogToRoot(string tomlTrimmed, string sourceName) |
| 108 | { |
| 109 | // Tomlyn reflection deserializer is fragile for command-first TOML with nested arrays-of-tables. |
| 110 | // We parse to DOM (TomlTable) and project into our POCOs explicitly. |
| 111 | var rootTable = TomlSerializer.Deserialize<TomlTable>(tomlTrimmed, CascadeTomlSerializer.Options); |
| 112 | if (rootTable is null) |
| 113 | throw new InvalidOperationException($"{sourceName}: empty parse."); |
| 114 | |
| 115 | return new IntentMelodyTomlRoot |
| 116 | { |
| 117 | IntentCatalogSchemaVersion = GetInt(rootTable, "intent_catalog_schema_version"), |
| 118 | MelodyCatalogSchemaVersion = GetInt(rootTable, "melody_catalog_schema_version"), |
| 119 | SlashCatalogSchemaVersion = GetInt(rootTable, "slash_catalog_schema_version"), |
| 120 | Aliases = GetStringMap(rootTable, "aliases"), |
| 121 | Command = ReadCommands(rootTable), |
| 122 | // legacy blocks (melody_root, slash_route, tail_wire_class) are still supported if present |
| 123 | MelodyRoot = ReadMelodyRoots(rootTable), |
| 124 | SlashRoute = ReadSlashRoutes(rootTable), |
| 125 | TailWireClass = ReadTailWireClasses(rootTable), |
| 126 | }; |
| 127 | } |
| 128 | |
| 129 | private static List<CommandToml>? ReadCommands(TomlTable root) |
| 130 | { |
| 131 | if (!root.TryGetValue("command", out var v) || v is not TomlTableArray arr || arr.Count == 0) |
| 132 | return null; |
| 133 | |
| 134 | var list = new List<CommandToml>(arr.Count); |
| 135 | foreach (var x in arr) |
| 136 | { |
| 137 | if (x is not TomlTable t) |
| 138 | continue; |
| 139 | |
| 140 | var cmd = new CommandToml |
| 141 | { |
| 142 | CommandId = GetString(t, "command_id"), |
| 143 | Enabled = GetBool(t, "enabled"), |
| 144 | SlashGroup = GetString(t, "slash_group"), |
| 145 | |
| 146 | MelodySlug = GetString(t, "melody_slug"), |
| 147 | MelodyShape = GetString(t, "melody_shape"), |
| 148 | MelodyShowUsageHintIfBareSlug = GetBool(t, "melody_show_usage_hint_if_bare_slug"), |
| 149 | MelodyTailSignature = GetString(t, "melody_tail_signature"), |
| 150 | MelodyWireClass = GetString(t, "melody_wire_class"), |
| 151 | MelodyChordCommit = GetString(t, "melody_chord_commit"), |
| 152 | MelodyPaletteHintSlug = GetString(t, "melody_palette_hint_slug"), |
| 153 | MelodyPaletteUsageHint = GetString(t, "melody_palette_usage_hint"), |
| 154 | MelodyPaletteUsageCategory = GetString(t, "melody_palette_usage_category"), |
| 155 | }; |
| 156 | |
| 157 | // legacy [command.melody] |
| 158 | cmd.Melody = ReadMelodyForm(t, "melody"); |
| 159 | |
| 160 | // legacy [[command.slash]] (what we use now) |
| 161 | cmd.Slash = ReadSlashForms(t, "slash"); |
| 162 | |
| 163 | // newer nested [command.form] with [[command.form.slash]] if present in older overlays |
| 164 | if (t.TryGetValue("form", out var formObj) && formObj is TomlTable form) |
| 165 | { |
| 166 | cmd.Form = new CommandFormToml |
| 167 | { |
| 168 | Melody = ReadMelodyForm(form, "melody"), |
| 169 | Slash = ReadSlashForms(form, "slash"), |
| 170 | }; |
| 171 | } |
| 172 | |
| 173 | list.Add(cmd); |
| 174 | } |
| 175 | |
| 176 | return list.Count == 0 ? null : list; |
| 177 | } |
| 178 | |
| 179 | private static MelodyFormToml? ReadMelodyForm(TomlTable t, string key) |
| 180 | { |
| 181 | if (!t.TryGetValue(key, out var v) || v is not TomlTable m) |
| 182 | return null; |
| 183 | |
| 184 | return new MelodyFormToml |
| 185 | { |
| 186 | Slug = GetString(m, "slug"), |
| 187 | Shape = GetString(m, "shape"), |
| 188 | ShowUsageHintIfBareSlug = GetBool(m, "show_usage_hint_if_bare_slug"), |
| 189 | TailSignature = GetString(m, "tail_signature"), |
| 190 | WireClass = GetString(m, "wire_class"), |
| 191 | ChordCommit = GetString(m, "chord_commit"), |
| 192 | PaletteHintSlug = GetString(m, "palette_hint_slug"), |
| 193 | PaletteUsageHint = GetString(m, "palette_usage_hint"), |
| 194 | PaletteUsageCategory = GetString(m, "palette_usage_category"), |
| 195 | }; |
| 196 | } |
| 197 | |
| 198 | private static List<SlashFormToml>? ReadSlashForms(TomlTable t, string key) |
| 199 | { |
| 200 | if (!t.TryGetValue(key, out var v) || v is not TomlTableArray arr || arr.Count == 0) |
| 201 | return null; |
| 202 | |
| 203 | var list = new List<SlashFormToml>(arr.Count); |
| 204 | foreach (var x in arr) |
| 205 | { |
| 206 | if (x is not TomlTable s) |
| 207 | continue; |
| 208 | |
| 209 | var sf = new SlashFormToml |
| 210 | { |
| 211 | Enabled = GetBool(s, "enabled"), |
| 212 | Path = GetString(s, "path"), |
| 213 | Help = GetString(s, "help"), |
| 214 | Group = GetString(s, "group"), |
| 215 | Kind = GetString(s, "kind"), |
| 216 | MfdPage = GetString(s, "mfd_page"), |
| 217 | PrimarySurface = GetString(s, "primary_surface"), |
| 218 | Completion = GetString(s, "completion"), |
| 219 | ReportHandler = GetString(s, "report_handler"), |
| 220 | IntercomHandler = GetString(s, "intercom_handler"), |
| 221 | Audience = GetString(s, "audience"), |
| 222 | AutoRunOnCommit = GetBool(s, "auto_run_on_commit"), |
| 223 | AutoRunRequiresArgs = GetBool(s, "auto_run_requires_args"), |
| 224 | RequiresArgTail = GetBool(s, "requires_arg_tail"), |
| 225 | ArgTail = GetString(s, "arg_tail"), |
| 226 | Domain = GetString(s, "domain"), |
| 227 | Object = GetString(s, "object"), |
| 228 | Intent = GetString(s, "intent"), |
| 229 | PathRole = GetString(s, "path_role"), |
| 230 | }; |
| 231 | |
| 232 | // args = { page=..., surface=..., level=... } |
| 233 | if (s.TryGetValue("args", out var argsObj) && argsObj is TomlTable a) |
| 234 | { |
| 235 | sf.Args = new SlashStaticArgsToml |
| 236 | { |
| 237 | Page = GetString(a, "page"), |
| 238 | Surface = GetString(a, "surface"), |
| 239 | Level = GetString(a, "level"), |
| 240 | }; |
| 241 | } |
| 242 | |
| 243 | list.Add(sf); |
| 244 | } |
| 245 | |
| 246 | return list.Count == 0 ? null : list; |
| 247 | } |
| 248 | |
| 249 | private static List<MelodyRootToml>? ReadMelodyRoots(TomlTable root) |
| 250 | { |
| 251 | if (!root.TryGetValue("melody_root", out var v) || v is not TomlTableArray arr || arr.Count == 0) |
| 252 | return null; |
| 253 | var list = new List<MelodyRootToml>(arr.Count); |
| 254 | foreach (var x in arr) |
| 255 | { |
| 256 | if (x is not TomlTable t) |
| 257 | continue; |
| 258 | list.Add(new MelodyRootToml |
| 259 | { |
| 260 | Slug = GetString(t, "slug"), |
| 261 | CommandId = GetString(t, "command_id"), |
| 262 | Shape = GetString(t, "shape"), |
| 263 | ShowUsageHintIfBareSlug = GetBool(t, "show_usage_hint_if_bare_slug"), |
| 264 | TailSignature = GetString(t, "tail_signature"), |
| 265 | WireClass = GetString(t, "wire_class"), |
| 266 | ChordCommit = GetString(t, "chord_commit"), |
| 267 | PaletteHintSlug = GetString(t, "palette_hint_slug"), |
| 268 | PaletteUsageHint = GetString(t, "palette_usage_hint"), |
| 269 | PaletteUsageCategory = GetString(t, "palette_usage_category"), |
| 270 | }); |
| 271 | } |
| 272 | return list.Count == 0 ? null : list; |
| 273 | } |
| 274 | |
| 275 | private static List<SlashRouteToml>? ReadSlashRoutes(TomlTable root) |
| 276 | { |
| 277 | if (!root.TryGetValue("slash_route", out var v) || v is not TomlTableArray arr || arr.Count == 0) |
| 278 | return null; |
| 279 | var list = new List<SlashRouteToml>(arr.Count); |
| 280 | foreach (var x in arr) |
| 281 | { |
| 282 | if (x is not TomlTable t) |
| 283 | continue; |
| 284 | list.Add(new SlashRouteToml |
| 285 | { |
| 286 | Path = GetString(t, "path"), |
| 287 | CommandId = GetString(t, "command_id"), |
| 288 | Help = GetString(t, "help"), |
| 289 | Group = GetString(t, "group"), |
| 290 | MfdPage = GetString(t, "mfd_page"), |
| 291 | PrimarySurface = GetString(t, "primary_surface"), |
| 292 | Kind = GetString(t, "kind"), |
| 293 | }); |
| 294 | } |
| 295 | return list.Count == 0 ? null : list; |
| 296 | } |
| 297 | |
| 298 | private static List<TailWireClassToml>? ReadTailWireClasses(TomlTable root) |
| 299 | { |
| 300 | if (!root.TryGetValue("tail_wire_class", out var v) || v is not TomlTableArray arr || arr.Count == 0) |
| 301 | return null; |
| 302 | var list = new List<TailWireClassToml>(arr.Count); |
| 303 | foreach (var x in arr) |
| 304 | { |
| 305 | if (x is not TomlTable t) |
| 306 | continue; |
| 307 | list.Add(new TailWireClassToml |
| 308 | { |
| 309 | Id = GetString(t, "id"), |
| 310 | Kind = GetString(t, "kind"), |
| 311 | BetweenSlotsAnyOf = GetStringArray(t, "between_slots_any_of"), |
| 312 | }); |
| 313 | } |
| 314 | return list.Count == 0 ? null : list; |
| 315 | } |
| 316 | |
| 317 | private static int? GetInt(TomlTable t, string key) => |
| 318 | t.TryGetValue(key, out var v) && v is long l ? (int)l : null; |
| 319 | |
| 320 | private static bool? GetBool(TomlTable t, string key) => |
| 321 | t.TryGetValue(key, out var v) && v is bool b ? b : null; |
| 322 | |
| 323 | private static string? GetString(TomlTable t, string key) => |
| 324 | t.TryGetValue(key, out var v) && v is string s ? s : null; |
| 325 | |
| 326 | private static string[]? GetStringArray(TomlTable t, string key) |
| 327 | { |
| 328 | if (!t.TryGetValue(key, out var v) || v is not TomlArray a || a.Count == 0) |
| 329 | return null; |
| 330 | var list = new List<string>(a.Count); |
| 331 | foreach (var x in a) |
| 332 | { |
| 333 | if (x is not string s || s.Length == 0) |
| 334 | continue; |
| 335 | |
| 336 | // Preserve single-char separators such as space in between_slots_any_of. |
| 337 | if (s.Length == 1 || !string.IsNullOrWhiteSpace(s)) |
| 338 | list.Add(s); |
| 339 | } |
| 340 | return list.Count == 0 ? null : list.ToArray(); |
| 341 | } |
| 342 | |
| 343 | private static Dictionary<string, string>? GetStringMap(TomlTable t, string key) |
| 344 | { |
| 345 | if (!t.TryGetValue(key, out var v) || v is not TomlTable map) |
| 346 | return null; |
| 347 | var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| 348 | foreach (var kv in map) |
| 349 | { |
| 350 | if (kv.Value is string s) |
| 351 | d[kv.Key] = s; |
| 352 | } |
| 353 | return d.Count == 0 ? null : d; |
| 354 | } |
| 355 | |
| 356 | internal static IntentMelodyBundleState Build(IntentMelodyTomlRoot root) |
| 357 | { |
| 358 | var catalog = IntentCatalogLoader.BuildSnapshot(root, BundledRelativePath); |
| 359 | var commandMap = IntentCatalogLoader.BuildMelodyAliasMap(catalog); |
| 360 | |
| 361 | if (commandMap.Count == 0 && catalog.SlashRoutes.Count == 0) |
| 362 | { |
| 363 | throw new InvalidOperationException( |
| 364 | $"{BundledRelativePath}: пустой каталог (нет melody slug и slash)."); |
| 365 | } |
| 366 | |
| 367 | return new IntentMelodyBundleState(commandMap, catalog); |
| 368 | } |
| 369 | |
| 370 | internal static IntentMelodyBundleState ParseBundleForTests(string tomlTrimmed) => |
| 371 | Build(ParseIntentCatalogToRoot(tomlTrimmed, "tests")); |
| 372 | |
| 373 | public static IReadOnlyList<(string Alias, string CommandId)> AllPairs() => |
| 374 | AliasToCommandId |
| 375 | .OrderBy(kv => kv.Key, StringComparer.Ordinal) |
| 376 | .Select(kv => (kv.Key, kv.Value)) |
| 377 | .ToList(); |
| 378 | |
| 379 | public static string SampleAliasesForFooter(int maxAliases = 8) |
| 380 | { |
| 381 | if (maxAliases <= 0) |
| 382 | return ""; |
| 383 | |
| 384 | var pairs = AllPairs(); |
| 385 | if (pairs.Count == 0) |
| 386 | return ""; |
| 387 | |
| 388 | var take = Math.Min(maxAliases, pairs.Count); |
| 389 | var s = string.Join(", ", pairs.Take(take).Select(p => p.Alias)); |
| 390 | if (pairs.Count > maxAliases) |
| 391 | s += ", …"; |
| 392 | return s; |
| 393 | } |
| 394 | |
| 395 | public static IReadOnlyList<(string Alias, string CommandId)> FilterByTailPrefix(string tailNormalized) |
| 396 | { |
| 397 | if (string.IsNullOrEmpty(tailNormalized)) |
| 398 | return AllPairs(); |
| 399 | |
| 400 | var list = new List<(string, string)>(); |
| 401 | foreach (var kv in AliasToCommandId.OrderBy(x => x.Key, StringComparer.Ordinal)) |
| 402 | { |
| 403 | if (kv.Key.StartsWith(tailNormalized, StringComparison.Ordinal)) |
| 404 | list.Add((kv.Key, kv.Value)); |
| 405 | } |
| 406 | |
| 407 | return list; |
| 408 | } |
| 409 | |
| 410 | public static bool TryGetTail(string? raw, out string tailNormalized) |
| 411 | { |
| 412 | tailNormalized = ""; |
| 413 | if (string.IsNullOrWhiteSpace(raw)) |
| 414 | return false; |
| 415 | |
| 416 | var t = raw.TrimStart(); |
| 417 | if (t.Length < 2 || char.ToLowerInvariant(t[0]) != 'c' || t[1] != ':') |
| 418 | return false; |
| 419 | |
| 420 | tailNormalized = t.Length > 2 ? t[2..].Trim().ToLowerInvariant() : ""; |
| 421 | return true; |
| 422 | } |
| 423 | |
| 424 | public static string? TryResolveExactCommandId(string tailNormalized) |
| 425 | { |
| 426 | if (string.IsNullOrEmpty(tailNormalized)) |
| 427 | return null; |
| 428 | |
| 429 | return AliasToCommandId.TryGetValue(tailNormalized, out var id) ? id : null; |
| 430 | } |
| 431 | |
| 432 | public static bool HasStrictLongerAliasPrefix(string tailNormalized) |
| 433 | { |
| 434 | if (string.IsNullOrEmpty(tailNormalized)) |
| 435 | return false; |
| 436 | |
| 437 | foreach (var key in AliasToCommandId.Keys) |
| 438 | { |
| 439 | if (key.Length > tailNormalized.Length && key.StartsWith(tailNormalized, StringComparison.Ordinal)) |
| 440 | return true; |
| 441 | } |
| 442 | |
| 443 | return false; |
| 444 | } |
| 445 | } |
| 446 | |