Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text.RegularExpressions;
4using CascadeIDE.Services.Intercom;
5
6namespace CascadeIDE.Features.Chat;
7
8/// <summary>Автокомплит внутри <c>[F:… M:… L:… S:…]</c> в composer Intercom (ADR 0128 §5.1).</summary>
9public static class ChatBracketAutocomplete
10{
11 public const int DefaultFileLimit = 25;
12 public const int DefaultMemberLimit = 30;
13 public const int DefaultTemplateLimit = 12;
14
15 private static readonly Regex CsFileBeforeMember = new(
16 @"(?<file>[^\s\[\]]+\.cs)\s+M:(?<prefix>[^\s;\]]*)$",
17 RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled);
18
19 private static readonly string[] ScopeKinds = ["for", "foreach", "if", "while", "switch", "try", "lock", "using"];
20
21 public enum Axis
22 {
23 None = 0,
24 Start,
25 File,
26 Member,
27 Lines,
28 Scope,
29 }
30
31 public sealed record EditState(int BracketStart, int CaretIndex, string Segment, Axis ActiveAxis, string AxisPrefix);
32
33 public static bool TryGetEditState(string? text, int caretIndex, out EditState state)
34 {
35 state = default!;
36 if (string.IsNullOrEmpty(text))
37 return false;
38
39 caretIndex = Math.Clamp(caretIndex, 0, text.Length);
40 var bracketStart = findOpenBracketStart(text, caretIndex);
41 if (bracketStart < 0)
42 return false;
43
44 var inner = text[(bracketStart + 1)..caretIndex];
45 if (inner.Contains('\n') || inner.Contains('\r'))
46 return false;
47
48 var segment = inner;
49 var semi = inner.LastIndexOf(';');
50 if (semi >= 0)
51 segment = inner[(semi + 1)..].TrimStart();
52
53 var (axis, prefix) = detectAxis(segment);
54 state = new EditState(bracketStart, caretIndex, segment, axis, prefix);
55 return true;
56 }
57
58 public static IReadOnlyList<ChatBracketSuggestion> GetSuggestions(
59 string? text,
60 int caretIndex,
61 string? activeFilePath,
62 string? workspaceRoot,
63 IWorkspaceFileSlashCompletionProvider? workspaceFiles,
64 int fileLimit = DefaultFileLimit,
65 int memberLimit = DefaultMemberLimit)
66 {
67 if (!TryGetEditState(text, caretIndex, out var state))
68 return [];
69
70 return state.ActiveAxis switch
71 {
72 Axis.File => suggestFiles(state, workspaceFiles, workspaceRoot, fileLimit),
73 Axis.Member => suggestMembers(state, activeFilePath, workspaceRoot, memberLimit),
74 Axis.Lines => suggestLines(state),
75 Axis.Scope => suggestScope(state),
76 Axis.Start => suggestStart(state, activeFilePath, workspaceRoot, workspaceFiles, fileLimit, memberLimit),
77 _ => [],
78 };
79 }
80
81 private static IReadOnlyList<ChatBracketSuggestion> suggestStart(
82 EditState state,
83 string? activeFilePath,
84 string? workspaceRoot,
85 IWorkspaceFileSlashCompletionProvider? workspaceFiles,
86 int fileLimit,
87 int memberLimit)
88 {
89 var list = new List<ChatBracketSuggestion>();
90 var seg = state.Segment.Trim();
91
92 if (seg.Length == 0)
93 {
94 list.Add(template(state, "M:", "Member (метод, свойство…)", "Attach", "M:", false));
95 list.Add(template(state, "F:", "File (workspace-relative)", "Attach", "F:", false));
96 list.Add(template(state, "S:", "Syntax scope (for/if/…)", "Attach", "S:for:1", false));
97 list.Add(template(state, "L:", "Lines 1-based", "Attach", "L:", false));
98
99 // Не тянуть Roslyn/parse всего .cs на один символ «[» — только шаблоны осей; члены после M: / file M:.
100 return list;
101 }
102
103 if (seg.Contains('.', StringComparison.Ordinal) && !seg.Contains(' ', StringComparison.Ordinal))
104 {
105 return suggestFiles(state, workspaceFiles, workspaceRoot, fileLimit);
106 }
107
108 if (char.IsLetterOrDigit(seg[0]))
109 {
110 var fileSeg = state with { ActiveAxis = Axis.File, AxisPrefix = seg };
111 return suggestFiles(fileSeg, workspaceFiles, workspaceRoot, fileLimit);
112 }
113
114 return list;
115 }
116
117 private static IReadOnlyList<ChatBracketSuggestion> suggestFiles(
118 EditState state,
119 IWorkspaceFileSlashCompletionProvider? workspaceFiles,
120 string? workspaceRoot,
121 int limit)
122 {
123 if (workspaceFiles is null)
124 return [];
125
126 var prefix = state.AxisPrefix;
127 if (state.Segment.StartsWith("F:", StringComparison.OrdinalIgnoreCase))
128 prefix = state.Segment[2..];
129
130 var matches = workspaceFiles.GetMatches(prefix, limit);
131 var list = new List<ChatBracketSuggestion>(matches.Count);
132 foreach (var m in matches)
133 {
134 var path = m.InsertPath;
135 if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
136 && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
137 && !path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
138 {
139 continue;
140 }
141
142 var inner = buildInnerWithAxisPrefix(state, "F:", path);
143 list.Add(new ChatBracketSuggestion(
144 path,
145 m.Help,
146 "File",
147 inner,
148 AddClosingBracket: true));
149 }
150
151 return list;
152 }
153
154 private static IReadOnlyList<ChatBracketSuggestion> suggestMembers(
155 EditState state,
156 string? activeFilePath,
157 string? workspaceRoot,
158 int limit)
159 {
160 string? file = null;
161 var memberPrefix = state.AxisPrefix;
162
163 if (CsFileBeforeMember.Match(state.Segment) is { Success: true } fm)
164 {
165 file = fm.Groups["file"].Value.Trim();
166 memberPrefix = fm.Groups["prefix"].Value;
167 }
168 else if (state.Segment.StartsWith("M:", StringComparison.OrdinalIgnoreCase))
169 {
170 memberPrefix = state.Segment[2..];
171 file = activeFilePath;
172 }
173 else
174 {
175 var fIdx = state.Segment.IndexOf(" F:", StringComparison.OrdinalIgnoreCase);
176 if (fIdx >= 0)
177 {
178 var fPart = state.Segment[(fIdx + 3)..].Trim();
179 var sp = fPart.IndexOf(' ');
180 file = sp > 0 ? fPart[..sp].Trim() : fPart;
181 }
182 else
183 {
184 file = activeFilePath;
185 }
186
187 var mIdx = state.Segment.LastIndexOf(" M:", StringComparison.OrdinalIgnoreCase);
188 if (mIdx >= 0)
189 memberPrefix = state.Segment[(mIdx + 3)..];
190 }
191
192 if (string.IsNullOrWhiteSpace(file))
193 return [];
194
195 var rel = AttachmentAnchorPaths.ToWorkspaceRelative(file, workspaceRoot) ?? file.Replace('\\', '/');
196 var matches = BracketMemberCompletionProvider.GetMatches(rel, workspaceRoot, memberPrefix, limit);
197 return matches
198 .Select(m =>
199 {
200 var inner = buildInnerWithMember(state, rel, m.Name);
201 return new ChatBracketSuggestion(
202 m.Name,
203 $"{m.Help} · {rel}",
204 "Member",
205 inner,
206 AddClosingBracket: true);
207 })
208 .ToList();
209 }
210
211 private static IReadOnlyList<ChatBracketSuggestion> suggestLines(EditState state)
212 {
213 var prefix = state.AxisPrefix.Trim();
214 var templates = new[] { "1", "10", "10-20", "50-100" };
215 return templates
216 .Where(t => prefix.Length == 0 || t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
217 .Select(t => template(state, t, "Line range @ send", "Lines", $"L:{t}", true))
218 .ToList();
219 }
220
221 private static IReadOnlyList<ChatBracketSuggestion> suggestScope(EditState state)
222 {
223 var payload = state.AxisPrefix.Trim();
224 string? kindFilter = null;
225 var indexPrefix = "";
226
227 if (payload.Contains(':'))
228 {
229 var parts = payload.Split(':', 2);
230 kindFilter = parts[0].Trim().ToLowerInvariant();
231 indexPrefix = parts.Length > 1 ? parts[1] : "";
232 }
233 else if (payload.Contains('('))
234 {
235 kindFilter = payload[..payload.IndexOf('(')].Trim().ToLowerInvariant();
236 indexPrefix = payload[(payload.IndexOf('(') + 1)..].TrimEnd(')');
237 }
238 else
239 {
240 kindFilter = payload.ToLowerInvariant();
241 }
242
243 var list = new List<ChatBracketSuggestion>();
244 foreach (var kind in ScopeKinds)
245 {
246 if (kindFilter.Length > 0 && !kind.StartsWith(kindFilter, StringComparison.Ordinal))
247 continue;
248
249 for (var i = 1; i <= 3; i++)
250 {
251 var idxText = i.ToString();
252 if (indexPrefix.Length > 0 && !idxText.StartsWith(indexPrefix, StringComparison.Ordinal))
253 continue;
254
255 var token = $"S:{kind}:{i}";
256 list.Add(template(state, token, $"Syntax scope ({kind}, #{i} in member body)", "Scope", token, true));
257 }
258 }
259
260 return list;
261 }
262
263 private static ChatBracketSuggestion template(
264 EditState state,
265 string display,
266 string help,
267 string? group,
268 string axisPayload,
269 bool close) =>
270 new(display, help, group, buildInnerReplacingAxis(state, axisPayload), close);
271
272 private static string buildInnerWithAxisPrefix(EditState state, string axis, string value)
273 {
274 var seg = state.Segment;
275 if (seg.StartsWith("F:", StringComparison.OrdinalIgnoreCase))
276 return $"F:{value}";
277
278 if (seg.Contains(';'))
279 {
280 var idx = seg.LastIndexOf(';');
281 return seg[..(idx + 1)] + $" F:{value}";
282 }
283
284 return $"F:{value}";
285 }
286
287 private static string buildInnerWithMember(EditState state, string file, string memberName)
288 {
289 if (CsFileBeforeMember.IsMatch(state.Segment) || state.Segment.Contains(".cs", StringComparison.OrdinalIgnoreCase))
290 {
291 var fileName = Path.GetFileName(file);
292 return $"{fileName} M:{memberName}";
293 }
294
295 if (state.Segment.StartsWith("M:", StringComparison.OrdinalIgnoreCase))
296 return $"M:{memberName}";
297
298 if (state.Segment.Contains(" F:", StringComparison.OrdinalIgnoreCase))
299 {
300 var fIdx = state.Segment.IndexOf(" F:", StringComparison.OrdinalIgnoreCase);
301 return state.Segment[..(fIdx + 3)].TrimEnd() + $" {Path.GetFileName(file)} M:{memberName}".Trim();
302 }
303
304 return $"M:{memberName}";
305 }
306
307 private static string buildInnerReplacingAxis(EditState state, string newTail)
308 {
309 if (state.Segment.Contains(';'))
310 {
311 var idx = state.Segment.LastIndexOf(';');
312 return state.Segment[..(idx + 1)] + " " + newTail;
313 }
314
315 return newTail;
316 }
317
318 private static (Axis axis, string prefix) detectAxis(string segment)
319 {
320 var seg = segment.Trim();
321 if (seg.Length == 0)
322 return (Axis.Start, "");
323
324 if (CsFileBeforeMember.IsMatch(seg))
325 return (Axis.Member, CsFileBeforeMember.Match(seg).Groups["prefix"].Value);
326
327 var sMatch = Regex.Match(seg, @"\bS:(?<p>[^\s;\]]*)$", RegexOptions.IgnoreCase);
328 if (sMatch.Success)
329 return (Axis.Scope, sMatch.Groups["p"].Value);
330
331 var lMatch = Regex.Match(seg, @"\bL:(?<p>[^\s;\]]*)$", RegexOptions.IgnoreCase);
332 if (lMatch.Success)
333 return (Axis.Lines, lMatch.Groups["p"].Value);
334
335 var mMatch = Regex.Match(seg, @"\bM:(?<p>[^\s;\]]*)$", RegexOptions.IgnoreCase);
336 if (mMatch.Success)
337 return (Axis.Member, mMatch.Groups["p"].Value);
338
339 var fMatch = Regex.Match(seg, @"\bF:(?<p>[^\s;\]]*)$", RegexOptions.IgnoreCase);
340 if (fMatch.Success)
341 return (Axis.File, fMatch.Groups["p"].Value);
342
343 if (seg.Contains(".cs", StringComparison.OrdinalIgnoreCase) && seg.Contains(' '))
344 return (Axis.Member, "");
345
346 if (seg.Contains('.', StringComparison.Ordinal) && !seg.Contains(' ', StringComparison.Ordinal))
347 return (Axis.File, seg);
348
349 return (Axis.Start, seg);
350 }
351
352 private static int findOpenBracketStart(string text, int caretIndex)
353 {
354 var depth = 0;
355 for (var i = caretIndex - 1; i >= 0; i--)
356 {
357 if (text[i] == ']')
358 {
359 depth++;
360 continue;
361 }
362
363 if (text[i] != '[')
364 continue;
365
366 if (depth > 0)
367 {
368 depth--;
369 continue;
370 }
371
372 return i;
373 }
374
375 return -1;
376 }
377}
378
View only · write via MCP/CIDE