| 1 | using System.Collections.Immutable; |
| 2 | using System.Globalization; |
| 3 | using System.Text; |
| 4 | using System.Xml.Linq; |
| 5 | using Microsoft.CodeAnalysis; |
| 6 | using Microsoft.CodeAnalysis.Diagnostics; |
| 7 | using Microsoft.CodeAnalysis.Host; |
| 8 | using Microsoft.CodeAnalysis.MSBuild; |
| 9 | using Microsoft.CodeAnalysis.Text; |
| 10 | |
| 11 | namespace RoslynMcp.ServiceLayer; |
| 12 | |
| 13 | /// <summary>Диагностики компиляции (ошибки, предупреждения) по solution/project. Опционально — только по одному файлу.</summary> |
| 14 | public static class GetDiagnostics |
| 15 | { |
| 16 | private static string NormalizePath(string path) |
| 17 | { |
| 18 | var p = Path.GetFullPath(path.Trim()); |
| 19 | if (p.EndsWith(Path.DirectorySeparatorChar)) |
| 20 | p = p.TrimEnd(Path.DirectorySeparatorChar); |
| 21 | return p; |
| 22 | } |
| 23 | |
| 24 | /// <summary>Исключаем артефакты сборки (obj/, bin/), чтобы не засорять вывод.</summary> |
| 25 | private static bool IsBuildArtifactPath(string normalizedPath) |
| 26 | { |
| 27 | return normalizedPath.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal) |
| 28 | || normalizedPath.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal) |
| 29 | || normalizedPath.Contains("/obj/", StringComparison.Ordinal) |
| 30 | || normalizedPath.Contains("/bin/", StringComparison.Ordinal); |
| 31 | } |
| 32 | |
| 33 | /// <summary> |
| 34 | /// Запускает source generators и возвращает <see cref="Compilation"/>. |
| 35 | /// После <see cref="Project.GetSourceGeneratedDocumentsAsync"/> обновляется <see cref="Workspace.CurrentSolution"/>; |
| 36 | /// старый снимок <see cref="Project"/> остаётся без сгенерированных синтаксических деревьев — повторно берём проект из workspace. |
| 37 | /// </summary> |
| 38 | private static async Task<Compilation?> GetCompilationAfterSourceGeneratorsAsync( |
| 39 | Workspace workspace, |
| 40 | ProjectId projectId, |
| 41 | CancellationToken cancellationToken) |
| 42 | { |
| 43 | var project = workspace.CurrentSolution.GetProject(projectId); |
| 44 | if (project is null) |
| 45 | return null; |
| 46 | await project.GetSourceGeneratedDocumentsAsync(cancellationToken).ConfigureAwait(false); |
| 47 | project = workspace.CurrentSolution.GetProject(projectId); |
| 48 | if (project is null) |
| 49 | return null; |
| 50 | return await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); |
| 51 | } |
| 52 | |
| 53 | private static (DiagnosticSeverity effective, bool isSuppressed) GetEffectiveSeverity(Diagnostic d, CompilationOptions options) |
| 54 | { |
| 55 | var report = options.SpecificDiagnosticOptions.GetValueOrDefault(d.Id, ReportDiagnostic.Default); |
| 56 | if (report == ReportDiagnostic.Suppress) |
| 57 | return (default, true); |
| 58 | |
| 59 | var severity = report switch |
| 60 | { |
| 61 | ReportDiagnostic.Error => DiagnosticSeverity.Error, |
| 62 | ReportDiagnostic.Warn => DiagnosticSeverity.Warning, |
| 63 | ReportDiagnostic.Info => DiagnosticSeverity.Info, |
| 64 | ReportDiagnostic.Hidden => DiagnosticSeverity.Hidden, |
| 65 | _ => d.Severity // Default → оставляем дефолтную серьёзность диагностики |
| 66 | }; |
| 67 | |
| 68 | // Treat warnings as errors (GeneralDiagnosticOption) |
| 69 | if (options.GeneralDiagnosticOption == ReportDiagnostic.Error && severity == DiagnosticSeverity.Warning) |
| 70 | severity = DiagnosticSeverity.Error; |
| 71 | |
| 72 | return (severity, false); |
| 73 | } |
| 74 | |
| 75 | /// <summary>Парсит .slnx: возвращает полные пути к .csproj (относительные пути разрешаются от каталога slnx).</summary> |
| 76 | private static List<string> GetProjectPathsFromSlnx(string slnxPath) |
| 77 | { |
| 78 | var dir = Path.GetDirectoryName(slnxPath) ?? ""; |
| 79 | var xml = XDocument.Load(slnxPath); |
| 80 | var projects = xml.Root? |
| 81 | .Elements("Project") |
| 82 | .Select(e => (string?)e.Attribute("Path")) |
| 83 | .Where(p => !string.IsNullOrWhiteSpace(p)) |
| 84 | .Select(p => Path.GetFullPath(Path.Combine(dir, p!.Trim()))) |
| 85 | .Where(File.Exists) |
| 86 | .ToList() ?? []; |
| 87 | return projects; |
| 88 | } |
| 89 | |
| 90 | private static async Task<(int totalAfterPath, int excludedSeverityNone, int excludedSuppress)> CollectDiagnosticsFromSolution( |
| 91 | Workspace workspace, |
| 92 | Solution solution, |
| 93 | string? targetPath, |
| 94 | List<(Diagnostic d, DiagnosticSeverity effectiveSeverity)> allDiagnostics, |
| 95 | CancellationToken cancellationToken) |
| 96 | { |
| 97 | var totalAfterPath = 0; |
| 98 | var excludedSeverityNone = 0; |
| 99 | var excludedSuppress = 0; |
| 100 | foreach (var projectId in solution.ProjectIds) |
| 101 | { |
| 102 | cancellationToken.ThrowIfCancellationRequested(); |
| 103 | var projectForPaths = workspace.CurrentSolution.GetProject(projectId); |
| 104 | if (projectForPaths is null) |
| 105 | continue; |
| 106 | var projectDir = Path.GetDirectoryName(projectForPaths.FilePath) ?? ""; |
| 107 | var severityNoneIds = EditorConfigStyle.GetDiagnosticIdsSeverityNone(projectDir); |
| 108 | |
| 109 | var comp = await GetCompilationAfterSourceGeneratorsAsync(workspace, projectId, cancellationToken).ConfigureAwait(false); |
| 110 | if (comp is null) |
| 111 | continue; |
| 112 | var compOptions = comp.Options; |
| 113 | |
| 114 | foreach (var d in comp.GetDiagnostics(cancellationToken)) |
| 115 | { |
| 116 | if (d.Location.SourceTree?.FilePath is null) continue; |
| 117 | var treePath = NormalizePath(d.Location.SourceTree.FilePath); |
| 118 | if (IsBuildArtifactPath(treePath)) continue; |
| 119 | if (targetPath is not null && !string.Equals(treePath, targetPath, StringComparison.OrdinalIgnoreCase)) |
| 120 | continue; |
| 121 | totalAfterPath++; |
| 122 | if (severityNoneIds.Contains(d.Id)) { excludedSeverityNone++; continue; } |
| 123 | var (effective, isSuppressed) = GetEffectiveSeverity(d, compOptions); |
| 124 | if (isSuppressed) { excludedSuppress++; continue; } |
| 125 | allDiagnostics.Add((d, effective)); |
| 126 | } |
| 127 | |
| 128 | var project = workspace.CurrentSolution.GetProject(projectId); |
| 129 | if (project is null) |
| 130 | continue; |
| 131 | var analyzers = project.AnalyzerReferences |
| 132 | .SelectMany(r => r.GetAnalyzers(project.Language)) |
| 133 | .ToImmutableArray(); |
| 134 | if (!analyzers.IsEmpty) |
| 135 | { |
| 136 | var analyzerOptions = new AnalyzerOptions(ImmutableArray<AdditionalText>.Empty); |
| 137 | var options = new CompilationWithAnalyzersOptions( |
| 138 | analyzerOptions, |
| 139 | onAnalyzerException: (_, _, _) => { }, |
| 140 | concurrentAnalysis: true, |
| 141 | logAnalyzerExecutionTime: false, |
| 142 | reportSuppressedDiagnostics: false); |
| 143 | var cwa = new CompilationWithAnalyzers(comp, analyzers, options); |
| 144 | var result = await cwa.GetAnalysisResultAsync(cancellationToken).ConfigureAwait(false); |
| 145 | foreach (var d in result.GetAllDiagnostics()) |
| 146 | { |
| 147 | if (d.Location.SourceTree?.FilePath is null) continue; |
| 148 | var treePath = NormalizePath(d.Location.SourceTree.FilePath); |
| 149 | if (IsBuildArtifactPath(treePath)) continue; |
| 150 | if (targetPath is not null && !string.Equals(treePath, targetPath, StringComparison.OrdinalIgnoreCase)) |
| 151 | continue; |
| 152 | totalAfterPath++; |
| 153 | if (severityNoneIds.Contains(d.Id)) { excludedSeverityNone++; continue; } |
| 154 | var (effective, isSuppressed) = GetEffectiveSeverity(d, compOptions); |
| 155 | if (isSuppressed) { excludedSuppress++; continue; } |
| 156 | allDiagnostics.Add((d, effective)); |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | return (totalAfterPath, excludedSeverityNone, excludedSuppress); |
| 161 | } |
| 162 | |
| 163 | public static async Task<string> GetDiagnosticsAsync( |
| 164 | string solutionOrProjectPath, |
| 165 | string? filePath, |
| 166 | CancellationToken cancellationToken = default) |
| 167 | { |
| 168 | if (!File.Exists(solutionOrProjectPath)) |
| 169 | return $"Error: solution/project not found: {solutionOrProjectPath}"; |
| 170 | |
| 171 | string? targetPath = null; |
| 172 | if (!string.IsNullOrWhiteSpace(filePath)) |
| 173 | { |
| 174 | if (!File.Exists(filePath)) |
| 175 | return $"Error: file not found: {filePath}"; |
| 176 | targetPath = NormalizePath(filePath); |
| 177 | } |
| 178 | |
| 179 | var allDiagnostics = new List<(Diagnostic d, DiagnosticSeverity effectiveSeverity)>(); |
| 180 | var totalAfterPath = 0; |
| 181 | var excludedSeverityNone = 0; |
| 182 | var excludedSuppress = 0; |
| 183 | |
| 184 | var ext = Path.GetExtension(solutionOrProjectPath); |
| 185 | if (string.Equals(ext, ".slnx", StringComparison.OrdinalIgnoreCase)) |
| 186 | { |
| 187 | var projectPaths = GetProjectPathsFromSlnx(solutionOrProjectPath); |
| 188 | if (projectPaths.Count == 0) |
| 189 | return "Error: .slnx contains no valid project paths or files not found."; |
| 190 | |
| 191 | foreach (var projectPath in projectPaths) |
| 192 | { |
| 193 | cancellationToken.ThrowIfCancellationRequested(); |
| 194 | var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild); |
| 195 | try |
| 196 | { |
| 197 | var solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, projectPath, cancellationToken).ConfigureAwait(false); |
| 198 | if (solution is not null) |
| 199 | { |
| 200 | var (t, n, s) = await CollectDiagnosticsFromSolution(workspace, solution, targetPath, allDiagnostics, cancellationToken).ConfigureAwait(false); |
| 201 | totalAfterPath += t; |
| 202 | excludedSeverityNone += n; |
| 203 | excludedSuppress += s; |
| 204 | } |
| 205 | } |
| 206 | finally |
| 207 | { |
| 208 | workspace.Dispose(); |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | else |
| 213 | { |
| 214 | Solution? solution = null; |
| 215 | try |
| 216 | { |
| 217 | var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild); |
| 218 | solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false); |
| 219 | |
| 220 | if (solution is null) |
| 221 | return "Error: failed to open solution."; |
| 222 | |
| 223 | var (t, n, s) = await CollectDiagnosticsFromSolution(workspace, solution, targetPath, allDiagnostics, cancellationToken).ConfigureAwait(false); |
| 224 | totalAfterPath += t; |
| 225 | excludedSeverityNone += n; |
| 226 | excludedSuppress += s; |
| 227 | } |
| 228 | finally |
| 229 | { |
| 230 | solution?.Workspace.Dispose(); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | var sb = new StringBuilder(); |
| 235 | sb.AppendLine("# Diagnostics (compiler + analyzers)"); |
| 236 | if (string.Equals(Path.GetExtension(solutionOrProjectPath), ".slnx", StringComparison.OrdinalIgnoreCase)) |
| 237 | sb.AppendLineInvariant($"# Solution: {solutionOrProjectPath}"); |
| 238 | sb.AppendLine("# Filtered by .editorconfig (severity = none) and CompilationOptions (Suppress); severity = effective (incl. TreatWarningsAsErrors)."); |
| 239 | sb.AppendLineInvariant($"# Total (after path filter): {totalAfterPath}; excluded: severity=none {excludedSeverityNone}, Suppress {excludedSuppress}; shown: {allDiagnostics.Count}"); |
| 240 | if (targetPath is not null) |
| 241 | sb.AppendLineInvariant($"# File: {filePath}"); |
| 242 | sb.AppendLine("# Format: file:line:column severity id — message"); |
| 243 | sb.AppendLine(); |
| 244 | |
| 245 | foreach (var (d, effectiveSeverity) in allDiagnostics.OrderBy(x => x.d.Location.SourceTree?.FilePath ?? "").ThenBy(x => x.d.Location.SourceSpan.Start)) |
| 246 | { |
| 247 | var tree = d.Location.SourceTree; |
| 248 | var file = tree?.FilePath ?? "(no file)"; |
| 249 | var line = 0; |
| 250 | var column = 0; |
| 251 | if (tree is not null && d.Location.IsInSource) |
| 252 | { |
| 253 | var lineSpan = d.Location.GetLineSpan(); |
| 254 | line = lineSpan.StartLinePosition.Line + 1; |
| 255 | column = lineSpan.StartLinePosition.Character + 1; |
| 256 | } |
| 257 | var severityStr = effectiveSeverity switch |
| 258 | { |
| 259 | DiagnosticSeverity.Error => "error", |
| 260 | DiagnosticSeverity.Warning => "warning", |
| 261 | DiagnosticSeverity.Info => "info", |
| 262 | DiagnosticSeverity.Hidden => "hidden", |
| 263 | _ => "unknown" |
| 264 | }; |
| 265 | sb.AppendLineInvariant($"{file}:{line}:{column} {severityStr} {d.Id} — {d.GetMessage(CultureInfo.InvariantCulture)}"); |
| 266 | } |
| 267 | |
| 268 | sb.AppendLine(); |
| 269 | sb.AppendLine("# Prefer this over parsing build logs for compiler/analyzer errors."); |
| 270 | sb.AppendLine("# To fix: use file:line:column with roslyn_get_code_actions, then roslyn_apply_code_action (or fix_all_scope)."); |
| 271 | sb.AppendLine(); |
| 272 | if (allDiagnostics.Count == 0) |
| 273 | sb.AppendLine("(no diagnostics)"); |
| 274 | else |
| 275 | sb.AppendLineInvariant($"Total: {allDiagnostics.Count}"); |
| 276 | |
| 277 | return sb.ToString(); |
| 278 | } |
| 279 | } |
| 280 | |