Forge
csharp4405de34
1#nullable enable
2
3using System.Text.RegularExpressions;
4using CascadeIDE.Models.Intercom;
5
6namespace CascadeIDE.Services.Intercom;
7
8/// <summary>Разбор code-ref хвоста find/relate (selection, L:, bracket, bare M:).</summary>
9public static class IntercomCodeRefParser
10{
11 private static readonly Regex CsFileBeforeMember = new(
12 @"(?<file>[^\s\[\]]+\.cs)\s+M:",
13 RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.IgnoreCase);
14
15 public static bool TryParse(
16 string? tail,
17 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
18 string? workspaceRoot,
19 string? solutionPath,
20 out IntercomCodeRefQuery query,
21 out string error,
22 string? indexDirectoryRelative = null)
23 {
24 query = new IntercomCodeRefQuery("", null, null);
25 error = "";
26
27 var text = (tail ?? "").Trim();
28 if (text.Length == 0)
29 {
30 error = "Укажи фрагмент кода: selection, L:10-20, [M:…] или M:Member.";
31 return false;
32 }
33
34 if (string.Equals(text, "selection", StringComparison.OrdinalIgnoreCase))
35 return fromSelection(editor, workspaceRoot, solutionPath, out query, out error);
36
37 if (text.StartsWith("L:", StringComparison.OrdinalIgnoreCase))
38 return fromLineLiteral(text[2..].Trim(), editor, out query, out error);
39
40 if (text.StartsWith("[", StringComparison.Ordinal) || looksLikeBareBracketAxes(text))
41 {
42 var bracketText = text.StartsWith("[", StringComparison.Ordinal) ? text : $"[{text}]";
43 return fromBracket(
44 bracketText,
45 editor,
46 workspaceRoot,
47 solutionPath,
48 indexDirectoryRelative,
49 out query,
50 out error);
51 }
52
53 error = "Ожидается selection, L:строки, [M:…] или M:Member.";
54 return false;
55 }
56
57 /// <summary>Resolve code-ref в канонический <see cref="AttachmentAnchor"/> для event log relate (ADR 0137).</summary>
58 public static bool TryResolveAnchor(
59 string? tail,
60 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
61 string? workspaceRoot,
62 string? solutionPath,
63 out AttachmentAnchor anchor,
64 out string error,
65 string? indexDirectoryRelative = null)
66 {
67 anchor = new AttachmentAnchor();
68 if (!TryParse(tail, editor, workspaceRoot, solutionPath, out var query, out error, indexDirectoryRelative))
69 return false;
70
71 if (query.ResolvedAnchor is { } resolved)
72 {
73 anchor = ensureAnchorId(resolved);
74 return true;
75 }
76
77 if (string.IsNullOrWhiteSpace(query.File))
78 {
79 error = "У ссылки нет file.";
80 return false;
81 }
82
83 anchor = ensureAnchorId(new AttachmentAnchor
84 {
85 AttachmentShape = "text-range",
86 DisplayLabel = query.HasLineRange
87 ? $"{query.File} L{query.LineStart}-{query.LineEnd}"
88 : query.File,
89 File = query.File.Replace('\\', '/'),
90 LineStart = query.LineStart,
91 LineEnd = query.LineEnd,
92 ResolvedAtUtc = DateTimeOffset.UtcNow.ToString("o"),
93 ResolveOutcome = "resolved",
94 });
95 return true;
96 }
97
98 public static bool TryResolveAnchorFromMcp(
99 IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args,
100 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
101 string? workspaceRoot,
102 string? solutionPath,
103 out AttachmentAnchor anchor,
104 out string error,
105 string? indexDirectoryRelative = null)
106 {
107 anchor = new AttachmentAnchor();
108 error = "";
109
110 if (args is not null && args.TryGetValue("anchor_json", out var anchorEl))
111 {
112 if (!AttachmentAnchor.TryParseFromJsonElement(anchorEl, out anchor, out error))
113 return false;
114
115 anchor = ensureAnchorId(anchor);
116 return true;
117 }
118
119 return TryResolveAnchorFromMcpQuery(
120 args,
121 editor,
122 workspaceRoot,
123 solutionPath,
124 indexDirectoryRelative,
125 out anchor,
126 out error);
127 }
128
129 private static bool TryResolveAnchorFromMcpQuery(
130 IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args,
131 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
132 string? workspaceRoot,
133 string? solutionPath,
134 string? indexDirectoryRelative,
135 out AttachmentAnchor anchor,
136 out string error)
137 {
138 if (!TryParseFromMcp(args, editor, workspaceRoot, solutionPath, out var query, out error, indexDirectoryRelative))
139 {
140 anchor = new AttachmentAnchor();
141 return false;
142 }
143
144 return TryResolveAnchorFromQuery(query, out anchor, out error);
145 }
146
147 private static bool TryResolveAnchorFromQuery(
148 IntercomCodeRefQuery query,
149 out AttachmentAnchor anchor,
150 out string error) =>
151 query.ResolvedAnchor is { } resolved
152 ? TryResolveAnchorFromResolved(resolved, out anchor, out error)
153 : TryResolveAnchorFromPositionalQuery(query, out anchor, out error);
154
155 private static bool TryResolveAnchorFromResolved(
156 AttachmentAnchor resolved,
157 out AttachmentAnchor anchor,
158 out string error)
159 {
160 error = "";
161 anchor = ensureAnchorId(resolved);
162 return true;
163 }
164
165 private static bool TryResolveAnchorFromPositionalQuery(
166 IntercomCodeRefQuery query,
167 out AttachmentAnchor anchor,
168 out string error)
169 {
170 error = "";
171 if (string.IsNullOrWhiteSpace(query.File))
172 {
173 error = "У ссылки нет file.";
174 anchor = new AttachmentAnchor();
175 return false;
176 }
177
178 anchor = ensureAnchorId(new AttachmentAnchor
179 {
180 AttachmentShape = "text-range",
181 DisplayLabel = query.HasLineRange
182 ? $"{query.File} L{query.LineStart}-{query.LineEnd}"
183 : query.File,
184 File = query.File.Replace('\\', '/'),
185 LineStart = query.LineStart,
186 LineEnd = query.LineEnd,
187 ResolvedAtUtc = DateTimeOffset.UtcNow.ToString("o"),
188 ResolveOutcome = "resolved",
189 });
190 return true;
191 }
192
193 private static AttachmentAnchor ensureAnchorId(AttachmentAnchor anchor) =>
194 string.IsNullOrWhiteSpace(anchor.Id)
195 ? anchor with { Id = Guid.NewGuid().ToString("N") }
196 : anchor;
197
198 private static bool fromSelection(
199 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
200 string? workspaceRoot,
201 string? solutionPath,
202 out IntercomCodeRefQuery query,
203 out string error)
204 {
205 if (!IntercomAttachmentResolveAtSend.TryResolveSelection(editor, workspaceRoot, solutionPath, out var anchor, out error))
206 {
207 query = new IntercomCodeRefQuery("", null, null);
208 return false;
209 }
210
211 return fromAnchor(anchor, out query, out error);
212 }
213
214 private static bool fromLineLiteral(
215 string lineTail,
216 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
217 out IntercomCodeRefQuery query,
218 out string error)
219 {
220 query = new IntercomCodeRefQuery("", null, null);
221 error = "";
222
223 if (string.IsNullOrWhiteSpace(editor.CurrentFilePath))
224 {
225 error = "Нет активного файла в редакторе.";
226 return false;
227 }
228
229 if (!tryParseLineRangeTail(lineTail, out var start, out var end, out error))
230 return false;
231
232 var file = AttachmentAnchorPaths.ToWorkspaceRelative(editor.CurrentFilePath, null)
233 ?? editor.CurrentFilePath.Replace('\\', '/');
234 var anchor = new AttachmentAnchor
235 {
236 AttachmentShape = "text-range",
237 DisplayLabel = start == end ? $"{file} L{start}" : $"{file} L{start}-{end}",
238 File = file,
239 LineStart = start,
240 LineEnd = end,
241 ResolvedAtUtc = DateTimeOffset.UtcNow.ToString("o"),
242 ResolveOutcome = "resolved",
243 };
244 query = IntercomCodeRefQuery.FromAnchor(ensureAnchorId(anchor));
245 return true;
246 }
247
248 private static bool fromBracket(
249 string text,
250 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
251 string? workspaceRoot,
252 string? solutionPath,
253 string? indexDirectoryRelative,
254 out IntercomCodeRefQuery query,
255 out string error)
256 {
257 if (!BracketCodeReferenceParser.TryParse(text, out var reference, out error))
258 {
259 query = new IntercomCodeRefQuery("", null, null);
260 return false;
261 }
262
263 if (!BracketCodeReferenceParser.TryToAttachmentAnchor(
264 reference,
265 editor.CurrentFilePath,
266 workspaceRoot,
267 solutionPath,
268 indexDirectoryRelative,
269 out var anchor,
270 out error))
271 {
272 query = new IntercomCodeRefQuery("", null, null);
273 return false;
274 }
275
276 return fromAnchor(anchor, out query, out error);
277 }
278
279 private static bool fromAnchor(AttachmentAnchor anchor, out IntercomCodeRefQuery query, out string error)
280 {
281 error = "";
282 if (string.IsNullOrWhiteSpace(anchor.File))
283 {
284 error = "У ссылки нет file.";
285 query = new IntercomCodeRefQuery("", null, null);
286 return false;
287 }
288
289 query = IntercomCodeRefQuery.FromAnchor(anchor);
290 return true;
291 }
292
293 private static bool looksLikeBareBracketAxes(string text) =>
294 text.StartsWith("M:", StringComparison.OrdinalIgnoreCase)
295 || text.StartsWith("F:", StringComparison.OrdinalIgnoreCase)
296 || text.StartsWith("S:", StringComparison.OrdinalIgnoreCase)
297 || CsFileBeforeMember.IsMatch(text);
298
299 public static bool TryParseFromMcp(
300 IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args,
301 in IntercomAttachmentResolveAtSend.EditorSnapshot editor,
302 string? workspaceRoot,
303 string? solutionPath,
304 out IntercomCodeRefQuery query,
305 out string error,
306 string? indexDirectoryRelative = null)
307 {
308 query = new IntercomCodeRefQuery("", null, null);
309 error = "";
310
311 if (args is null)
312 {
313 error = "Отсутствуют аргументы.";
314 return false;
315 }
316
317 var codeRef = McpCommandJsonArgs.String(args, "code_ref");
318 if (!string.IsNullOrWhiteSpace(codeRef))
319 {
320 return TryParse(
321 codeRef,
322 editor,
323 workspaceRoot,
324 solutionPath,
325 out query,
326 out error,
327 indexDirectoryRelative);
328 }
329
330 if (args.TryGetValue("use_selection", out var selEl)
331 && selEl.ValueKind is System.Text.Json.JsonValueKind.True)
332 {
333 return TryParse(
334 "selection",
335 editor,
336 workspaceRoot,
337 solutionPath,
338 out query,
339 out error,
340 indexDirectoryRelative);
341 }
342
343 var file = McpCommandJsonArgs.String(args, "file");
344 if (string.IsNullOrWhiteSpace(file))
345 {
346 error = "Укажи use_selection, code_ref или file+line_start.";
347 return false;
348 }
349
350 var lineStart = McpCommandJsonArgs.OptionalInt32(args, "line_start");
351 var lineEnd = McpCommandJsonArgs.OptionalInt32(args, "line_end") ?? lineStart;
352 if (lineStart is null)
353 {
354 query = new IntercomCodeRefQuery(file.Replace('\\', '/'), null, null);
355 return true;
356 }
357
358 if (lineStart < 1 || lineEnd < 1 || lineEnd < lineStart)
359 {
360 error = "line_start/line_end: 1-based, конец ≥ начала.";
361 return false;
362 }
363
364 query = new IntercomCodeRefQuery(file.Replace('\\', '/'), lineStart, lineEnd);
365 return true;
366 }
367
368 private static bool tryParseLineRangeTail(string lineTail, out int startLine, out int endLine, out string error)
369 {
370 startLine = 0;
371 endLine = 0;
372 error = "";
373
374 var tail = lineTail.Trim();
375 if (tail.Length == 0)
376 {
377 error = "Укажи номера строк (1-based): одну, две через пробел или start:end.";
378 return false;
379 }
380
381 var normalized = tail.Replace(':', ' ').Replace(';', ' ');
382 var parts = normalized.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
383 if (parts.Length is < 1 or > 2)
384 {
385 error = "Ожидается одна строка или диапазон: «5», «5 10» или «5:10».";
386 return false;
387 }
388
389 if (!int.TryParse(parts[0], out startLine) || startLine < 1)
390 {
391 error = $"Некорректный номер строки «{parts[0]}».";
392 return false;
393 }
394
395 if (parts.Length == 1)
396 {
397 endLine = startLine;
398 return true;
399 }
400
401 if (!int.TryParse(parts[1], out endLine) || endLine < 1)
402 {
403 error = $"Некорректный номер строки «{parts[1]}».";
404 return false;
405 }
406
407 if (endLine < startLine)
408 {
409 error = "Конец диапазона не может быть меньше начала.";
410 return false;
411 }
412
413 return true;
414 }
415}
416
View only · write via MCP/CIDE