Forge
csharp4405de34
1#nullable enable
2
3using System.Text.Json;
4using System.Text.RegularExpressions;
5using CascadeIDE.Models.Intercom;
6
7namespace CascadeIDE.Services.Intercom;
8
9/// <summary>
10/// Разбор bracket-ссылок H1/L2 (ADR 0128 §5.1, 0131): <c>F</c>/<c>M</c>/<c>L</c>/<c>S</c> — напр. <c>[M:Run S:for:2]</c>, <c>[F:…; M:…; S:for:2]</c>.
11/// </summary>
12public static class BracketCodeReferenceParser
13{
14 private static readonly Regex MemberToken = new(
15 @"\bM:(?<member>[^\s;\]]+)",
16 RegexOptions.CultureInvariant | RegexOptions.Compiled);
17
18 private static readonly Regex FileToken = new(
19 @"\bF:(?<file>[^\s;\]]+)",
20 RegexOptions.CultureInvariant | RegexOptions.Compiled);
21
22 private static readonly Regex LineToken = new(
23 @"\bL:(?<start>\d+)\s*(?:-\s*(?<end>\d+))?",
24 RegexOptions.CultureInvariant | RegexOptions.Compiled);
25
26 private static readonly Regex ScopeToken = new(
27 @"\bS:(?<kind>[A-Za-z_][\w]*)\s*(?::|\()\s*(?<index>\d+)\s*\)?",
28 RegexOptions.CultureInvariant | RegexOptions.Compiled);
29
30 private static readonly Regex CsFileBeforeMember = new(
31 @"(?<file>[^\s\[\]]+\.cs)\s+M:",
32 RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.IgnoreCase);
33
34 public static bool TryParse(string? input, out BracketCodeReference reference, out string error)
35 {
36 reference = default;
37 error = "";
38
39 var text = (input ?? "").Trim();
40 if (text.Length == 0)
41 {
42 error = "Пустая bracket-ссылка.";
43 return false;
44 }
45
46 if (text.StartsWith('[') && text.EndsWith(']'))
47 text = text[1..^1].Trim();
48
49 if (text.Contains(';', StringComparison.Ordinal))
50 return TryParseL2(text, out reference, out error);
51
52 return TryParseH1(text, out reference, out error);
53 }
54
55 public static bool TryToAttachmentAnchor(
56 in BracketCodeReference reference,
57 string? activeFilePath,
58 string? workspaceRoot,
59 out AttachmentAnchor anchor,
60 out string error) =>
61 TryToAttachmentAnchor(
62 reference,
63 activeFilePath,
64 workspaceRoot,
65 solutionPath: null,
66 indexDirectoryRelative: null,
67 out anchor,
68 out error);
69
70 public static bool TryToAttachmentAnchor(
71 in BracketCodeReference reference,
72 string? activeFilePath,
73 string? workspaceRoot,
74 string? solutionPath,
75 string? indexDirectoryRelative,
76 out AttachmentAnchor anchor,
77 out string error)
78 {
79 anchor = new AttachmentAnchor();
80 error = "";
81
82 if (!IntercomMemberFileInference.TryResolveRelativeFile(
83 reference.File,
84 reference.MemberKey,
85 activeFilePath,
86 workspaceRoot,
87 solutionPath,
88 indexDirectoryRelative,
89 out var file,
90 out error))
91 {
92 return false;
93 }
94
95 JsonElement? syntaxScope = null;
96 if (!string.IsNullOrWhiteSpace(reference.ScopeKind))
97 {
98 var index = reference.ScopeIndexInParent is > 0 ? reference.ScopeIndexInParent.Value : 1;
99 var parentMember = string.IsNullOrWhiteSpace(reference.MemberKey) ? null : reference.MemberKey.Trim();
100 syntaxScope = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
101 {
102 ["kind"] = reference.ScopeKind.Trim(),
103 ["indexInParent"] = index,
104 ["parentMemberKey"] = parentMember,
105 });
106 }
107
108 anchor = new AttachmentAnchor
109 {
110 File = file.Replace('\\', '/'),
111 MemberKey = string.IsNullOrWhiteSpace(reference.MemberKey) ? null : reference.MemberKey.Trim(),
112 LineStart = reference.LineStart,
113 LineEnd = reference.LineEnd,
114 SyntaxScope = syntaxScope,
115 };
116
117 return true;
118 }
119
120 private static bool TryParseL2(string text, out BracketCodeReference reference, out string error)
121 {
122 reference = default;
123 error = "";
124
125 string? file = null;
126 string? member = null;
127 int? lineStart = null;
128 int? lineEnd = null;
129 string? scopeKind = null;
130 int? scopeIndex = null;
131
132 foreach (var part in text.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
133 {
134 if (part.Length == 0)
135 continue;
136
137 if (part.StartsWith("F:", StringComparison.OrdinalIgnoreCase))
138 {
139 file = part[2..].Trim();
140 continue;
141 }
142
143 if (part.StartsWith("M:", StringComparison.OrdinalIgnoreCase))
144 {
145 member = part[2..].Trim();
146 continue;
147 }
148
149 if (part.StartsWith("L:", StringComparison.OrdinalIgnoreCase))
150 {
151 if (!TryParseLineRange(part[2..].Trim(), out lineStart, out lineEnd, out error))
152 return false;
153 continue;
154 }
155
156 if (part.StartsWith("S:", StringComparison.OrdinalIgnoreCase))
157 {
158 if (!TryParseScopePayload(part[2..].Trim(), out scopeKind, out scopeIndex, out error))
159 return false;
160 continue;
161 }
162
163 error = $"Неизвестное поле L2: «{part}».";
164 return false;
165 }
166
167 if (string.IsNullOrWhiteSpace(file)
168 && string.IsNullOrWhiteSpace(member)
169 && lineStart is null
170 && string.IsNullOrWhiteSpace(scopeKind))
171 {
172 error = "L2-ссылка не содержит F:, M:, L: или S:.";
173 return false;
174 }
175
176 reference = new BracketCodeReference(file, member, lineStart, lineEnd, scopeKind, scopeIndex);
177 return true;
178 }
179
180 private static bool TryParseH1(string text, out BracketCodeReference reference, out string error)
181 {
182 reference = default;
183 error = "";
184
185 string? file = null;
186 if (FileToken.Match(text) is { Success: true } ff)
187 file = ff.Groups["file"].Value.Trim();
188 else if (CsFileBeforeMember.Match(text) is { Success: true } fm)
189 file = fm.Groups["file"].Value.Trim();
190
191 string? member = null;
192 if (MemberToken.Match(text) is { Success: true } mm)
193 member = mm.Groups["member"].Value.Trim();
194 else if (text.StartsWith("M:", StringComparison.OrdinalIgnoreCase))
195 member = text[2..].Trim();
196
197 string? scopeKind = null;
198 int? scopeIndex = null;
199 if (ScopeToken.Match(text) is { Success: true } sm)
200 {
201 scopeKind = sm.Groups["kind"].Value.Trim();
202 if (!int.TryParse(sm.Groups["index"].Value, out var scopeIdx) || scopeIdx < 1)
203 {
204 error = "Некорректный индекс S: (ожидается ≥ 1).";
205 return false;
206 }
207
208 scopeIndex = scopeIdx;
209 }
210
211 int? lineStart = null;
212 int? lineEnd = null;
213 if (LineToken.Match(text) is { Success: true } lm)
214 {
215 if (!int.TryParse(lm.Groups["start"].Value, out var start) || start < 1)
216 {
217 error = "Некорректный диапазон L:.";
218 return false;
219 }
220
221 lineStart = start;
222 if (lm.Groups["end"].Success && int.TryParse(lm.Groups["end"].Value, out var end) && end >= start)
223 lineEnd = end;
224 else
225 lineEnd = start;
226 }
227
228 if (string.IsNullOrWhiteSpace(file)
229 && string.IsNullOrWhiteSpace(member)
230 && lineStart is null
231 && string.IsNullOrWhiteSpace(scopeKind))
232 {
233 error = "Ожидается [M:…], [M:… S:for:2], [file.cs M:…] или L2 [F:…; M:…; …].";
234 return false;
235 }
236
237 reference = new BracketCodeReference(file, member, lineStart, lineEnd, scopeKind, scopeIndex);
238 return true;
239 }
240
241 private static bool TryParseScopePayload(string payload, out string? kind, out int? indexInParent, out string error)
242 {
243 kind = null;
244 indexInParent = null;
245 error = "";
246 var token = "S:" + payload;
247 if (ScopeToken.Match(token) is not { Success: true } m)
248 {
249 error = "S: ожидает for:2 или if(1) (kind + 1-based index).";
250 return false;
251 }
252
253 kind = m.Groups["kind"].Value.Trim();
254 if (!int.TryParse(m.Groups["index"].Value, out var idx) || idx < 1)
255 {
256 error = "Некорректный индекс S: (ожидается ≥ 1).";
257 return false;
258 }
259
260 indexInParent = idx;
261 return true;
262 }
263
264 private static bool TryParseLineRange(string payload, out int? lineStart, out int? lineEnd, out string error)
265 {
266 lineStart = null;
267 lineEnd = null;
268 error = "";
269
270 var normalized = payload.Replace(':', '-').Replace(' ', '-');
271 var parts = normalized.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
272 if (parts.Length is < 1 or > 2)
273 {
274 error = "L: ожидает «50» или «50-100».";
275 return false;
276 }
277
278 if (!int.TryParse(parts[0], out var start) || start < 1)
279 {
280 error = $"Некорректная строка «{parts[0]}».";
281 return false;
282 }
283
284 lineStart = start;
285 if (parts.Length == 1)
286 {
287 lineEnd = start;
288 return true;
289 }
290
291 if (!int.TryParse(parts[1], out var end) || end < start)
292 {
293 error = $"Некорректный конец диапазона «{parts[1]}».";
294 return false;
295 }
296
297 lineEnd = end;
298 return true;
299 }
300
301 /// <summary>Скан bracket-ссылок только в prose (вне fenced code), ADR 0129 §5.</summary>
302 public static IReadOnlyList<(BracketCodeReference Reference, int LineNumber)> EnumerateInProse(string markdown)
303 {
304 var hits = new List<(BracketCodeReference, int)>();
305 foreach (var prose in MarkdownProseSegments.EnumerateProse(markdown))
306 {
307 var line = 1;
308 var lineStart = 0;
309 for (var i = 0; i < prose.Length; i++)
310 {
311 if (prose[i] == '\n')
312 {
313 line++;
314 lineStart = i + 1;
315 }
316
317 if (prose[i] != '[')
318 continue;
319
320 if (!TryReadBracketSpan(prose, i, out var close))
321 continue;
322
323 var inner = prose.Substring(i + 1, close - i - 1);
324 if (!TryParse(inner, out var reference, out _))
325 continue;
326
327 if (IsMarkdownLinkAfter(prose, close))
328 continue;
329
330 if (string.IsNullOrWhiteSpace(reference.File))
331 continue;
332
333 var lineNumber = line;
334 for (var p = lineStart; p < i; p++)
335 {
336 if (prose[p] == '\n')
337 lineNumber++;
338 }
339
340 hits.Add((reference, lineNumber));
341 i = close;
342 }
343 }
344
345 return hits;
346 }
347
348 internal static bool IsMarkdownLinkAfter(string text, int closeBracketIndex)
349 {
350 var j = closeBracketIndex + 1;
351 while (j < text.Length && char.IsWhiteSpace(text[j]))
352 j++;
353
354 return j < text.Length && text[j] == '(';
355 }
356
357 internal static bool TryReadBracketSpan(string text, int openIndex, out int closeIndex)
358 {
359 closeIndex = -1;
360 var depth = 0;
361 for (var i = openIndex; i < text.Length; i++)
362 {
363 if (text[i] == '[')
364 depth++;
365 else if (text[i] == ']')
366 {
367 depth--;
368 if (depth == 0)
369 {
370 closeIndex = i;
371 return true;
372 }
373 }
374 }
375
376 return false;
377 }
378}
379
380/// <summary>Результат parse bracket до resolve в <see cref="AttachmentAnchor"/>.</summary>
381public readonly record struct BracketCodeReference(
382 string? File,
383 string? MemberKey,
384 int? LineStart,
385 int? LineEnd,
386 string? ScopeKind = null,
387 int? ScopeIndexInParent = null);
388
View only · write via MCP/CIDE