diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs index 2de5f38..6dac4fe 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs @@ -18,7 +18,7 @@ public DiagnosticsTools(RpcClient rpcClient) } [McpServerTool(Name = "errors_list", ReadOnly = true)] - [Description("Get errors, warnings, and messages from the Error List. Returns diagnostics with file, line, description, and severity. Filter by severity to focus on specific issues.")] + [Description("Get errors, warnings, and messages from the Error List. Returns diagnostics with file, line, description, and severity. Filter by severity to focus on specific issues. TotalCount, ErrorCount, WarningCount, and MessageCount always cover the whole Error List, regardless of the severity filter or maxResults. Truncated is true when more entries matched than maxResults allowed. An empty Error List can mean nothing has been built yet; build the solution to populate it.")] public async Task GetErrorListAsync( [Description("Filter by severity: \"Error\", \"Warning\", \"Message\", or null for all. Case-insensitive.")] string? severity = null, diff --git a/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs b/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs new file mode 100644 index 0000000..ec805da --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodingWithCalvin.MCPServer.Services; +using CodingWithCalvin.MCPServer.Shared.Models; +using Xunit; + +namespace CodingWithCalvin.MCPServer.Tests; + +public class ErrorListTests +{ + [Fact] + public void CollectErrorListEntries_CountsEveryEntry_WhenResultsAreCapped() + { + var entries = Entries(errors: 250, warnings: 3, messages: 2); + + var result = Collect(entries, severityFilter: null, maxResults: 100); + + Assert.Equal(100, result.Items.Count); + Assert.Equal(255, result.TotalCount); + Assert.Equal(250, result.ErrorCount); + Assert.Equal(3, result.WarningCount); + Assert.Equal(2, result.MessageCount); + Assert.True(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_CountsFilteredOutSeverities() + { + var entries = Entries(errors: 2, warnings: 4, messages: 1); + + var result = Collect(entries, severityFilter: "error", maxResults: 100); + + Assert.Equal(2, result.Items.Count); + Assert.All(result.Items, item => Assert.Equal("Error", item.Severity)); + Assert.Equal(7, result.TotalCount); + Assert.Equal(2, result.ErrorCount); + Assert.Equal(4, result.WarningCount); + Assert.Equal(1, result.MessageCount); + Assert.False(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_TruncatedOnlyCountsMatchingEntries() + { + // 3 warnings fit within the cap even though the whole list is larger than it. + var entries = Entries(errors: 10, warnings: 3, messages: 0); + + var result = Collect(entries, severityFilter: "Warning", maxResults: 5); + + Assert.Equal(3, result.Items.Count); + Assert.Equal(13, result.TotalCount); + Assert.False(result.Truncated); + } + + [Theory] + [InlineData(5, 5, false)] + [InlineData(6, 5, true)] + [InlineData(1, 0, true)] + public void CollectErrorListEntries_SetsTruncatedAtTheCapBoundary(int errors, int maxResults, bool expected) + { + var result = Collect(Entries(errors, warnings: 0, messages: 0), severityFilter: null, maxResults); + + Assert.Equal(Math.Min(errors, maxResults), result.Items.Count); + Assert.Equal(expected, result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_ReturnsNoItems_WhenNothingMatches() + { + var entries = Entries(errors: 0, warnings: 2, messages: 1); + + var result = Collect(entries, severityFilter: "Error", maxResults: 100); + + Assert.Empty(result.Items); + Assert.Equal(3, result.TotalCount); + Assert.Equal(0, result.ErrorCount); + Assert.False(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_ReturnsEmptyResult_ForEmptyErrorList() + { + var result = Collect(Array.Empty(), severityFilter: null, maxResults: 100); + + Assert.Empty(result.Items); + Assert.Equal(0, result.TotalCount); + Assert.False(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_OnlyReadsEntriesThatAreReturned() + { + var created = 0; + + var result = VisualStudioService.CollectErrorListEntries( + Entries(errors: 50, warnings: 0, messages: 0), + entry => entry, + (entry, severity) => + { + created++; + return new ErrorItemInfo { Severity = severity }; + }, + severityFilter: null, + maxResults: 10); + + Assert.Equal(10, created); + Assert.Equal(50, result.TotalCount); + } + + [Fact] + public void CollectErrorListEntries_SkipsEntriesWhoseSeverityCannotBeRead() + { + var entries = new[] { "Error", null, "Warning" }; + + var result = VisualStudioService.CollectErrorListEntries( + entries, + entry => entry, + (entry, severity) => new ErrorItemInfo { Severity = severity }, + severityFilter: null, + maxResults: 100); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(2, result.TotalCount); + } + + [Fact] + public void CollectErrorListEntries_CountsEntriesThatFailToRead() + { + // An entry whose details cannot be read is still counted, so it is not silently lost. + var entries = Entries(errors: 3, warnings: 0, messages: 0); + var index = 0; + + var result = VisualStudioService.CollectErrorListEntries( + entries, + entry => entry, + (entry, severity) => index++ == 1 ? null : new ErrorItemInfo { Severity = severity }, + severityFilter: null, + maxResults: 100); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(3, result.TotalCount); + Assert.Equal(3, result.ErrorCount); + Assert.False(result.Truncated); + } + + private static ErrorListResult Collect(IEnumerable entries, string? severityFilter, int maxResults) => + VisualStudioService.CollectErrorListEntries( + entries, + entry => entry, + (entry, severity) => new ErrorItemInfo { Severity = severity }, + severityFilter, + maxResults); + + // Interleaves severities so capping and filtering cannot pass by relying on order. + private static List Entries(int errors, int warnings, int messages) + { + var groups = new[] + { + Enumerable.Repeat("Error", errors).ToList(), + Enumerable.Repeat("Warning", warnings).ToList(), + Enumerable.Repeat("Message", messages).ToList() + }; + + var entries = new List(); + for (var i = 0; i < groups.Max(g => g.Count); i++) + { + foreach (var group in groups.Where(g => i < g.Count)) + { + entries.Add(group[i]); + } + } + + return entries; + } +} diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 0e49676..bb3802b 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -2208,126 +2208,148 @@ public async Task GetErrorListAsync(string? severity = null, in return result; } - int count = 0; - var severityFilter = severity?.ToLowerInvariant(); + result = CollectErrorListEntries( + tableControl.Entries, + GetErrorListSeverity, + CreateErrorItem, + severity, + maxResults); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + result.Items.Add(new ErrorItemInfo + { + Description = $"Error accessing Error List: {ex.Message}", + Severity = "Message" + }); + } + + return result; + } + + /// + /// Counts every Error List entry by severity, and collects up to + /// of the entries that match . + /// + /// + /// The counts cover the whole Error List, not just the returned items, so the caller can tell + /// how much was filtered out or cut off. Entries past the cap are counted but never read in full. + /// An entry whose severity cannot be read ( returns null) is skipped. + /// + internal static ErrorListResult CollectErrorListEntries( + IEnumerable entries, + Func getSeverity, + Func createItem, + string? severityFilter, + int maxResults) + { + var result = new ErrorListResult(); + var matchedCount = 0; - // Enumerate through error list entries - foreach (ITableEntryHandle entry in tableControl.Entries) + foreach (var entry in entries) + { + var severity = getSeverity(entry); + if (severity == null) { - if (count >= maxResults) - break; + continue; + } - try - { - // Get error properties from the table entry - string errorCode = ""; - string projectName = ""; - string text = ""; - string documentName = ""; - int line = 0; - int column = 0; - string severityStr = "Message"; - - // Extract all available properties - if (entry.TryGetValue(StandardTableKeyNames.ErrorCode, out object codeObj)) - { - errorCode = codeObj as string ?? ""; - } + result.TotalCount++; + if (severity == "Error") result.ErrorCount++; + else if (severity == "Warning") result.WarningCount++; + else result.MessageCount++; - if (entry.TryGetValue(StandardTableKeyNames.ProjectName, out object projectObj)) - { - projectName = projectObj as string ?? ""; - } + if (!string.IsNullOrEmpty(severityFilter) && + !severity.Equals(severityFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } - if (entry.TryGetValue(StandardTableKeyNames.Text, out object textObj)) - { - text = textObj as string ?? ""; - } + matchedCount++; + if (matchedCount > maxResults) + { + continue; + } - if (entry.TryGetValue(StandardTableKeyNames.DocumentName, out object docObj)) - { - documentName = docObj as string ?? ""; - } + var item = createItem(entry, severity); + if (item != null) + { + result.Items.Add(item); + } + } - // Get line number - if (entry.TryGetValue(StandardTableKeyNames.Line, out object lineObj) && lineObj is int lineInt) - { - line = lineInt; - } + result.Truncated = matchedCount > maxResults; + return result; + } - // Get column number - if (entry.TryGetValue(StandardTableKeyNames.Column, out object colObj) && colObj is int colInt) - { - column = colInt; - } + private static string? GetErrorListSeverity(ITableEntryHandle entry) + { + try + { + if (entry.TryGetValue(StandardTableKeyNames.ErrorSeverity, out object severityObj) && + severityObj is __VSERRORCATEGORY errorCategory) + { + return errorCategory switch + { + __VSERRORCATEGORY.EC_ERROR => "Error", + __VSERRORCATEGORY.EC_WARNING => "Warning", + _ => "Message" + }; + } - // Get error severity - if (entry.TryGetValue(StandardTableKeyNames.ErrorSeverity, out object severityObj) && - severityObj is __VSERRORCATEGORY errorCategory) - { - severityStr = errorCategory switch - { - __VSERRORCATEGORY.EC_ERROR => "Error", - __VSERRORCATEGORY.EC_WARNING => "Warning", - __VSERRORCATEGORY.EC_MESSAGE => "Message", - _ => "Message" - }; - } + return "Message"; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } - // Apply severity filter if specified - if (!string.IsNullOrEmpty(severityFilter) && - !severityStr.Equals(severityFilter, StringComparison.OrdinalIgnoreCase)) - { - continue; - } + private static ErrorItemInfo? CreateErrorItem(ITableEntryHandle entry, string severity) + { + try + { + var item = new ErrorItemInfo { Severity = severity }; - // Add the error item to results - result.Items.Add(new ErrorItemInfo - { - FilePath = documentName, - Line = line, - Column = column, - Description = text, - Severity = severityStr, - ErrorCode = errorCode, - Project = projectName - }); + if (entry.TryGetValue(StandardTableKeyNames.ErrorCode, out object codeObj)) + { + item.ErrorCode = codeObj as string ?? ""; + } - count++; + if (entry.TryGetValue(StandardTableKeyNames.ProjectName, out object projectObj)) + { + item.Project = projectObj as string ?? ""; + } - // Count by severity - if (severityStr == "Error") result.ErrorCount++; - else if (severityStr == "Warning") result.WarningCount++; - else result.MessageCount++; - } - catch (Exception itemEx) - { - VsixTelemetry.TrackException(itemEx); - } + if (entry.TryGetValue(StandardTableKeyNames.Text, out object textObj)) + { + item.Description = textObj as string ?? ""; } - result.TotalCount = count; + if (entry.TryGetValue(StandardTableKeyNames.DocumentName, out object docObj)) + { + item.FilePath = docObj as string ?? ""; + } - if (count == 0) + if (entry.TryGetValue(StandardTableKeyNames.Line, out object lineObj) && lineObj is int line) { - result.Items.Add(new ErrorItemInfo - { - Description = "No errors or warnings in the Error List. Build the project to populate the Error List.", - Severity = "Message" - }); + item.Line = line; } + + if (entry.TryGetValue(StandardTableKeyNames.Column, out object colObj) && colObj is int column) + { + item.Column = column; + } + + return item; } catch (Exception ex) { VsixTelemetry.TrackException(ex); - result.Items.Add(new ErrorItemInfo - { - Description = $"Error accessing Error List: {ex.Message}", - Severity = "Message" - }); + return null; } - - return result; } public async Task> GetOutputPanesAsync()