Forge
csharpdeeb25a2
1using CascadeIDE.Features.Chat;
2using Tomlyn;
3using Tomlyn.Model;
4
5namespace 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>
11public 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
View only · write via MCP/CIDE