Forge
csharpdeeb25a2
1#nullable enable
2
3namespace CascadeIDE.Services;
4
5/// <summary>Семантика slash path: domain · object · intent (ADR 0154).</summary>
6public readonly record struct SlashSemanticFields(
7 string Domain,
8 string Object,
9 string Intent,
10 SlashPathRole PathRole = SlashPathRole.Canonical)
11{
12 public bool DomainOmittedInPath =>
13 PathRole == SlashPathRole.Alias && !string.IsNullOrEmpty(Domain);
14}
15
16public enum SlashPathRole
17{
18 Canonical,
19 Alias,
20}
21
22/// <summary>Вывод и проверка domain/object/intent для <c>[[command.form.slash]]</c>.</summary>
23public static class SlashRouteSemantics
24{
25 private static readonly HashSet<string> SolutionElisionObjects =
26 new(StringComparer.OrdinalIgnoreCase) { "build", "test", "debug", "format" };
27
28 public static SlashSemanticFields Resolve(string slashPath, string? mapLevel = null)
29 {
30 var segs = SplitPath(slashPath);
31 if (segs.Count == 0)
32 return new("", "", "", SlashPathRole.Canonical);
33
34 if (segs.Count == 1)
35 return new(segs[0], "", "", SlashPathRole.Canonical);
36
37 if (segs.Count == 2 && SolutionElisionObjects.Contains(segs[0]))
38 return new("solution", segs[0], segs[1], SlashPathRole.Alias);
39
40 if (segs[0].Equals("anchor", StringComparison.OrdinalIgnoreCase))
41 return new("intercom", "anchor", segs[1], SlashPathRole.Alias);
42
43 return segs[0].ToLowerInvariant() switch
44 {
45 "intercom" => resolveIntercom(segs),
46 "solution" => resolveSolution(segs),
47 "editor" => resolveEditor(segs),
48 "map" => resolveMap(segs, mapLevel),
49 "git" or "agent" or "chat" or "diagnostics" or "state" or "search" or "export" or "open"
50 or "folder" or "portal" or "cockpit" or "help" or "file" or "pfd" or "related" or "mfd"
51 or "preview" or "output" or "repository" or "instrumentation" or "terminal" or "problems"
52 or "events" or "workspace" or "settings" or "readiness" or "index" or "tests" or "ide"
53 => resolveDomainIntent(segs),
54 _ => segs.Count >= 3
55 ? new(segs[0], segs[1], joinTail(segs, 2), SlashPathRole.Canonical)
56 : new(segs[0], "", segs[1], SlashPathRole.Canonical),
57 };
58 }
59
60 public static string BuildCanonicalPath(string domain, string obj, string intent)
61 {
62 var parts = new List<string>(3);
63 if (!string.IsNullOrEmpty(domain))
64 parts.Add(domain);
65 if (!string.IsNullOrEmpty(obj))
66 parts.Add(obj);
67 if (!string.IsNullOrEmpty(intent))
68 parts.Add(intent);
69
70 return parts.Count == 0 ? "" : "/" + string.Join(' ', parts);
71 }
72
73 public static bool PathMatchesSemantic(string slashPath, SlashSemanticFields fields)
74 {
75 var path = normalizePath(slashPath);
76 var canonical = BuildCanonicalPath(fields.Domain, fields.Object, fields.Intent);
77 if (path.Equals(canonical, StringComparison.OrdinalIgnoreCase))
78 return true;
79
80 var segs = SplitPath(path);
81 var required = collectRequiredTokens(fields);
82 if (required.Count == 0)
83 return segs.Count <= 1;
84
85 foreach (var token in required)
86 {
87 if (tokenPresentInPath(token, segs))
88 continue;
89
90 if (fields.PathRole == SlashPathRole.Alias
91 && token.Equals("project", StringComparison.OrdinalIgnoreCase)
92 && fields.Intent.Equals("new", StringComparison.OrdinalIgnoreCase)
93 && segs.Any(s => s.Equals("new", StringComparison.OrdinalIgnoreCase)))
94 continue;
95
96 if (fields.PathRole == SlashPathRole.Alias
97 && fields.DomainOmittedInPath
98 && token.Equals(fields.Domain, StringComparison.OrdinalIgnoreCase))
99 continue;
100
101 if (fields.PathRole == SlashPathRole.Alias
102 && fields.Intent.Equals("set", StringComparison.OrdinalIgnoreCase)
103 && fields.Object.Equals("type", StringComparison.OrdinalIgnoreCase)
104 && token.Equals("set", StringComparison.OrdinalIgnoreCase))
105 continue;
106
107 return false;
108 }
109
110 return true;
111 }
112
113 private static bool tokenPresentInPath(string token, IReadOnlyList<string> segs)
114 {
115 if (segs.Any(s => s.Equals(token, StringComparison.OrdinalIgnoreCase)))
116 return true;
117
118 if (!token.Contains('_', StringComparison.Ordinal))
119 return false;
120
121 var parts = token.Split('_', StringSplitOptions.RemoveEmptyEntries);
122 var idx = 0;
123 foreach (var part in parts)
124 {
125 var found = false;
126 for (; idx < segs.Count; idx++)
127 {
128 if (!segs[idx].Equals(part, StringComparison.OrdinalIgnoreCase))
129 continue;
130
131 found = true;
132 idx++;
133 break;
134 }
135
136 if (!found)
137 return false;
138 }
139
140 return true;
141 }
142
143 private static List<string> collectRequiredTokens(SlashSemanticFields fields)
144 {
145 var required = new List<string>(3);
146 if (!fields.DomainOmittedInPath && !string.IsNullOrEmpty(fields.Domain))
147 required.Add(fields.Domain);
148 if (!string.IsNullOrEmpty(fields.Object))
149 required.Add(fields.Object);
150 if (!string.IsNullOrEmpty(fields.Intent))
151 required.Add(fields.Intent);
152 return required;
153 }
154
155 /// <summary>Подпись шага иерархии для popup (домен → объект → действие → аргумент).</summary>
156 public static string GetNextStepLabel(
157 IReadOnlyList<string> tokens,
158 bool endsWithSpace,
159 SlashSemanticFields fields,
160 string slashPath)
161 {
162 var idx = getSemanticStepIndex(tokens, endsWithSpace, fields, slashPath);
163 return idx switch
164 {
165 0 => "домен",
166 1 => "объект",
167 2 => "действие",
168 _ => "аргумент",
169 };
170 }
171
172 public static string BuildSemanticBreadcrumb(
173 IReadOnlyList<string> tokens,
174 SlashSemanticFields fields,
175 string slashPath)
176 {
177 var parts = new List<string> { "/" };
178 if (fields.DomainOmittedInPath && !string.IsNullOrEmpty(fields.Domain))
179 parts.Add($"({fields.Domain})");
180
181 if (!string.IsNullOrEmpty(fields.Domain) && !fields.DomainOmittedInPath)
182 parts.Add(fields.Domain);
183
184 foreach (var t in tokens)
185 parts.Add(t);
186
187 if (tokens.Count == 0 && string.IsNullOrEmpty(fields.Domain))
188 return $"/ → {GetNextStepLabel(tokens, endsWithSpace: false, fields, slashPath)}";
189
190 parts.Add("…");
191 return string.Join(" › ", parts);
192 }
193
194 private static int getSemanticStepIndex(
195 IReadOnlyList<string> tokens,
196 bool endsWithSpace,
197 SlashSemanticFields fields,
198 string slashPath)
199 {
200 var idx = endsWithSpace ? tokens.Count : Math.Max(0, tokens.Count - 1);
201 if (fields.DomainOmittedInPath || isDomainOmittedAlias(fields, slashPath))
202 idx++;
203
204 return idx;
205 }
206
207 private static bool isDomainOmittedAlias(SlashSemanticFields fields, string slashPath)
208 {
209 if (fields.PathRole != SlashPathRole.Alias)
210 return false;
211
212 var canonical = BuildCanonicalPath(fields.Domain, fields.Object, fields.Intent);
213 return !slashPath.Equals(canonical, StringComparison.OrdinalIgnoreCase);
214 }
215
216 private static SlashSemanticFields resolveIntercom(IReadOnlyList<string> segs)
217 {
218 if (segs.Count == 2)
219 {
220 var second = segs[1];
221 if (second.Equals("overview", StringComparison.OrdinalIgnoreCase)
222 || second.Equals("show", StringComparison.OrdinalIgnoreCase))
223 return new("intercom", "", second, SlashPathRole.Canonical);
224
225 return new("intercom", "", second, SlashPathRole.Canonical);
226 }
227
228 if (segs.Count == 3)
229 return new("intercom", segs[1], segs[2], SlashPathRole.Canonical);
230
231 if (segs.Count >= 4)
232 return new("intercom", segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
233
234 return new("intercom", segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
235 }
236
237 private static SlashSemanticFields resolveSolution(IReadOnlyList<string> segs)
238 {
239 if (segs.Count == 2)
240 return new("solution", "", segs[1], SlashPathRole.Canonical);
241
242 if (segs.Count >= 3 && segs[1].Equals("new", StringComparison.OrdinalIgnoreCase))
243 return new("solution", "project", "new", SlashPathRole.Alias);
244
245 if (segs.Count >= 3 && segs[1].Equals("explorer", StringComparison.OrdinalIgnoreCase))
246 return new("solution", "explorer", segs[2], SlashPathRole.Canonical);
247
248 return new("solution", segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
249 }
250
251 private static SlashSemanticFields resolveEditor(IReadOnlyList<string> segs)
252 {
253 if (segs.Count == 2)
254 return new("editor", "", segs[1], SlashPathRole.Canonical);
255
256 if (segs.Count == 3 && segs[1].Equals("line", StringComparison.OrdinalIgnoreCase))
257 return new("editor", "line", segs[2], SlashPathRole.Canonical);
258
259 if (segs.Count == 3 && (segs[1].Equals("select", StringComparison.OrdinalIgnoreCase)
260 || segs[1].Equals("reveal", StringComparison.OrdinalIgnoreCase)))
261 return new("editor", "code", segs[1], SlashPathRole.Canonical);
262
263 if (segs.Count == 3 && segs[1].Equals("layout", StringComparison.OrdinalIgnoreCase))
264 return new("editor", "layout", segs[2], SlashPathRole.Canonical);
265
266 return new("editor", segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
267 }
268
269 private static SlashSemanticFields resolveMap(IReadOnlyList<string> segs, string? mapLevel)
270 {
271 if (segs.Count >= 3 && segs[1].Equals("type", StringComparison.OrdinalIgnoreCase))
272 return new("map", "type", "set", SlashPathRole.Alias);
273
274 if (segs.Count == 3 && segs[1].Equals("cycle", StringComparison.OrdinalIgnoreCase))
275 return new("map", "cycle", segs[2], SlashPathRole.Canonical);
276
277 return new("map", segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
278 }
279
280 private static SlashSemanticFields resolveDomainIntent(IReadOnlyList<string> segs) =>
281 segs.Count == 2
282 ? new(segs[0], "", segs[1], SlashPathRole.Canonical)
283 : new(segs[0], segs[1], joinTail(segs, 2), SlashPathRole.Canonical);
284
285 private static string joinTail(IReadOnlyList<string> segs, int start)
286 {
287 if (start >= segs.Count)
288 return "";
289
290 return start == segs.Count - 1
291 ? segs[start]
292 : string.Join('_', segs.Skip(start));
293 }
294
295 private static List<string> SplitPath(string slashPath)
296 {
297 var path = normalizePath(slashPath);
298 if (path.Length < 2)
299 return [];
300
301 return path[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
302 }
303
304 private static string normalizePath(string slashPath)
305 {
306 if (string.IsNullOrWhiteSpace(slashPath))
307 return "";
308
309 var t = slashPath.Trim();
310 return t[0] == '/' ? t : "/" + t;
311 }
312
313}
314
View only · write via MCP/CIDE