Forge
csharp4405de34
1using System.Text;
2using System.Text.Json;
3using Tomlyn;
4using Tomlyn.Model;
5
6internal sealed record SlashRouteCodegenEntry(string Path, string ArgTailKind);
7
8/// <summary>Собирает <c>path</c> и <c>arg_tail</c> из <c>IntentMelody/intent-catalog.toml</c> (паритет с <c>IntentCatalogLoader</c>).</summary>
9internal static class IntentCatalogSlashPathCollector
10{
11 private static readonly TomlSerializerOptions TomlOptions = new()
12 {
13 PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
14 };
15
16 public static IReadOnlyList<SlashRouteCodegenEntry> Collect(string catalogPath)
17 {
18 var text = File.ReadAllText(catalogPath, Encoding.UTF8);
19 if (TomlSerializer.Deserialize<TomlTable>(text, TomlOptions) is not TomlTable root)
20 return [];
21
22 var routes = new Dictionary<string, SlashRouteCodegenEntry>(StringComparer.OrdinalIgnoreCase);
23
24 if (root.TryGetValue("command", out var commandNode))
25 collectFromCommands(commandNode, routes);
26
27 return routes.Values
28 .OrderByDescending(static r => r.Path.Length)
29 .ThenBy(static r => r.Path, StringComparer.Ordinal)
30 .ToList();
31 }
32
33 private static void collectFromCommands(object? node, Dictionary<string, SlashRouteCodegenEntry> routes)
34 {
35 switch (node)
36 {
37 case TomlTableArray commands:
38 foreach (var command in commands)
39 collectSlashTables(command, routes);
40 break;
41 case TomlTable single:
42 collectSlashTables(single, routes);
43 break;
44 }
45 }
46
47 private static void collectSlashTables(TomlTable command, Dictionary<string, SlashRouteCodegenEntry> routes)
48 {
49 if (getBool(command, "enabled") == false)
50 return;
51
52 if (command.TryGetValue("slash", out var slashNode))
53 collectSlashRows(slashNode, routes);
54
55 if (!command.TryGetValue("form", out var formNode) || formNode is not TomlTable form)
56 return;
57
58 if (form.TryGetValue("slash", out var formSlash))
59 collectSlashRows(formSlash, routes);
60 }
61
62 private static void collectSlashRows(object? slashNode, Dictionary<string, SlashRouteCodegenEntry> routes)
63 {
64 switch (slashNode)
65 {
66 case TomlTableArray rows:
67 foreach (var row in rows)
68 addSlashRow(row, routes);
69 break;
70 case TomlTable single:
71 addSlashRow(single, routes);
72 break;
73 }
74 }
75
76 private static void addSlashRow(TomlTable row, Dictionary<string, SlashRouteCodegenEntry> routes)
77 {
78 if (getBool(row, "enabled") == false)
79 return;
80
81 if (!row.TryGetValue("path", out var pathNode))
82 return;
83
84 var path = normalizeSlashPath(pathNode.ToString());
85 if (path.Length == 0)
86 return;
87
88 var argTail = "none";
89 if (row.TryGetValue("arg_tail", out var argTailNode))
90 argTail = argTailNode.ToString()?.Trim().ToLowerInvariant() ?? "none";
91
92 if (argTail is not ("none" or "optional" or "required"))
93 throw new InvalidOperationException($"intent-catalog: slash '{path}' arg_tail must be none|optional|required, got '{argTail}'.");
94
95 if (routes.ContainsKey(path))
96 throw new InvalidOperationException($"intent-catalog: duplicate slash path '{path}'.");
97
98 routes[path] = new SlashRouteCodegenEntry(path, argTail);
99 }
100
101 private static bool? getBool(TomlTable row, string key)
102 {
103 if (!row.TryGetValue(key, out var node))
104 return null;
105
106 return node switch
107 {
108 bool b => b,
109 _ => bool.TryParse(node.ToString(), out var v) ? v : null,
110 };
111 }
112
113 internal static string normalizeSlashPath(string? raw)
114 {
115 if (string.IsNullOrWhiteSpace(raw))
116 return "";
117
118 var t = raw.Trim();
119 if (t.Length == 0)
120 return "";
121
122 return t[0] == '/' ? t : "/" + t;
123 }
124}
125
126internal static class SlashRouteCatalogPathsEmitter
127{
128 public static string Emit(IReadOnlyList<SlashRouteCodegenEntry> routes)
129 {
130 var sb = new StringBuilder();
131 sb.AppendLine("// <auto-generated/>");
132 sb.AppendLine("// Source: IntentMelody/intent-catalog.toml — do not edit.");
133 sb.AppendLine("using System;");
134 sb.AppendLine("using System.Collections.Frozen;");
135 sb.AppendLine("using System.Collections.Generic;");
136 sb.AppendLine("using System.Linq;");
137 sb.AppendLine();
138 sb.AppendLine("namespace CascadeIDE.Services;");
139 sb.AppendLine();
140 sb.AppendLine("/// <summary>Codegen trie index: все slash <c>path</c> из intent-catalog (ADR 0150).</summary>");
141 sb.AppendLine("internal static class SlashRouteCatalogPathsGenerated");
142 sb.AppendLine("{");
143 sb.AppendLine(" /// <summary>Канонические пути, longest-first (longest-prefix match).</summary>");
144 sb.AppendLine(" public static readonly string[] PathsLongestFirst =");
145 sb.AppendLine(" [");
146 foreach (var route in routes)
147 {
148 sb.Append(" \"");
149 sb.Append(Escape(route.Path));
150 sb.AppendLine("\",");
151 }
152 sb.AppendLine(" ];");
153 sb.AppendLine();
154 sb.AppendLine(" private static readonly FrozenSet<string> PathSet =");
155 sb.AppendLine(" PathsLongestFirst.ToFrozenSet(StringComparer.OrdinalIgnoreCase);");
156 sb.AppendLine();
157 sb.AppendLine(" private static readonly FrozenDictionary<string, byte> ArgTailKindByPath =");
158 sb.AppendLine(" new Dictionary<string, byte>(StringComparer.OrdinalIgnoreCase)");
159 sb.AppendLine(" {");
160 foreach (var route in routes.OrderBy(r => r.Path, StringComparer.Ordinal))
161 {
162 sb.Append(" [\"");
163 sb.Append(Escape(route.Path));
164 sb.Append("\"] = ");
165 sb.Append(argTailByte(route.ArgTailKind));
166 sb.AppendLine(",");
167 }
168 sb.AppendLine(" }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);");
169 sb.AppendLine();
170 sb.AppendLine(" public static bool ContainsPath(string normalizedPath) =>");
171 sb.AppendLine(" PathSet.Contains(normalizedPath);");
172 sb.AppendLine();
173 sb.AppendLine(" public static bool TryGetArgTailKind(string normalizedPath, out SlashRouteArgTailKind kind)");
174 sb.AppendLine(" {");
175 sb.AppendLine(" if (ArgTailKindByPath.TryGetValue(normalizedPath, out var b))");
176 sb.AppendLine(" {");
177 sb.AppendLine(" kind = (SlashRouteArgTailKind)b;");
178 sb.AppendLine(" return true;");
179 sb.AppendLine(" }");
180 sb.AppendLine();
181 sb.AppendLine(" kind = SlashRouteArgTailKind.None;");
182 sb.AppendLine(" return false;");
183 sb.AppendLine(" }");
184 sb.AppendLine();
185 sb.AppendLine(" /// <summary>Longest catalog path matching token prefix.</summary>");
186 sb.AppendLine(" public static bool TryResolveLongestPrefix(");
187 sb.AppendLine(" IReadOnlyList<string> tokens,");
188 sb.AppendLine(" bool endsWithSpace,");
189 sb.AppendLine(" out string canonicalPath,");
190 sb.AppendLine(" out string argTail,");
191 sb.AppendLine(" out bool isExactPath,");
192 sb.AppendLine(" out bool endsWithSpaceAfterPath)");
193 sb.AppendLine(" {");
194 sb.AppendLine(" canonicalPath = \"\";");
195 sb.AppendLine(" argTail = \"\";");
196 sb.AppendLine(" isExactPath = false;");
197 sb.AppendLine(" endsWithSpaceAfterPath = false;");
198 sb.AppendLine(" if (tokens.Count == 0)");
199 sb.AppendLine(" return false;");
200 sb.AppendLine();
201 sb.AppendLine(" for (var len = tokens.Count; len >= 1; len--)");
202 sb.AppendLine(" {");
203 sb.AppendLine(" var path = \"/\" + string.Join(' ', tokens.Take(len));");
204 sb.AppendLine(" if (!PathSet.Contains(path))");
205 sb.AppendLine(" continue;");
206 sb.AppendLine();
207 sb.AppendLine(" canonicalPath = path;");
208 sb.AppendLine(" argTail = string.Join(' ', tokens.Skip(len));");
209 sb.AppendLine(" var hasArgTail = argTail.Length > 0;");
210 sb.AppendLine(" isExactPath = len == tokens.Count && !endsWithSpace && !hasArgTail;");
211 sb.AppendLine(" endsWithSpaceAfterPath = endsWithSpace && !hasArgTail && len == tokens.Count;");
212 sb.AppendLine(" return true;");
213 sb.AppendLine(" }");
214 sb.AppendLine();
215 sb.AppendLine(" return false;");
216 sb.AppendLine(" }");
217 sb.AppendLine("}");
218 sb.AppendLine();
219 sb.AppendLine("/// <summary>Зеркало <see cref=\"CascadeIDE.Features.Chat.SlashArgTailKind\"/> (значения 0/1/2).</summary>");
220 sb.AppendLine("internal enum SlashRouteArgTailKind : byte");
221 sb.AppendLine("{");
222 sb.AppendLine(" None = 0,");
223 sb.AppendLine(" Optional = 1,");
224 sb.AppendLine(" Required = 2,");
225 sb.AppendLine("}");
226 return sb.ToString();
227 }
228
229 private static int argTailByte(string kind) =>
230 kind switch
231 {
232 "optional" => 1,
233 "required" => 2,
234 _ => 0,
235 };
236
237 private static string Escape(string s) =>
238 s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
239}
240
View only · write via MCP/CIDE