| 1 | using System.Text; |
| 2 | using Microsoft.CodeAnalysis; |
| 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 4 | using Microsoft.CodeAnalysis.MSBuild; |
| 5 | using Microsoft.CodeAnalysis.Text; |
| 6 | |
| 7 | namespace RoslynMcp.ServiceLayer; |
| 8 | |
| 9 | /// <summary>Разрешение места для брейкпоинта: по имени символа (метод, свойство и т.п.) в файле возвращает file:line первой исполняемой строки.</summary> |
| 10 | public static class ResolveBreakpoint |
| 11 | { |
| 12 | private static string NormalizePath(string path) |
| 13 | { |
| 14 | var p = Path.GetFullPath(path.Trim()); |
| 15 | if (p.EndsWith(Path.DirectorySeparatorChar)) |
| 16 | p = p.TrimEnd(Path.DirectorySeparatorChar); |
| 17 | return p; |
| 18 | } |
| 19 | |
| 20 | /// <summary>Возвращает номер строки (1-based) для узла по дереву.</summary> |
| 21 | private static int GetLine(SyntaxNode node, SyntaxTree tree) |
| 22 | { |
| 23 | var lineSpan = tree.GetLineSpan(node.Span); |
| 24 | return lineSpan.StartLinePosition.Line + 1; |
| 25 | } |
| 26 | |
| 27 | /// <summary>Первая исполняемая строка для метода: первый оператор в теле или строка expression body.</summary> |
| 28 | private static SyntaxNode? GetFirstExecutableNode(BaseMethodDeclarationSyntax method) |
| 29 | { |
| 30 | if (method.Body != null) |
| 31 | { |
| 32 | var first = method.Body.Statements.FirstOrDefault(); |
| 33 | if (first != null) return first; |
| 34 | return method.Body.OpenBraceToken.Parent; |
| 35 | } |
| 36 | if (method.ExpressionBody?.Expression != null) |
| 37 | return method.ExpressionBody.Expression; |
| 38 | return null; |
| 39 | } |
| 40 | |
| 41 | private static SyntaxNode? GetFirstExecutableNode(PropertyDeclarationSyntax prop) |
| 42 | { |
| 43 | if (prop.ExpressionBody != null) |
| 44 | return prop.ExpressionBody.Expression; |
| 45 | var accessor = prop.AccessorList?.Accessors.FirstOrDefault(a => a.Body != null); |
| 46 | if (accessor?.Body?.Statements.FirstOrDefault() is { } st) |
| 47 | return st; |
| 48 | if (accessor?.Body != null) |
| 49 | return accessor.Body.OpenBraceToken.Parent; |
| 50 | return null; |
| 51 | } |
| 52 | |
| 53 | private static SyntaxNode? GetFirstExecutableNode(IndexerDeclarationSyntax indexer) |
| 54 | { |
| 55 | if (indexer.ExpressionBody != null) |
| 56 | return indexer.ExpressionBody.Expression; |
| 57 | var accessor = indexer.AccessorList?.Accessors.FirstOrDefault(a => a.Body != null || a.ExpressionBody != null); |
| 58 | if (accessor?.ExpressionBody?.Expression != null) |
| 59 | return accessor.ExpressionBody.Expression; |
| 60 | if (accessor?.Body?.Statements.FirstOrDefault() is { } st) |
| 61 | return st; |
| 62 | if (accessor?.Body != null) |
| 63 | return accessor.Body.OpenBraceToken.Parent; |
| 64 | return null; |
| 65 | } |
| 66 | |
| 67 | public static async Task<string> ResolveAsync( |
| 68 | string solutionOrProjectPath, |
| 69 | string filePath, |
| 70 | string symbolName, |
| 71 | CancellationToken cancellationToken = default) |
| 72 | { |
| 73 | if (!File.Exists(solutionOrProjectPath)) |
| 74 | return $"Error: solution/project not found: {solutionOrProjectPath}"; |
| 75 | if (!File.Exists(filePath)) |
| 76 | return $"Error: file not found: {filePath}"; |
| 77 | if (string.IsNullOrWhiteSpace(symbolName)) |
| 78 | return "Error: symbol_name is required."; |
| 79 | |
| 80 | var targetPath = NormalizePath(filePath); |
| 81 | Solution? solution = null; |
| 82 | try |
| 83 | { |
| 84 | var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild); |
| 85 | solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false); |
| 86 | |
| 87 | if (solution is null) |
| 88 | return "Error: failed to open solution."; |
| 89 | |
| 90 | var document = solution.Projects |
| 91 | .SelectMany(p => p.Documents) |
| 92 | .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase)); |
| 93 | if (document is null) |
| 94 | return $"Error: file not found in solution: {filePath}"; |
| 95 | |
| 96 | var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); |
| 97 | if (root is null) |
| 98 | return "Error: could not get syntax root."; |
| 99 | |
| 100 | var tree = root.SyntaxTree; |
| 101 | var name = symbolName.Trim(); |
| 102 | var results = new List<(string kind, int line)>(); |
| 103 | |
| 104 | foreach (var method in root.DescendantNodes().OfType<MethodDeclarationSyntax>()) |
| 105 | { |
| 106 | cancellationToken.ThrowIfCancellationRequested(); |
| 107 | if (!string.Equals(method.Identifier.ValueText, name, StringComparison.OrdinalIgnoreCase)) |
| 108 | continue; |
| 109 | var node = GetFirstExecutableNode(method); |
| 110 | if (node != null) |
| 111 | results.Add(("method", GetLine(node, tree))); |
| 112 | } |
| 113 | |
| 114 | foreach (var ctor in root.DescendantNodes().OfType<ConstructorDeclarationSyntax>()) |
| 115 | { |
| 116 | cancellationToken.ThrowIfCancellationRequested(); |
| 117 | var ctorName = ctor.Identifier.ValueText; |
| 118 | if (!string.Equals(ctorName, name, StringComparison.OrdinalIgnoreCase)) |
| 119 | continue; |
| 120 | var node = GetFirstExecutableNode(ctor); |
| 121 | if (node != null) |
| 122 | results.Add(("constructor", GetLine(node, tree))); |
| 123 | } |
| 124 | |
| 125 | foreach (var prop in root.DescendantNodes().OfType<PropertyDeclarationSyntax>()) |
| 126 | { |
| 127 | cancellationToken.ThrowIfCancellationRequested(); |
| 128 | if (!string.Equals(prop.Identifier.ValueText, name, StringComparison.OrdinalIgnoreCase)) |
| 129 | continue; |
| 130 | var node = GetFirstExecutableNode(prop); |
| 131 | if (node != null) |
| 132 | results.Add(("property", GetLine(node, tree))); |
| 133 | } |
| 134 | |
| 135 | foreach (var indexer in root.DescendantNodes().OfType<IndexerDeclarationSyntax>()) |
| 136 | { |
| 137 | cancellationToken.ThrowIfCancellationRequested(); |
| 138 | if (!string.Equals("this", name, StringComparison.OrdinalIgnoreCase)) |
| 139 | continue; |
| 140 | var node = GetFirstExecutableNode(indexer); |
| 141 | if (node != null) |
| 142 | results.Add(("indexer", GetLine(node, tree))); |
| 143 | } |
| 144 | |
| 145 | var docPath = document.FilePath ?? filePath; |
| 146 | if (results.Count == 0) |
| 147 | return $"# Resolve breakpoint: no executable location found for symbol \"{name}\" in {docPath}.\n# Searched: methods, constructors, properties, indexers (use \"this\" for indexer)."; |
| 148 | |
| 149 | var sb = new StringBuilder(); |
| 150 | sb.AppendLine("# Breakpoint location(s) — first executable line of symbol"); |
| 151 | sb.AppendLineInvariant($"# Symbol: \"{name}\" in {docPath}"); |
| 152 | sb.AppendLine(); |
| 153 | foreach (var (kind, line) in results.Distinct().OrderBy(t => t.line)) |
| 154 | sb.AppendLineInvariant($"{docPath}:{line}\t({kind})"); |
| 155 | sb.AppendLine().AppendLineInvariant($"Total: {results.Count} location(s). Use file:line to set breakpoint."); |
| 156 | return sb.ToString(); |
| 157 | } |
| 158 | catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx")) |
| 159 | { |
| 160 | return "Error: .slnx format is not supported. Use .sln or .csproj."; |
| 161 | } |
| 162 | finally |
| 163 | { |
| 164 | solution?.Workspace.Dispose(); |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |