| 1 | using Microsoft.CodeAnalysis; |
| 2 | using Microsoft.CodeAnalysis.CSharp; |
| 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 4 | using Microsoft.CodeAnalysis.MSBuild; |
| 5 | using Microsoft.CodeAnalysis.Text; |
| 6 | |
| 7 | namespace RoslynMcp.ServiceLayer; |
| 8 | |
| 9 | /// <summary>Символ в позиции (строка, столбец). Без solution — по синтаксису; с solution_or_project_path — семантика и квалифицированное имя. 1-based.</summary> |
| 10 | public static class SymbolAtPosition |
| 11 | { |
| 12 | /// <summary>Семантический вариант: загружает solution, возвращает kind, name, location и Qualified (ToDisplayString).</summary> |
| 13 | public static async Task<string> GetSymbolAtPositionAsync( |
| 14 | string filePath, |
| 15 | int line, |
| 16 | int column, |
| 17 | string? solutionOrProjectPath, |
| 18 | CancellationToken cancellationToken = default) |
| 19 | { |
| 20 | if (!string.IsNullOrWhiteSpace(solutionOrProjectPath) && File.Exists(solutionOrProjectPath)) |
| 21 | { |
| 22 | var result = await GetSymbolWithSemanticAsync(filePath, line, column, solutionOrProjectPath!.Trim(), cancellationToken).ConfigureAwait(false); |
| 23 | if (result is not null) |
| 24 | return result; |
| 25 | } |
| 26 | return GetSymbolAtPosition(filePath, line, column, cancellationToken); |
| 27 | } |
| 28 | |
| 29 | private static async Task<string?> GetSymbolWithSemanticAsync(string filePath, int line, int column, string solutionOrProjectPath, CancellationToken ct) |
| 30 | { |
| 31 | var targetPath = NormalizePath(filePath); |
| 32 | Solution? solution = null; |
| 33 | try |
| 34 | { |
| 35 | var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild); |
| 36 | solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, ct).ConfigureAwait(false); |
| 37 | if (solution is null) return null; |
| 38 | |
| 39 | var document = solution.Projects |
| 40 | .SelectMany(p => p.Documents) |
| 41 | .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase)); |
| 42 | if (document is null) return null; |
| 43 | |
| 44 | var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); |
| 45 | var semanticModel = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); |
| 46 | if (root is null || semanticModel is null) return null; |
| 47 | |
| 48 | var sourceText = await document.GetTextAsync(ct).ConfigureAwait(false); |
| 49 | var lines = sourceText.Lines; |
| 50 | if (line < 1 || line > lines.Count) return null; |
| 51 | var lineInfo = lines[line - 1]; |
| 52 | var columnIndex = column - 1; |
| 53 | if (columnIndex < 0) return null; |
| 54 | var lineLen = lineInfo.Span.Length; |
| 55 | var position = lineLen == 0 ? lineInfo.Start : lineInfo.Start + Math.Min(columnIndex, lineLen); |
| 56 | |
| 57 | var node = root.FindToken(position, findInsideTrivia: true).Parent; |
| 58 | ISymbol? symbol = null; |
| 59 | while (node != null) |
| 60 | { |
| 61 | ct.ThrowIfCancellationRequested(); |
| 62 | symbol = semanticModel.GetDeclaredSymbol(node, ct) ?? semanticModel.GetSymbolInfo(node, ct).Symbol; |
| 63 | if (symbol != null) break; |
| 64 | node = node.Parent; |
| 65 | } |
| 66 | if (symbol is null) return null; |
| 67 | |
| 68 | var kind = symbol.Kind.ToString().ToLowerInvariant(); |
| 69 | var name = symbol.Name; |
| 70 | var format = new SymbolDisplayFormat( |
| 71 | typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, |
| 72 | memberOptions: SymbolDisplayMemberOptions.IncludeContainingType); |
| 73 | var qualified = symbol.ToDisplayString(format); |
| 74 | var docPath = document.FilePath ?? filePath; |
| 75 | return $"# Document: {docPath} (total_lines={lines.Count}, line_{line}_length={lineLen})\n{kind}\t{name}\t{filePath}:{line}:{column}\tQualified: {qualified}"; |
| 76 | } |
| 77 | catch (InvalidOperationException) { return null; } |
| 78 | finally { solution?.Workspace.Dispose(); } |
| 79 | } |
| 80 | |
| 81 | private static string NormalizePath(string path) |
| 82 | { |
| 83 | var p = Path.GetFullPath(path.Trim()); |
| 84 | if (p.EndsWith(Path.DirectorySeparatorChar)) p = p.TrimEnd(Path.DirectorySeparatorChar); |
| 85 | return p; |
| 86 | } |
| 87 | |
| 88 | public static string GetSymbolAtPosition(string filePath, int line, int column, CancellationToken cancellationToken = default) |
| 89 | { |
| 90 | if (!File.Exists(filePath)) |
| 91 | return $"Error: file not found: {filePath}"; |
| 92 | |
| 93 | var sourceText = SourceText.From(File.ReadAllText(filePath)); |
| 94 | var tree = CSharpSyntaxTree.ParseText(sourceText, cancellationToken: cancellationToken); |
| 95 | var root = tree.GetRoot(cancellationToken); |
| 96 | var lines = sourceText.Lines; |
| 97 | |
| 98 | if (line < 1 || line > lines.Count) |
| 99 | return $"Error: line {line} out of range (1..{lines.Count})."; |
| 100 | var lineInfo = lines[line - 1]; |
| 101 | var columnIndex = column - 1; |
| 102 | if (columnIndex < 0) |
| 103 | return $"Error: column {column} must be >= 1."; |
| 104 | var lineLen = lineInfo.Span.Length; |
| 105 | var position = lineLen == 0 ? lineInfo.Start : lineInfo.Start + Math.Min(columnIndex, lineLen); |
| 106 | |
| 107 | var token = root.FindToken(position, findInsideTrivia: true); |
| 108 | var node = token.Parent; |
| 109 | while (node != null) |
| 110 | { |
| 111 | cancellationToken.ThrowIfCancellationRequested(); |
| 112 | var (kind, name) = GetKindAndName(node); |
| 113 | if (kind != null && name != null) |
| 114 | return $"{kind}\t{name}\t{filePath}:{line}:{column}"; |
| 115 | node = node.Parent; |
| 116 | } |
| 117 | |
| 118 | return $"No symbol at {filePath}:{line}:{column} (token: {token.Kind()})."; |
| 119 | } |
| 120 | |
| 121 | private static (string? kind, string? name) GetKindAndName(SyntaxNode node) => node switch |
| 122 | { |
| 123 | NamespaceDeclarationSyntax n => ("namespace", n.Name.ToString()), |
| 124 | FileScopedNamespaceDeclarationSyntax n => ("namespace", n.Name.ToString()), |
| 125 | ClassDeclarationSyntax n => ("class", n.Identifier.Text), |
| 126 | StructDeclarationSyntax n => ("struct", n.Identifier.Text), |
| 127 | InterfaceDeclarationSyntax n => ("interface", n.Identifier.Text), |
| 128 | RecordDeclarationSyntax n => ("record", n.Identifier.Text), |
| 129 | MethodDeclarationSyntax n => ("method", n.Identifier.Text), |
| 130 | ConstructorDeclarationSyntax n => ("constructor", n.Identifier.Text), |
| 131 | PropertyDeclarationSyntax n => ("property", n.Identifier.Text), |
| 132 | FieldDeclarationSyntax n => ("field", n.Declaration.Variables.FirstOrDefault()?.Identifier.Text ?? ""), |
| 133 | EventDeclarationSyntax n => ("event", n.Identifier.Text), |
| 134 | EnumDeclarationSyntax n => ("enum", n.Identifier.Text), |
| 135 | DelegateDeclarationSyntax n => ("delegate", n.Identifier.Text), |
| 136 | VariableDeclaratorSyntax n => ("local", n.Identifier.Text), |
| 137 | ParameterSyntax n => ("parameter", n.Identifier.Text), |
| 138 | TypeParameterSyntax n => ("type_parameter", n.Identifier.Text), |
| 139 | _ => (null, null) |
| 140 | }; |
| 141 | } |
| 142 | |