Forge
csharp551a3611
1#nullable enable
2using System.Text.Json;
3using System.Text.RegularExpressions;
4using Microsoft.CodeAnalysis;
5using Microsoft.CodeAnalysis.CSharp;
6using Microsoft.CodeAnalysis.CSharp.Syntax;
7using Microsoft.CodeAnalysis.MSBuild;
8using RoslynMcp.ServiceLayer.WorkspaceNavigation;
9
10namespace RoslynMcp.ServiceLayer;
11
12/// <summary>
13/// Упрощённый контекст навигации по solution (эвристики как в Cascade IDE ADR 0039): источник файлов — документы Roslyn solution, без .cascade/workspace.toml.
14/// </summary>
15public static class GetWorkspaceNavigationContext
16{
17 public const int DefaultMaxRelated = 32;
18 public const int DefaultMaxNodes = 12;
19 public const int DefaultMaxEdges = 24;
20
21 private static readonly JsonSerializerOptions s_compactJson = new() { WriteIndented = false };
22
23 private static string NormalizePath(string path)
24 {
25 var p = Path.GetFullPath(path.Trim());
26 if (p.EndsWith(Path.DirectorySeparatorChar))
27 p = p.TrimEnd(Path.DirectorySeparatorChar);
28 return p;
29 }
30
31 public static async Task<string> GetAsync(
32 string solutionOrProjectPath,
33 string filePath,
34 string mode,
35 int? line,
36 int? column,
37 int maxRelated,
38 int maxNodes,
39 int maxEdges,
40 IReadOnlyList<string>? includeKinds,
41 IReadOnlyList<string>? excludeKinds,
42 string? preset,
43 CancellationToken cancellationToken = default)
44 {
45 if (!File.Exists(solutionOrProjectPath))
46 return JsonSerializer.Serialize(new { error = "not_found", message = $"solution/project not found: {solutionOrProjectPath}" }, s_compactJson);
47
48 var (mergedInc, mergedExc, presetErr) = WorkspaceNavigationPresetMerge.Merge(
49 preset,
50 BundledWorkspaceNavigationPresets.Json,
51 includeKinds,
52 excludeKinds);
53 if (presetErr is not null)
54 return JsonSerializer.Serialize(new { error = "bad_preset", message = presetErr, preset }, s_compactJson);
55
56 var kindFilter = WorkspaceNavigationKindFilter.Create(mergedInc, mergedExc);
57 string anchor;
58 try
59 {
60 anchor = NormalizePath(filePath);
61 }
62 catch
63 {
64 return JsonSerializer.Serialize(new { error = "bad_path", message = filePath }, s_compactJson);
65 }
66
67 if (!File.Exists(anchor))
68 return JsonSerializer.Serialize(new { error = "not_found", message = $"file not found: {anchor}" }, s_compactJson);
69
70 Solution? solution = null;
71 try
72 {
73 var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild);
74 solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false);
75 }
76 catch (Exception ex)
77 {
78 return JsonSerializer.Serialize(new { error = "open_failed", message = ex.Message }, s_compactJson);
79 }
80
81 if (solution is null)
82 return JsonSerializer.Serialize(new { error = "open_failed", message = "failed to open solution." }, s_compactJson);
83
84 var allKnownFiles = CollectKnownFilePaths(solution);
85 var known = new HashSet<string>(allKnownFiles, StringComparer.OrdinalIgnoreCase);
86 if (!known.Contains(anchor))
87 return JsonSerializer.Serialize(new { error = "file_not_in_solution", message = "Файл не входит в загруженное solution/project.", path = anchor }, s_compactJson);
88
89 var navFiles = allKnownFiles.Where(f => !WorkspaceNavigationPathHelpers.IsBuildArtifactPath(f)).ToList();
90 var allCs = navFiles.Where(f => f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)).ToList();
91 if (allCs.Count == 0)
92 return JsonSerializer.Serialize(new { error = "no_solution_files", message = "Нет .cs в solution/project." }, s_compactJson);
93
94 var markupPaths = navFiles
95 .Where(f => f.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase))
96 .ToList();
97
98 var m = mode.Trim().ToLowerInvariant();
99 // Для relative_path: каталог .sln или каталог .csproj (как корень обзора).
100 var solutionPathForRelative = solutionOrProjectPath;
101 return m switch
102 {
103 "related" => BuildRelated(anchor, allCs, navFiles, markupPaths, solutionPathForRelative, kindFilter, preset, maxRelated, line, column),
104 "subgraph" => BuildSubgraph(anchor, allCs, navFiles, markupPaths, solutionPathForRelative, kindFilter, preset, maxNodes, maxEdges, line, column),
105 _ => JsonSerializer.Serialize(new { error = "bad_mode", message = "mode: related | subgraph", mode }, s_compactJson)
106 };
107 }
108
109 private static List<string> CollectKnownFilePaths(Solution solution)
110 {
111 var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
112 foreach (var project in solution.Projects)
113 {
114 foreach (var doc in project.Documents)
115 TryAdd(doc.FilePath);
116 foreach (var doc in project.AdditionalDocuments)
117 TryAdd(doc.FilePath);
118 }
119
120 void TryAdd(string? fp)
121 {
122 if (string.IsNullOrEmpty(fp))
123 return;
124 try
125 {
126 set.Add(Path.GetFullPath(fp));
127 }
128 catch
129 {
130 // skip
131 }
132 }
133
134 return set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
135 }
136
137 private static string BuildRelated(
138 string anchor,
139 IReadOnlyList<string> allCs,
140 IReadOnlyList<string> allKnownFiles,
141 IReadOnlyList<string> markupPaths,
142 string? solutionPath,
143 WorkspaceNavigationKindFilter kindFilter,
144 string? presetRequested,
145 int maxRelated,
146 int? line,
147 int? column)
148 {
149 var owningCache = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
150 string? Owning(string path)
151 {
152 if (!owningCache.TryGetValue(path, out var o))
153 {
154 o = WorkspaceNavigationPathHelpers.ResolveOwningProjectPath(path);
155 owningCache[path] = o;
156 }
157 return o;
158 }
159
160 var items = new List<object>();
161 var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(anchor) };
162
163 void AddIfNew(string path, string kind, string rationale)
164 {
165 if (!kindFilter.Allows(kind))
166 return;
167 if (items.Count >= maxRelated)
168 return;
169 string full;
170 try
171 {
172 full = Path.GetFullPath(path);
173 }
174 catch
175 {
176 return;
177 }
178
179 if (seen.Contains(full))
180 return;
181 seen.Add(full);
182 items.Add(new
183 {
184 path = full,
185 kind,
186 rationale,
187 relative_path = WorkspaceNavigationPathHelpers.GetRelativePath(solutionPath, full)
188 });
189 }
190
191 var anchorIsCs = anchor.EndsWith(".cs", StringComparison.OrdinalIgnoreCase);
192
193 if (anchorIsCs)
194 {
195 foreach (var name in EnumeratePartialTypeNames(anchor))
196 {
197 foreach (var peer in FindPartialPeers(allCs, anchor, name))
198 {
199 if (items.Count >= maxRelated)
200 goto AfterPartial;
201 AddIfNew(peer, "partial_peer", $"Partial того же типа «{name}»");
202 }
203 }
204 }
205
206 AfterPartial:
207
208 var anchorProj = Owning(anchor);
209 if (items.Count < maxRelated && !string.IsNullOrEmpty(anchorProj))
210 {
211 foreach (var f in allCs
212 .Where(f => !string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
213 .OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
214 {
215 if (items.Count >= maxRelated)
216 break;
217 var fp = Owning(f);
218 if (!string.IsNullOrEmpty(fp) && string.Equals(fp, anchorProj, StringComparison.OrdinalIgnoreCase))
219 AddIfNew(f, "project_peer", "Тот же проект");
220 }
221 }
222
223 if (items.Count < maxRelated)
224 {
225 foreach (var p in FindXamlCodeBehindPairs(anchor, allCs, markupPaths))
226 {
227 if (items.Count >= maxRelated)
228 break;
229 AddIfNew(p.path, "xaml_codebehind_pair", p.rationale);
230 }
231 }
232
233 if (items.Count < maxRelated && anchorIsCs)
234 {
235 foreach (var p in FindTestCounterparts(anchor, allCs))
236 {
237 if (items.Count >= maxRelated)
238 break;
239 AddIfNew(p.path, "test_counterpart", p.rationale);
240 }
241 }
242
243 if (items.Count < maxRelated && anchorIsCs)
244 {
245 var anchorNs = ExtractNamespaces(anchor);
246 if (anchorNs.Count > 0)
247 {
248 foreach (var f in allCs
249 .Where(f => !string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
250 .OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
251 {
252 if (items.Count >= maxRelated)
253 break;
254 var ns = ExtractNamespaces(f);
255 if (!anchorNs.Overlaps(ns))
256 continue;
257 var overlap = anchorNs.Intersect(ns, StringComparer.Ordinal).FirstOrDefault();
258 if (overlap is null)
259 continue;
260 AddIfNew(f, "same_namespace", $"Тот же namespace «{overlap}»");
261 }
262 }
263 }
264
265 if (items.Count < maxRelated)
266 {
267 var dir = Path.GetDirectoryName(anchor);
268 if (!string.IsNullOrEmpty(dir))
269 {
270 foreach (var p in allKnownFiles
271 .Where(f => string.Equals(Path.GetDirectoryName(f), dir, StringComparison.OrdinalIgnoreCase))
272 .OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
273 {
274 if (items.Count >= maxRelated)
275 break;
276 AddIfNew(p, "same_directory", "Тот же каталог");
277 }
278 }
279 }
280
281 var kindFilterPayload = new
282 {
283 preset = presetRequested,
284 include_kinds_effective = kindFilter.EffectiveIncludeKinds,
285 exclude_kinds_effective = kindFilter.EffectiveExcludeKinds
286 };
287
288 var payload = new
289 {
290 mode = "related",
291 anchor_path = anchor,
292 line,
293 column,
294 max_related = maxRelated,
295 kind_filter = kindFilterPayload,
296 items
297 };
298 return JsonSerializer.Serialize(payload, s_compactJson);
299 }
300
301 private static string BuildSubgraph(
302 string anchor,
303 IReadOnlyList<string> allCs,
304 IReadOnlyList<string> allKnownFiles,
305 IReadOnlyList<string> markupPaths,
306 string? solutionPath,
307 WorkspaceNavigationKindFilter kindFilter,
308 string? presetRequested,
309 int maxNodes,
310 int maxEdges,
311 int? line,
312 int? column)
313 {
314 var relatedJson = BuildRelated(anchor, allCs, allKnownFiles, markupPaths, solutionPath, kindFilter, presetRequested, Math.Max(maxNodes * 2, DefaultMaxRelated), line, column);
315 using var doc = JsonDocument.Parse(relatedJson);
316 if (doc.RootElement.TryGetProperty("error", out _))
317 return relatedJson;
318
319 var kindFilterPayload = new
320 {
321 preset = presetRequested,
322 include_kinds_effective = kindFilter.EffectiveIncludeKinds,
323 exclude_kinds_effective = kindFilter.EffectiveExcludeKinds
324 };
325
326 var items = doc.RootElement.GetProperty("items").EnumerateArray().ToList();
327 var nodes = new List<object>
328 {
329 new { id = "n0", path = anchor, kind = "anchor", label = Path.GetFileName(anchor), relative_path = WorkspaceNavigationPathHelpers.GetRelativePath(solutionPath, anchor) }
330 };
331 var edges = new List<object>();
332 var idByPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [Path.GetFullPath(anchor)] = "n0" };
333 var n = 1;
334 foreach (var el in items)
335 {
336 if (nodes.Count >= maxNodes)
337 break;
338 if (!el.TryGetProperty("path", out var pathEl))
339 continue;
340 var p = pathEl.GetString();
341 if (string.IsNullOrEmpty(p))
342 continue;
343 var full = Path.GetFullPath(p);
344 if (idByPath.ContainsKey(full))
345 continue;
346 var id = $"n{n++}";
347 idByPath[full] = id;
348 var relatedKind = el.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() : null;
349 var semanticKind = string.IsNullOrEmpty(relatedKind) ? "related" : relatedKind!;
350 nodes.Add(new
351 {
352 id,
353 path = full,
354 kind = semanticKind,
355 label = Path.GetFileName(full),
356 relative_path = WorkspaceNavigationPathHelpers.GetRelativePath(solutionPath, full),
357 rationale = el.TryGetProperty("rationale", out var r) ? r.GetString() : null
358 });
359 if (edges.Count < maxEdges)
360 edges.Add(new { from_id = "n0", to_id = id, kind = "related_to", related_kind = relatedKind });
361 }
362
363 return JsonSerializer.Serialize(new
364 {
365 mode = "subgraph",
366 anchor_path = anchor,
367 line,
368 column,
369 max_nodes = maxNodes,
370 max_edges = maxEdges,
371 kind_filter = kindFilterPayload,
372 nodes,
373 edges
374 }, s_compactJson);
375 }
376
377 private static IEnumerable<(string path, string rationale)> FindXamlCodeBehindPairs(
378 string anchor,
379 IReadOnlyList<string> allCs,
380 IReadOnlyList<string> markupPaths)
381 {
382 var name = Path.GetFileName(anchor);
383 if (name.EndsWith(".axaml.cs", StringComparison.OrdinalIgnoreCase))
384 {
385 var baseName = name[..^".axaml.cs".Length];
386 var want = baseName + ".axaml";
387 foreach (var m in markupPaths)
388 {
389 if (string.Equals(Path.GetFileName(m), want, StringComparison.OrdinalIgnoreCase))
390 yield return (m, "Разметка Avalonia (.axaml) для этого code-behind");
391 }
392
393 yield break;
394 }
395
396 if (name.EndsWith(".xaml.cs", StringComparison.OrdinalIgnoreCase))
397 {
398 var baseName = name[..^".xaml.cs".Length];
399 var want = baseName + ".xaml";
400 foreach (var m in markupPaths)
401 {
402 if (string.Equals(Path.GetFileName(m), want, StringComparison.OrdinalIgnoreCase))
403 yield return (m, "Разметка WPF (.xaml) для этого code-behind");
404 }
405
406 yield break;
407 }
408
409 if (name.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) && !name.EndsWith(".axaml.cs", StringComparison.OrdinalIgnoreCase))
410 {
411 var stem = Path.GetFileNameWithoutExtension(anchor);
412 var want = stem + ".axaml.cs";
413 foreach (var c in allCs)
414 {
415 if (string.Equals(Path.GetFileName(c), want, StringComparison.OrdinalIgnoreCase))
416 yield return (c, "Code-behind (.axaml.cs) для этой разметки");
417 }
418 }
419 else if (name.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase) && !name.EndsWith(".xaml.cs", StringComparison.OrdinalIgnoreCase))
420 {
421 var stem = Path.GetFileNameWithoutExtension(anchor);
422 var want = stem + ".xaml.cs";
423 foreach (var c in allCs)
424 {
425 if (string.Equals(Path.GetFileName(c), want, StringComparison.OrdinalIgnoreCase))
426 yield return (c, "Code-behind (.xaml.cs) для этой разметки");
427 }
428 }
429 }
430
431 private static IEnumerable<(string path, string rationale)> FindTestCounterparts(string anchor, IReadOnlyList<string> allCs)
432 {
433 var stem = Path.GetFileNameWithoutExtension(anchor);
434 if (stem.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) && stem.Length > "Tests".Length)
435 {
436 var baseName = stem[..^"Tests".Length];
437 foreach (var f in allCs)
438 {
439 if (string.Equals(Path.GetFileNameWithoutExtension(f), baseName, StringComparison.OrdinalIgnoreCase)
440 && !string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
441 yield return (f, "Исходный файл для тестового типа (*Tests)");
442 }
443
444 yield break;
445 }
446
447 if (stem.EndsWith("Test", StringComparison.OrdinalIgnoreCase)
448 && !stem.EndsWith("Tests", StringComparison.OrdinalIgnoreCase)
449 && stem.Length > "Test".Length)
450 {
451 var baseName = stem[..^"Test".Length];
452 foreach (var f in allCs)
453 {
454 if (string.Equals(Path.GetFileNameWithoutExtension(f), baseName, StringComparison.OrdinalIgnoreCase)
455 && !string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
456 yield return (f, "Исходный файл для тестового типа (*Test)");
457 }
458
459 yield break;
460 }
461
462 foreach (var suffix in new[] { "Tests", "Test" })
463 {
464 var wantFile = stem + suffix + ".cs";
465 foreach (var f in allCs)
466 {
467 if (!string.Equals(Path.GetFileName(f), wantFile, StringComparison.OrdinalIgnoreCase))
468 continue;
469 if (string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
470 continue;
471 yield return (f, suffix == "Tests" ? "Тесты (*Tests.cs) для этого типа" : "Тесты (*Test.cs) для этого типа");
472 }
473 }
474 }
475
476 private static HashSet<string> ExtractNamespaces(string path)
477 {
478 try
479 {
480 var text = File.ReadAllText(path);
481 if (text.Length > 12000)
482 text = text[..12000];
483 var tree = CSharpSyntaxTree.ParseText(text, path: path);
484 var root = tree.GetRoot();
485 var set = new HashSet<string>(StringComparer.Ordinal);
486 foreach (var ns in root.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>())
487 set.Add(ns.Name.ToString());
488 return set;
489 }
490 catch
491 {
492 return [];
493 }
494 }
495
496 private static List<string> EnumeratePartialTypeNames(string anchorPath)
497 {
498 string text;
499 try
500 {
501 text = File.ReadAllText(anchorPath);
502 }
503 catch
504 {
505 return [];
506 }
507
508 SyntaxTree tree;
509 try
510 {
511 tree = CSharpSyntaxTree.ParseText(text, path: anchorPath);
512 }
513 catch
514 {
515 return [];
516 }
517
518 var root = tree.GetRoot();
519 return root.DescendantNodes().OfType<TypeDeclarationSyntax>()
520 .Where(t => t.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
521 .Select(t => t.Identifier.Text)
522 .Distinct()
523 .ToList();
524 }
525
526 private static List<string> FindPartialPeers(IReadOnlyList<string> allCs, string anchor, string typeName)
527 {
528 var rx = new Regex($@"\bpartial\s+(?:class|struct|record)\s+{Regex.Escape(typeName)}\b", RegexOptions.CultureInvariant);
529 var list = new List<string>();
530 foreach (var f in allCs)
531 {
532 if (string.Equals(Path.GetFullPath(f), Path.GetFullPath(anchor), StringComparison.OrdinalIgnoreCase))
533 continue;
534 try
535 {
536 var head = ReadHead(File.ReadAllText(f));
537 if (rx.IsMatch(head))
538 list.Add(f);
539 }
540 catch
541 {
542 // skip
543 }
544 }
545 return list;
546 }
547
548 private static string ReadHead(string text)
549 {
550 if (text.Length <= 12000)
551 return text;
552 return text[..12000];
553 }
554}
555
View only · write via MCP/CIDE