diff --git a/CLAUDE.md b/CLAUDE.md index 4eb5de7..4a12b2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,11 +195,13 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj - `debugger_stop` - Stop debugging (Shift+F5) ### Diagnostics Tools -- `errors_list` - Read build errors, warnings, and messages from the Error List +- `errors_list` - Read errors, warnings, and messages from the Error List, filtered by severity, scope, Build/IntelliSense source, suppression state, code, tool, category, project, path, or text - `output_list_panes` - List all available Output window panes - `output_read` - Read content from an Output window pane - `output_write` - Write a message to an Output window pane +`errors_list` reads `IWpfTableControl.Entries`, which holds only what the Error List window displays, so the window's own scope and filters apply before the tool's filters. Its severity and Build/IntelliSense filters are readable through `IErrorList` and reported in `Notes`; its column filters are not, and the Suppression State column hides suppressed entries by default. The scope drop-down is internal API, so the tool's `scope` is resolved from the active document, open documents, and the active document's project instead. Changed Documents scope is not supported. Entries from shared files carry `ProjectNames` / `ProjectGuids` instead of `ProjectName` / `ProjectGuid`, so project matching reads both. + ### Test Tools - `test_cancel` - Cancel the test run in progress - `test_debug` - Debug the tests in a single class or method diff --git a/README.md b/README.md index 0c7eb28..680d223 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ | Tool | Description | |------|-------------| -| `errors_list` | Read build errors, warnings, and messages from the Error List | +| `errors_list` | Read errors, warnings, and messages from the Error List, filtered by severity, scope, Build/IntelliSense source, suppression state, code, tool, category, project, path, or text | | `output_list_panes` | List all available Output window panes | | `output_read` | Read content from an Output window pane | | `output_write` | Write a message to an Output window pane | diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 64afa7a..794ab37 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -172,8 +172,7 @@ public Task DebugSetBreakpointEnabledAsync(string file, int line, bool ena public Task DebugSetVariableValueAsync(string variableName, string value) => Proxy.DebugSetVariableValueAsync(variableName, value); public Task> DebugGetCallStackAsync() => Proxy.DebugGetCallStackAsync(); - public Task GetErrorListAsync(string? severity = null, int maxResults = 100) - => Proxy.GetErrorListAsync(severity, maxResults); + public Task GetErrorListAsync(ErrorListQuery query) => Proxy.GetErrorListAsync(query); public Task ReadOutputPaneAsync(string paneIdentifier) => Proxy.ReadOutputPaneAsync(paneIdentifier); public Task WriteOutputPaneAsync(string paneIdentifier, string message, bool activate = false) => Proxy.WriteOutputPaneAsync(paneIdentifier, message, activate); diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs index 6dac4fe..d974c7e 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/DiagnosticsTools.cs @@ -1,6 +1,10 @@ +using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using CodingWithCalvin.MCPServer.Shared.Models; using ModelContextProtocol.Server; namespace CodingWithCalvin.MCPServer.Server.Tools; @@ -8,6 +12,8 @@ namespace CodingWithCalvin.MCPServer.Server.Tools; [McpServerToolType] public class DiagnosticsTools { + private static readonly string[] Severities = { "Error", "Warning", "Message" }; + private readonly RpcClient _rpcClient; private readonly JsonSerializerOptions _jsonOptions; @@ -18,14 +24,67 @@ 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. 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.")] + [Description("Get errors, warnings, and messages from the Error List. Returns diagnostics with file, line, description, severity, code, project, source (Build or IntelliSense), tool, category, and suppression state. Filters combine: an entry must match every filter given. Only entries the Error List window is currently showing can be returned, so the window's own scope and filters apply first; Notes reports anything the window is hiding. TotalCount, ErrorCount, WarningCount, and MessageCount always cover the whole Error List, regardless of filters or maxResults. MatchedCount is how many entries passed the filters, and Truncated is true when that is more than maxResults. 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, + [Description("Which documents to include: solution, openDocuments, currentProject (the project containing the active document), or currentDocument. Defaults to solution. The Error List's Changed Documents scope is not available.")] + string scope = "solution", + [Description("Which producer to include: all, build, or intellisense. Defaults to all.")] + string source = "all", + [Description("Which suppression state to include: all, active, suppressed, or notApplicable. Defaults to all. The Error List window hides suppressed entries by default, so suppressed returns them only when the window's Suppression State column filter includes them.")] + string suppressionState = "all", + [Description("Error code to match exactly, such as \"CS0103\". Separate several codes with commas. Case-insensitive.")] + string? code = null, + [Description("Tool that produced the entry, as shown in the Error List's Tool column, such as \"Compiler\". Separate several with commas. Case-insensitive.")] + string? tool = null, + [Description("Category, as shown in the Error List's Category column, such as \"Style\". Separate several with commas. Case-insensitive.")] + string? category = null, + [Description("Project name. Also matches multi-targeted entries such as \"MyApp (net8.0)\". Case-insensitive.")] + string? project = null, + [Description("File name, folder, or partial path, matched on whole path segments, such as \"Program.cs\" or \"src/Services\". Case-insensitive.")] + string? path = null, + [Description("Text to find in the description, code, project, file path, tool, or category. Case-insensitive.")] + string? search = null, [Description("Maximum number of items to return. Defaults to 100.")] int maxResults = 100) { - var result = await _rpcClient.GetErrorListAsync(severity, maxResults); + if (!string.IsNullOrEmpty(severity) && !Severities.Contains(severity, StringComparer.OrdinalIgnoreCase)) + { + return $"Invalid severity '{severity}'. Valid values are: Error, Warning, Message."; + } + + if (!TryParseScope(scope, out var parsedScope)) + { + return $"Invalid scope '{scope}'. Valid values are: solution, openDocuments, currentProject, currentDocument."; + } + + if (!TryParseSource(source, out var parsedSource)) + { + return $"Invalid source '{source}'. Valid values are: all, build, intellisense."; + } + + if (!TryParseSuppressionState(suppressionState, out var parsedSuppressionState)) + { + return $"Invalid suppressionState '{suppressionState}'. Valid values are: all, active, suppressed, notApplicable."; + } + + var query = new ErrorListQuery + { + Severity = string.IsNullOrEmpty(severity) ? null : severity, + Scope = parsedScope, + Source = parsedSource, + SuppressionState = parsedSuppressionState, + Codes = SplitList(code), + Tools = SplitList(tool), + Categories = SplitList(category), + Project = project, + Path = path, + Search = search, + MaxResults = maxResults + }; + + var result = await _rpcClient.GetErrorListAsync(query); // Always return the JSON result (includes debug info if TotalCount is 0) return JsonSerializer.Serialize(result, _jsonOptions); @@ -76,4 +135,31 @@ public async Task GetOutputPanesAsync() return JsonSerializer.Serialize(panes, _jsonOptions); } + + private static bool TryParseScope(string value, out ErrorListScope scope) + { + return Enum.TryParse(value, ignoreCase: true, out scope) + && Enum.IsDefined(typeof(ErrorListScope), scope); + } + + private static bool TryParseSource(string value, out ErrorListSource source) + { + return Enum.TryParse(value, ignoreCase: true, out source) + && Enum.IsDefined(typeof(ErrorListSource), source); + } + + private static bool TryParseSuppressionState(string value, out ErrorListSuppressionState suppressionState) + { + return Enum.TryParse(value, ignoreCase: true, out suppressionState) + && Enum.IsDefined(typeof(ErrorListSuppressionState), suppressionState); + } + + private static List SplitList(string? value) + { + return (value ?? string.Empty) + .Split(',') + .Select(item => item.Trim()) + .Where(item => item.Length > 0) + .ToList(); + } } diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/DiagnosticsModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/DiagnosticsModels.cs index e67d188..2f036a1 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/Models/DiagnosticsModels.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/DiagnosticsModels.cs @@ -2,6 +2,68 @@ namespace CodingWithCalvin.MCPServer.Shared.Models; +/// +/// Which documents an Error List query covers, mirroring the scope drop-down in the Error List window. +/// +public enum ErrorListScope +{ + Solution, + OpenDocuments, + CurrentProject, + CurrentDocument +} + +/// +/// Which producer an Error List entry must come from, mirroring the Build + IntelliSense drop-down. +/// +public enum ErrorListSource +{ + All, + Build, + IntelliSense +} + +/// +/// Which suppression state an Error List entry must have, mirroring the Suppression State column. +/// +public enum ErrorListSuppressionState +{ + All, + Active, + Suppressed, + NotApplicable +} + +/// +/// Filters applied to the entries the Error List window is currently showing. Every filter that is +/// set must match for an entry to be returned. +/// +public class ErrorListQuery +{ + /// "Error", "Warning", or "Message"; null for all. + public string? Severity { get; set; } + public ErrorListScope Scope { get; set; } = ErrorListScope.Solution; + public ErrorListSource Source { get; set; } = ErrorListSource.All; + public ErrorListSuppressionState SuppressionState { get; set; } = ErrorListSuppressionState.All; + + /// Error codes to keep (e.g. "CS0103"); empty for all. + public List Codes { get; set; } = new(); + + /// Tool names to keep, as shown in the Tool column; empty for all. + public List Tools { get; set; } = new(); + + /// Categories to keep, as shown in the Category column; empty for all. + public List Categories { get; set; } = new(); + public string? Project { get; set; } + + /// File name, folder, or partial path, matched on whole path segments. + public string? Path { get; set; } + + /// Text to find in the description, code, project, file path, tool, or category. + public string? Search { get; set; } + public int MaxResults { get; set; } = 100; +} + public class ErrorListResult { public List Items { get; set; } = new(); @@ -9,7 +71,18 @@ public class ErrorListResult public int ErrorCount { get; set; } public int WarningCount { get; set; } public int MessageCount { get; set; } + + /// + /// Entries that passed every filter, including any cut off by the result limit. The other counts + /// cover the whole Error List. + /// + public int MatchedCount { get; set; } public bool Truncated { get; set; } + + /// + /// Caveats about the results, such as categories the Error List window itself is hiding. + /// + public List Notes { get; set; } = new(); } public class ErrorItemInfo @@ -17,10 +90,14 @@ public class ErrorItemInfo public string Severity { get; set; } = string.Empty; // "Error", "Warning", "Message" public string Description { get; set; } = string.Empty; public string ErrorCode { get; set; } = string.Empty; // e.g., "CS0103" - public string Project { get; set; } = string.Empty; + public string Project { get; set; } = string.Empty; // comma-separated when the entry belongs to several projects public string FilePath { get; set; } = string.Empty; public int Line { get; set; } public int Column { get; set; } + public string Source { get; set; } = string.Empty; // "Build", "IntelliSense", or empty when unknown + public string Tool { get; set; } = string.Empty; // e.g., "Compiler" + public string Category { get; set; } = string.Empty; // e.g., "Style" + public string SuppressionState { get; set; } = string.Empty; // "Active", "Suppressed", "NotApplicable", or empty when unknown } public class OutputPaneInfo diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index 7dd0a83..99ff6a6 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -75,7 +75,7 @@ Task DebugAddBreakpointAsync( Task> DebugGetCallStackAsync(); // Diagnostics tools - Task GetErrorListAsync(string? severity = null, int maxResults = 100); + Task GetErrorListAsync(ErrorListQuery query); Task ReadOutputPaneAsync(string paneIdentifier); Task WriteOutputPaneAsync(string paneIdentifier, string message, bool activate = false); Task> GetOutputPanesAsync(); diff --git a/src/CodingWithCalvin.MCPServer.Tests/ErrorListFilterTests.cs b/src/CodingWithCalvin.MCPServer.Tests/ErrorListFilterTests.cs new file mode 100644 index 0000000..ac30cef --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/ErrorListFilterTests.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using CodingWithCalvin.MCPServer.Services; +using CodingWithCalvin.MCPServer.Shared.Models; +using Microsoft.VisualStudio.Shell.TableManager; +using Xunit; + +namespace CodingWithCalvin.MCPServer.Tests; + +public class ErrorListFilterTests +{ + private static readonly Guid AppProjectGuid = new("6f1c2a4e-0b7d-4c55-9a3e-2d8f6b1e7c90"); + private static readonly Guid TestsProjectGuid = new("a3b9e7d1-5c2f-4e86-8b14-9f0d3c6a2e57"); + + [Fact] + public void Matches_WithDefaultQuery_KeepsEveryEntry() + { + var filter = CreateFilter(new ErrorListQuery()); + + Assert.True(Match(filter, CreateItem())); + Assert.True(Match(filter, CreateItem(severity: "Message", source: ""))); + } + + [Theory] + [InlineData("Error", "error", true)] + [InlineData("Warning", "Error", false)] + public void Matches_BySeverity_IsCaseInsensitive(string itemSeverity, string severity, bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Severity = severity }); + + Assert.Equal(expected, Match(filter, CreateItem(severity: itemSeverity))); + } + + [Theory] + [InlineData(ErrorListSource.All, "Build", true)] + [InlineData(ErrorListSource.All, "", true)] + [InlineData(ErrorListSource.Build, "Build", true)] + [InlineData(ErrorListSource.Build, "IntelliSense", false)] + [InlineData(ErrorListSource.Build, "", false)] + [InlineData(ErrorListSource.IntelliSense, "IntelliSense", true)] + [InlineData(ErrorListSource.IntelliSense, "Build", false)] + [InlineData(ErrorListSource.IntelliSense, "", false)] + public void Matches_BySource_ExcludesUnknownSourcesUnlessAll( + ErrorListSource source, + string itemSource, + bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Source = source }); + + Assert.Equal(expected, Match(filter, CreateItem(source: itemSource))); + } + + [Theory] + [InlineData("CS0103", true)] + [InlineData("cs8618", true)] + [InlineData("CS0168", false)] + [InlineData("", false)] + public void Matches_ByCode_KeepsAnyListedCode(string itemCode, bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Codes = new List { "CS0103", "CS8618" } }); + + Assert.Equal(expected, Match(filter, CreateItem(code: itemCode))); + } + + [Theory] + [InlineData("undefined", true)] + [InlineData("CS01", true)] + [InlineData("MYAPP", true)] + [InlineData("program.cs", true)] + [InlineData("fxcop", true)] + [InlineData("naming", true)] + [InlineData("nullable", false)] + public void Matches_BySearch_LooksAcrossEveryTextColumn(string search, bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Search = search }); + + Assert.Equal(expected, Match(filter, CreateItem(tool: "FxCop", category: "Naming"))); + } + + [Theory] + [InlineData(ErrorListSuppressionState.All, "Suppressed", true)] + [InlineData(ErrorListSuppressionState.All, "", true)] + [InlineData(ErrorListSuppressionState.Active, "Active", true)] + [InlineData(ErrorListSuppressionState.Active, "Suppressed", false)] + [InlineData(ErrorListSuppressionState.Suppressed, "Suppressed", true)] + [InlineData(ErrorListSuppressionState.NotApplicable, "NotApplicable", true)] + [InlineData(ErrorListSuppressionState.NotApplicable, "", false)] + public void Matches_BySuppressionState_ExcludesUnknownStatesUnlessAll( + ErrorListSuppressionState suppressionState, + string itemSuppressionState, + bool expected) + { + var filter = CreateFilter(new ErrorListQuery { SuppressionState = suppressionState }); + + Assert.Equal(expected, Match(filter, CreateItem(suppressionState: itemSuppressionState))); + } + + [Theory] + [InlineData("Compiler", true)] + [InlineData("fxcop", true)] + [InlineData("Roslyn", false)] + [InlineData("", false)] + public void Matches_ByTool_KeepsAnyListedTool(string itemTool, bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Tools = new List { "compiler", "FxCop" } }); + + Assert.Equal(expected, Match(filter, CreateItem(tool: itemTool))); + } + + [Theory] + [InlineData("Style", true)] + [InlineData("Compiler", false)] + [InlineData("", false)] + public void Matches_ByCategory_KeepsAnyListedCategory(string itemCategory, bool expected) + { + var filter = CreateFilter(new ErrorListQuery { Categories = new List { "style" } }); + + Assert.Equal(expected, Match(filter, CreateItem(category: itemCategory))); + } + + [Fact] + public void Matches_ByProject_KeepsEntriesSharedWithThatProject() + { + var filter = CreateFilter(new ErrorListQuery { Project = "App.Android" }); + var item = CreateItem(project: "App.iOS, App.Android"); + + Assert.True(filter.Matches(new ErrorListEntry(item, new[] { "App.iOS", "App.Android" }, Array.Empty()))); + Assert.False(filter.Matches(new ErrorListEntry(item, new[] { "App.iOS", "App.Windows" }, Array.Empty()))); + } + + [Fact] + public void Matches_ByProject_WithoutAnyProject_MatchesNothing() + { + var filter = CreateFilter(new ErrorListQuery { Project = "MyApp" }); + + Assert.False(Match(filter, CreateItem(project: ""))); + } + + [Fact] + public void Matches_CurrentProjectScope_KeepsEntriesSharedWithTheCurrentProject() + { + var filter = CreateFilter( + new ErrorListQuery { Scope = ErrorListScope.CurrentProject }, + new ErrorListScopeContext { CurrentProjectName = "MyApp", CurrentProjectGuid = AppProjectGuid }); + var names = new[] { "MyApp.Tests", "MyApp" }; + + Assert.True(filter.Matches(new ErrorListEntry(CreateItem(), names, new[] { TestsProjectGuid, AppProjectGuid }))); + Assert.True(filter.Matches(new ErrorListEntry(CreateItem(), names, Array.Empty()))); + Assert.False(filter.Matches(new ErrorListEntry(CreateItem(), names, new[] { TestsProjectGuid }))); + } + + [Fact] + public void NeedsEntryDetails_IsFalseForADefaultOrSeverityOnlyQuery() + { + Assert.False(CreateFilter(new ErrorListQuery()).NeedsEntryDetails); + Assert.False(CreateFilter(new ErrorListQuery { Severity = "Error" }).NeedsEntryDetails); + } + + public static TheoryData DetailQueries => new() + { + new ErrorListQuery { Scope = ErrorListScope.CurrentDocument }, + new ErrorListQuery { Source = ErrorListSource.Build }, + new ErrorListQuery { SuppressionState = ErrorListSuppressionState.Active }, + new ErrorListQuery { Codes = new List { "CS0103" } }, + new ErrorListQuery { Tools = new List { "Compiler" } }, + new ErrorListQuery { Categories = new List { "Style" } }, + new ErrorListQuery { Project = "MyApp" }, + new ErrorListQuery { Path = "Program.cs" }, + new ErrorListQuery { Search = "undefined" } + }; + + [Theory] + [MemberData(nameof(DetailQueries))] + public void NeedsEntryDetails_IsTrueForAnyOtherFilter(ErrorListQuery query) + { + Assert.True(CreateFilter(query).NeedsEntryDetails); + } + + [Fact] + public void Matches_WithSeveralFilters_RequiresAllOfThem() + { + var filter = CreateFilter(new ErrorListQuery + { + Severity = "Error", + Source = ErrorListSource.Build, + Codes = new List { "CS0103" } + }); + + Assert.True(Match(filter, CreateItem())); + Assert.False(Match(filter, CreateItem(severity: "Warning"))); + Assert.False(Match(filter, CreateItem(source: "IntelliSense"))); + } + + [Theory] + [InlineData(@"C:\src\App\Program.cs", true)] + [InlineData("c:/src/app/program.cs", true)] + [InlineData(@"C:\src\App\Other.cs", false)] + [InlineData("", false)] + public void Matches_CurrentDocumentScope_ComparesFullPaths(string itemPath, bool expected) + { + var filter = CreateFilter( + new ErrorListQuery { Scope = ErrorListScope.CurrentDocument }, + new ErrorListScopeContext { ActiveDocumentPath = @"C:\src\App\Program.cs" }); + + Assert.Equal(expected, Match(filter, CreateItem(path: itemPath))); + } + + [Fact] + public void Matches_CurrentDocumentScope_WithoutActiveDocument_MatchesNothing() + { + var filter = CreateFilter(new ErrorListQuery { Scope = ErrorListScope.CurrentDocument }); + + Assert.False(Match(filter, CreateItem())); + } + + [Theory] + [InlineData(@"C:\src\App\Program.cs", true)] + [InlineData(@"c:\src\app\startup.cs", true)] + [InlineData(@"C:\src\App\Other.cs", false)] + [InlineData("", false)] + public void Matches_OpenDocumentsScope_KeepsOpenFiles(string itemPath, bool expected) + { + var filter = CreateFilter( + new ErrorListQuery { Scope = ErrorListScope.OpenDocuments }, + new ErrorListScopeContext + { + OpenDocumentPaths = new[] { @"C:\src\App\Program.cs", "C:/src/App/Startup.cs" } + }); + + Assert.Equal(expected, Match(filter, CreateItem(path: itemPath))); + } + + [Fact] + public void Matches_CurrentProjectScope_PrefersProjectGuidOverName() + { + var filter = CreateFilter( + new ErrorListQuery { Scope = ErrorListScope.CurrentProject }, + new ErrorListScopeContext { CurrentProjectName = "MyApp", CurrentProjectGuid = AppProjectGuid }); + + Assert.True(Match(filter, CreateItem(project: "Renamed"), AppProjectGuid)); + Assert.False(Match(filter, CreateItem(project: "MyApp"), TestsProjectGuid)); + } + + [Theory] + [InlineData("MyApp", true)] + [InlineData("MyApp (net8.0)", true)] + [InlineData("MyApp.Tests", false)] + [InlineData("", false)] + public void Matches_CurrentProjectScope_FallsBackToNameWhenEntryHasNoGuid(string itemProject, bool expected) + { + var filter = CreateFilter( + new ErrorListQuery { Scope = ErrorListScope.CurrentProject }, + new ErrorListScopeContext { CurrentProjectName = "MyApp", CurrentProjectGuid = AppProjectGuid }); + + Assert.Equal(expected, Match(filter, CreateItem(project: itemProject))); + } + + [Fact] + public void Matches_CurrentProjectScope_WithoutCurrentProject_MatchesNothing() + { + var filter = CreateFilter(new ErrorListQuery { Scope = ErrorListScope.CurrentProject }); + + Assert.False(Match(filter, CreateItem(), AppProjectGuid)); + } + + [Theory] + [InlineData("MyApp", "myapp", true)] + [InlineData("MyApp (net8.0)", "MyApp", true)] + [InlineData("MyApp (net8.0)", "MyApp (net8.0)", true)] + [InlineData("MyApp (net8.0)", " MyApp ", true)] + [InlineData("MyApp.Tests", "MyApp", false)] + [InlineData("MyApp (net8.0)", "MyAp", false)] + [InlineData("", "MyApp", false)] + public void MatchesProjectName_AcceptsTargetFrameworkSuffix(string entryProject, string project, bool expected) + { + Assert.Equal(expected, ErrorListFilter.MatchesProjectName(entryProject, project)); + } + + [Theory] + [InlineData(@"C:\src\App\Program.cs", "Program.cs", true)] + [InlineData(@"C:\src\App\Program.cs", "program.cs", true)] + [InlineData(@"C:\src\App\Program.cs", "src/App", true)] + [InlineData(@"C:\src\App\Program.cs", @"src\App\", true)] + [InlineData(@"C:\src\App\Program.cs", "App/Program.cs", true)] + [InlineData(@"C:\src\App\Program.cs", @"C:\src\App\Program.cs", true)] + [InlineData(@"C:\src\App\Program.cs", "gram.cs", false)] + [InlineData(@"C:\src\Application\Program.cs", "src/App", false)] + [InlineData(@"C:\src\App\Program.cs", "/", false)] + [InlineData("", "Program.cs", false)] + public void MatchesPath_MatchesWholePathSegments(string entryPath, string path, bool expected) + { + Assert.Equal(expected, ErrorListFilter.MatchesPath(entryPath, path)); + } + + [Fact] + public void DescribeHiddenEntries_WhenWindowShowsEverything_ReturnsNull() + { + Assert.Null(ErrorListFilter.DescribeHiddenEntries(true, true, true, true, true)); + } + + [Fact] + public void DescribeHiddenEntries_NamesASingleHiddenCategory() + { + Assert.Equal( + "The Error List window is hiding warnings, so they are not included. Change the window's filters to include them.", + ErrorListFilter.DescribeHiddenEntries(true, false, true, true, true)); + } + + [Fact] + public void DescribeHiddenEntries_ListsEveryHiddenCategory() + { + Assert.Equal( + "The Error List window is hiding messages, build entries and IntelliSense entries, so they are not included. Change the window's filters to include them.", + ErrorListFilter.DescribeHiddenEntries(true, true, false, false, false)); + } + + [Theory] + [InlineData(ErrorSource.Build, "Build")] + [InlineData(ErrorSource.Other, "IntelliSense")] + [InlineData((ErrorSource)0, "")] + public void GetErrorSourceName_UsesErrorListLabels(ErrorSource source, string expected) + { + Assert.Equal(expected, VisualStudioService.GetErrorSourceName(source)); + } + + [Theory] + [InlineData(SuppressionState.Active, ErrorListSuppressionState.Active)] + [InlineData(SuppressionState.Suppressed, ErrorListSuppressionState.Suppressed)] + [InlineData(SuppressionState.NotApplicable, ErrorListSuppressionState.NotApplicable)] + public void GetSuppressionStateName_MatchesTheQueryFilterValues( + SuppressionState state, + ErrorListSuppressionState expected) + { + var filter = CreateFilter(new ErrorListQuery { SuppressionState = expected }); + var item = CreateItem(suppressionState: VisualStudioService.GetSuppressionStateName(state)); + + Assert.True(Match(filter, item)); + } + + [Fact] + public void GetSuppressionStateName_WithUnknownState_ReturnsEmpty() + { + Assert.Equal(string.Empty, VisualStudioService.GetSuppressionStateName((SuppressionState)99)); + } + + private static ErrorListFilter CreateFilter(ErrorListQuery query, ErrorListScopeContext? scope = null) => + new(query, scope ?? new ErrorListScopeContext()); + + /// Matches an entry belonging to the single project named in . + private static bool Match(ErrorListFilter filter, ErrorItemInfo item, params Guid[] projectGuids) => + filter.Matches(new ErrorListEntry( + item, + string.IsNullOrEmpty(item.Project) ? Array.Empty() : new[] { item.Project }, + projectGuids)); + + private static ErrorItemInfo CreateItem( + string severity = "Error", + string source = "Build", + string code = "CS0103", + string project = "MyApp", + string path = @"C:\src\App\Program.cs", + string tool = "Compiler", + string category = "Compiler", + string suppressionState = "Active") => new() + { + Severity = severity, + Source = source, + ErrorCode = code, + Project = project, + FilePath = path, + Tool = tool, + Category = category, + SuppressionState = suppressionState, + Description = "The name 'undefined' does not exist in the current context" + }; +} diff --git a/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs b/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs index ec805da..39f39fb 100644 --- a/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs +++ b/src/CodingWithCalvin.MCPServer.Tests/ErrorListTests.cs @@ -99,9 +99,9 @@ public void CollectErrorListEntries_OnlyReadsEntriesThatAreReturned() (entry, severity) => { created++; - return new ErrorItemInfo { Severity = severity }; + return Entry(severity); }, - severityFilter: null, + Filter(), maxResults: 10); Assert.Equal(10, created); @@ -116,8 +116,8 @@ public void CollectErrorListEntries_SkipsEntriesWhoseSeverityCannotBeRead() var result = VisualStudioService.CollectErrorListEntries( entries, entry => entry, - (entry, severity) => new ErrorItemInfo { Severity = severity }, - severityFilter: null, + (entry, severity) => Entry(severity), + Filter(), maxResults: 100); Assert.Equal(2, result.Items.Count); @@ -134,8 +134,8 @@ public void CollectErrorListEntries_CountsEntriesThatFailToRead() var result = VisualStudioService.CollectErrorListEntries( entries, entry => entry, - (entry, severity) => index++ == 1 ? null : new ErrorItemInfo { Severity = severity }, - severityFilter: null, + (entry, severity) => index++ == 1 ? null : Entry(severity), + Filter(), maxResults: 100); Assert.Equal(2, result.Items.Count); @@ -144,12 +144,80 @@ public void CollectErrorListEntries_CountsEntriesThatFailToRead() Assert.False(result.Truncated); } + [Fact] + public void CollectErrorListEntries_ReportsMatchedCount_PastTheCap() + { + var entries = Entries(errors: 250, warnings: 5, messages: 0); + + var result = Collect(entries, severityFilter: "Error", maxResults: 100); + + Assert.Equal(100, result.Items.Count); + Assert.Equal(250, result.MatchedCount); + Assert.True(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_ReadsEveryEntry_WhenAFilterNeedsItsDetails() + { + // Past the cap, a code filter cannot tell whether an entry matches without reading it. + var created = 0; + var filter = new ErrorListFilter( + new ErrorListQuery { Codes = new List { "CS0103" } }, + new ErrorListScopeContext()); + + var result = VisualStudioService.CollectErrorListEntries( + Entries(errors: 50, warnings: 0, messages: 0), + entry => entry, + (entry, severity) => + { + var code = created++ % 2 == 0 ? "CS0103" : "CS0168"; + return new ErrorListEntry( + new ErrorItemInfo { Severity = severity, ErrorCode = code }, + Array.Empty(), + Array.Empty()); + }, + filter, + maxResults: 10); + + Assert.Equal(50, created); + Assert.Equal(10, result.Items.Count); + Assert.All(result.Items, item => Assert.Equal("CS0103", item.ErrorCode)); + Assert.Equal(25, result.MatchedCount); + Assert.Equal(50, result.TotalCount); + Assert.True(result.Truncated); + } + + [Fact] + public void CollectErrorListEntries_EntriesThatFailToRead_NeverMatchADetailFilter() + { + var filter = new ErrorListFilter( + new ErrorListQuery { Search = "anything" }, + new ErrorListScopeContext()); + + var result = VisualStudioService.CollectErrorListEntries( + Entries(errors: 3, warnings: 0, messages: 0), + entry => entry, + (entry, severity) => null, + filter, + maxResults: 100); + + Assert.Empty(result.Items); + Assert.Equal(0, result.MatchedCount); + Assert.Equal(3, result.TotalCount); + } + + private static ErrorListFilter Filter(string? severityFilter = null) => + new(new ErrorListQuery { Severity = severityFilter }, new ErrorListScopeContext()); + + private static ErrorListEntry Entry(string severity) => + new(new ErrorItemInfo { Severity = severity }, Array.Empty(), Array.Empty()); + private static ErrorListResult Collect(IEnumerable entries, string? severityFilter, int maxResults) => VisualStudioService.CollectErrorListEntries( entries, entry => entry, - (entry, severity) => new ErrorItemInfo { Severity = severity }, - severityFilter, + (entry, severity) => Entry(severity), + Filter(severityFilter), maxResults); // Interleaves severities so capping and filtering cannot pass by relying on order. diff --git a/src/CodingWithCalvin.MCPServer/Services/ErrorListFilter.cs b/src/CodingWithCalvin.MCPServer/Services/ErrorListFilter.cs new file mode 100644 index 0000000..06a9181 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer/Services/ErrorListFilter.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodingWithCalvin.MCPServer.Shared.Models; + +namespace CodingWithCalvin.MCPServer.Services; + +/// +/// Decides whether an Error List entry passes an . +/// +/// +/// Kept free of Visual Studio types so the matching rules can be unit tested. The caller reads the +/// entries and resolves the scope (active document, open documents, current project) up front. +/// +internal sealed class ErrorListFilter +{ + internal const string BuildSource = "Build"; + internal const string IntelliSenseSource = "IntelliSense"; + + private readonly ErrorListQuery _query; + private readonly ErrorListScopeContext _scope; + private readonly HashSet _openDocumentPaths; + + public ErrorListFilter(ErrorListQuery query, ErrorListScopeContext scope) + { + _query = query; + _scope = scope; + _openDocumentPaths = new HashSet( + scope.OpenDocumentPaths.Select(NormalizeSeparators), + StringComparer.OrdinalIgnoreCase); + + NeedsEntryDetails = query.Scope != ErrorListScope.Solution + || query.Source != ErrorListSource.All + || query.SuppressionState != ErrorListSuppressionState.All + || query.Codes.Count > 0 + || query.Tools.Count > 0 + || query.Categories.Count > 0 + || !string.IsNullOrWhiteSpace(query.Project) + || !string.IsNullOrWhiteSpace(query.Path) + || !string.IsNullOrWhiteSpace(query.Search); + } + + /// + /// True when some filter other than severity is set, so an entry has to be read in full to know + /// whether it matches. Severity alone can be decided before reading anything else. + /// + public bool NeedsEntryDetails { get; } + + public bool MatchesSeverity(string severity) => + string.IsNullOrEmpty(_query.Severity) + || severity.Equals(_query.Severity, StringComparison.OrdinalIgnoreCase); + + public bool Matches(ErrorListEntry entry) + { + var item = entry.Item; + + return MatchesSeverity(item.Severity) + && MatchesSource(item.Source) + && MatchesSuppressionState(item.SuppressionState) + && MatchesScope(entry) + && MatchesAny(item.ErrorCode, _query.Codes) + && MatchesAny(item.Tool, _query.Tools) + && MatchesAny(item.Category, _query.Categories) + && (string.IsNullOrWhiteSpace(_query.Project) + || entry.ProjectNames.Any(name => MatchesProjectName(name, _query.Project!))) + && (string.IsNullOrWhiteSpace(_query.Path) || MatchesPath(item.FilePath, _query.Path!)) + && (string.IsNullOrWhiteSpace(_query.Search) || MatchesSearch(item, _query.Search!)); + } + + /// + /// Matches a project name exactly, or the "Name (target framework)" form the Error List uses for + /// multi-targeted projects. + /// + internal static bool MatchesProjectName(string entryProject, string project) + { + if (string.IsNullOrEmpty(entryProject)) + { + return false; + } + + var name = project.Trim(); + + return entryProject.Equals(name, StringComparison.OrdinalIgnoreCase) + || (entryProject.StartsWith(name + " (", StringComparison.OrdinalIgnoreCase) + && entryProject.EndsWith(")", StringComparison.Ordinal)); + } + + /// + /// Matches a file name, folder, or partial path against whole segments of the entry's path, so + /// "src/App" matches "C:\src\App\Program.cs" but not "C:\src\Application\Program.cs". + /// + internal static bool MatchesPath(string entryPath, string path) + { + if (string.IsNullOrEmpty(entryPath)) + { + return false; + } + + var segments = NormalizeSeparators(path.Trim()).Trim('\\'); + if (segments.Length == 0) + { + return false; + } + + return ("\\" + NormalizeSeparators(entryPath) + "\\") + .IndexOf("\\" + segments + "\\", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Describes the categories the Error List window's own filters are hiding, or returns null when + /// it hides none. Hidden entries never reach this tool, so callers should be told. + /// + internal static string? DescribeHiddenEntries( + bool errorsShown, + bool warningsShown, + bool messagesShown, + bool buildShown, + bool intelliSenseShown) + { + var hidden = new List(); + if (!errorsShown) hidden.Add("errors"); + if (!warningsShown) hidden.Add("warnings"); + if (!messagesShown) hidden.Add("messages"); + if (!buildShown) hidden.Add("build entries"); + if (!intelliSenseShown) hidden.Add("IntelliSense entries"); + + if (hidden.Count == 0) + { + return null; + } + + var list = hidden.Count == 1 + ? hidden[0] + : string.Join(", ", hidden.Take(hidden.Count - 1)) + " and " + hidden[hidden.Count - 1]; + + return $"The Error List window is hiding {list}, so they are not included. Change the window's filters to include them."; + } + + private bool MatchesSource(string source) => + _query.Source switch + { + ErrorListSource.Build => source == BuildSource, + ErrorListSource.IntelliSense => source == IntelliSenseSource, + _ => true + }; + + private bool MatchesSuppressionState(string suppressionState) => + _query.SuppressionState == ErrorListSuppressionState.All + || suppressionState == _query.SuppressionState.ToString(); + + private static bool MatchesAny(string value, IReadOnlyCollection allowed) => + allowed.Count == 0 + || allowed.Any(candidate => candidate.Equals(value, StringComparison.OrdinalIgnoreCase)); + + private bool MatchesScope(ErrorListEntry entry) + { + var item = entry.Item; + + switch (_query.Scope) + { + case ErrorListScope.CurrentDocument: + return _scope.ActiveDocumentPath != null + && !string.IsNullOrEmpty(item.FilePath) + && NormalizeSeparators(item.FilePath).Equals( + NormalizeSeparators(_scope.ActiveDocumentPath), + StringComparison.OrdinalIgnoreCase); + + case ErrorListScope.OpenDocuments: + return !string.IsNullOrEmpty(item.FilePath) + && _openDocumentPaths.Contains(NormalizeSeparators(item.FilePath)); + + case ErrorListScope.CurrentProject: + // The Error List identifies the current project by GUID; build entries often carry + // only a project name, so fall back to that. + if (_scope.CurrentProjectGuid != Guid.Empty && entry.ProjectGuids.Count > 0) + { + return entry.ProjectGuids.Contains(_scope.CurrentProjectGuid); + } + + return _scope.CurrentProjectName != null + && entry.ProjectNames.Any(name => MatchesProjectName(name, _scope.CurrentProjectName)); + + default: + return true; + } + } + + private static bool MatchesSearch(ErrorItemInfo item, string search) => + Contains(item.Description, search) + || Contains(item.ErrorCode, search) + || Contains(item.Project, search) + || Contains(item.FilePath, search) + || Contains(item.Tool, search) + || Contains(item.Category, search); + + private static bool Contains(string value, string search) => + value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; + + private static string NormalizeSeparators(string path) => path.Replace('/', '\\'); +} + +/// +/// An Error List entry read for filtering: the item returned to the caller, plus every project the +/// entry belongs to. +/// +internal sealed class ErrorListEntry +{ + /// The entry, mapped from the Error List. + /// + /// Every project the entry belongs to. Usually one, but an error in a shared file belongs to each + /// project that compiles it. + /// + /// The GUIDs of those projects, where the entry supplies them. + public ErrorListEntry( + ErrorItemInfo item, + IReadOnlyCollection projectNames, + IReadOnlyCollection projectGuids) + { + Item = item; + ProjectNames = projectNames; + ProjectGuids = projectGuids; + } + + public ErrorItemInfo Item { get; } + public IReadOnlyCollection ProjectNames { get; } + public IReadOnlyCollection ProjectGuids { get; } +} + +/// +/// The parts of the IDE state an depends on, captured on the UI thread. +/// Only the members the requested scope needs are filled in. +/// +internal sealed class ErrorListScopeContext +{ + public string? ActiveDocumentPath { get; set; } + public IReadOnlyCollection OpenDocumentPaths { get; set; } = Array.Empty(); + public string? CurrentProjectName { get; set; } + public Guid CurrentProjectGuid { get; set; } +} diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index b8114f3..2065cf2 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -70,7 +70,7 @@ Task DebugAddBreakpointAsync( Task DebugSetVariableValueAsync(string variableName, string value); Task> DebugGetCallStackAsync(); - Task GetErrorListAsync(string? severity = null, int maxResults = 100); + Task GetErrorListAsync(ErrorListQuery query); Task ReadOutputPaneAsync(string paneIdentifier); Task WriteOutputPaneAsync(string paneIdentifier, string message, bool activate = false); Task> GetOutputPanesAsync(); diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index 6ae60c2..d0e84c7 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -281,8 +281,7 @@ public Task DebugSetBreakpointEnabledAsync(string file, int line, bool ena public Task DebugSetVariableValueAsync(string variableName, string value) => _vsService.DebugSetVariableValueAsync(variableName, value); public Task> DebugGetCallStackAsync() => _vsService.DebugGetCallStackAsync(); - public Task GetErrorListAsync(string? severity = null, int maxResults = 100) - => _vsService.GetErrorListAsync(severity, maxResults); + public Task GetErrorListAsync(ErrorListQuery query) => _vsService.GetErrorListAsync(query); public Task ReadOutputPaneAsync(string paneIdentifier) => _vsService.ReadOutputPaneAsync(paneIdentifier); public Task WriteOutputPaneAsync(string paneIdentifier, string message, bool activate = false) => _vsService.WriteOutputPaneAsync(paneIdentifier, message, activate); diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index bb3802b..5f98bc4 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -2165,7 +2165,7 @@ public async Task> DebugGetCallStackAsync() return results; } - public async Task GetErrorListAsync(string? severity = null, int maxResults = 100) + public async Task GetErrorListAsync(ErrorListQuery query) { var result = new ErrorListResult(); @@ -2208,12 +2208,29 @@ public async Task GetErrorListAsync(string? severity = null, in return result; } + // Entries is only what the window displays, so anything its own filters hide is invisible here + var notes = new List(); + var hiddenEntries = ErrorListFilter.DescribeHiddenEntries( + errorList.AreErrorsShown, + errorList.AreWarningsShown, + errorList.AreMessagesShown, + errorList.AreBuildErrorSourceEntriesShown, + errorList.AreOtherErrorSourceEntriesShown); + if (hiddenEntries != null) + { + notes.Add(hiddenEntries); + } + + var dte = await GetDteAsync(); + var filter = new ErrorListFilter(query, GetErrorListScopeContext(dte, query.Scope, notes)); + result = CollectErrorListEntries( tableControl.Entries, GetErrorListSeverity, - CreateErrorItem, - severity, - maxResults); + CreateErrorListEntry, + filter, + query.MaxResults); + result.Notes.AddRange(notes); } catch (Exception ex) { @@ -2230,18 +2247,20 @@ public async Task GetErrorListAsync(string? severity = null, in /// /// Counts every Error List entry by severity, and collects up to - /// of the entries that match . + /// 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. + /// how much was filtered out or cut off. Entries past the cap are counted but only read in full + /// when a filter other than severity needs their details to decide whether they match. + /// An entry whose severity cannot be read ( returns null) is skipped, + /// and one whose details cannot be read ( returns null) never matches. /// internal static ErrorListResult CollectErrorListEntries( IEnumerable entries, Func getSeverity, - Func createItem, - string? severityFilter, + Func createEntry, + ErrorListFilter filter, int maxResults) { var result = new ErrorListResult(); @@ -2260,25 +2279,31 @@ internal static ErrorListResult CollectErrorListEntries( else if (severity == "Warning") result.WarningCount++; else result.MessageCount++; - if (!string.IsNullOrEmpty(severityFilter) && - !severity.Equals(severityFilter, StringComparison.OrdinalIgnoreCase)) + if (!filter.MatchesSeverity(severity)) { continue; } - matchedCount++; - if (matchedCount > maxResults) + if (matchedCount >= maxResults && !filter.NeedsEntryDetails) { + matchedCount++; continue; } - var item = createItem(entry, severity); - if (item != null) + var errorListEntry = createEntry(entry, severity); + if (errorListEntry == null || !filter.Matches(errorListEntry)) { - result.Items.Add(item); + continue; + } + + matchedCount++; + if (matchedCount <= maxResults) + { + result.Items.Add(errorListEntry.Item); } } + result.MatchedCount = matchedCount; result.Truncated = matchedCount > maxResults; return result; } @@ -2307,20 +2332,81 @@ internal static ErrorListResult CollectErrorListEntries( } } - private static ErrorItemInfo? CreateErrorItem(ITableEntryHandle entry, string severity) + private static ErrorListEntry? CreateErrorListEntry(ITableEntryHandle entry, string severity) { try { var item = new ErrorItemInfo { Severity = severity }; + var projectNames = new List(); + var projectGuids = new List(); if (entry.TryGetValue(StandardTableKeyNames.ErrorCode, out object codeObj)) { item.ErrorCode = codeObj as string ?? ""; } - if (entry.TryGetValue(StandardTableKeyNames.ProjectName, out object projectObj)) + // An error in a shared file belongs to several projects, in which case ProjectName and + // ProjectGuid are null and the plural keys carry them instead + if (entry.TryGetValue(StandardTableKeyNames.ProjectName, out object projectObj) && + projectObj is string projectName && + projectName.Length > 0) + { + projectNames.Add(projectName); + } + + if (entry.TryGetValue(StandardTableKeyNames.ProjectNames, out object projectNamesObj) && + projectNamesObj is IEnumerable entryProjectNames) + { + foreach (var name in entryProjectNames) + { + if (!string.IsNullOrEmpty(name) && !projectNames.Contains(name)) + { + projectNames.Add(name); + } + } + } + + if (entry.TryGetValue(StandardTableKeyNames.ProjectGuid, out object projectGuidObj) && + projectGuidObj is Guid projectGuid && + projectGuid != Guid.Empty) + { + projectGuids.Add(projectGuid); + } + + if (entry.TryGetValue(StandardTableKeyNames.ProjectGuids, out object projectGuidsObj) && + projectGuidsObj is IEnumerable entryProjectGuids) + { + foreach (var guid in entryProjectGuids) + { + if (guid != Guid.Empty && !projectGuids.Contains(guid)) + { + projectGuids.Add(guid); + } + } + } + + item.Project = string.Join(", ", projectNames); + + if (entry.TryGetValue(StandardTableKeyNames.ErrorSource, out object sourceObj) && + sourceObj is ErrorSource errorSource) + { + item.Source = GetErrorSourceName(errorSource); + } + + if (entry.TryGetValue(StandardTableKeyNames.BuildTool, out object toolObj)) + { + item.Tool = toolObj as string ?? ""; + } + + if (entry.TryGetValue(StandardTableKeyNames.ErrorCategory, out object categoryObj)) { - item.Project = projectObj as string ?? ""; + item.Category = categoryObj as string ?? ""; + } + + if (entry.TryGetValue(StandardTableKeyNames.SuppressionState, out object suppressionObj) && + suppressionObj is SuppressionState suppressionState) + { + item.SuppressionState = GetSuppressionStateName(suppressionState); } if (entry.TryGetValue(StandardTableKeyNames.Text, out object textObj)) @@ -2343,7 +2429,7 @@ internal static ErrorListResult CollectErrorListEntries( item.Column = column; } - return item; + return new ErrorListEntry(item, projectNames, projectGuids); } catch (Exception ex) { @@ -2352,6 +2438,114 @@ internal static ErrorListResult CollectErrorListEntries( } } + internal static string GetErrorSourceName(ErrorSource source) => + source switch + { + ErrorSource.Build => ErrorListFilter.BuildSource, + ErrorSource.Other => ErrorListFilter.IntelliSenseSource, + _ => string.Empty + }; + + internal static string GetSuppressionStateName(SuppressionState state) => + state switch + { + SuppressionState.Active => nameof(ErrorListSuppressionState.Active), + SuppressionState.Suppressed => nameof(ErrorListSuppressionState.Suppressed), + SuppressionState.NotApplicable => nameof(ErrorListSuppressionState.NotApplicable), + _ => string.Empty + }; + + private ErrorListScopeContext GetErrorListScopeContext(DTE2 dte, ErrorListScope scope, List notes) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var context = new ErrorListScopeContext(); + + switch (scope) + { + case ErrorListScope.CurrentDocument: + { + context.ActiveDocumentPath = dte.ActiveDocument?.FullName; + if (context.ActiveDocumentPath == null) + { + notes.Add("No document is active, so the currentDocument scope matches nothing."); + } + + break; + } + + case ErrorListScope.OpenDocuments: + { + var paths = new List(); + foreach (Document doc in dte.Documents) + { + try + { + paths.Add(doc.FullName); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + + context.OpenDocumentPaths = paths; + if (paths.Count == 0) + { + notes.Add("No documents are open, so the openDocuments scope matches nothing."); + } + + break; + } + + case ErrorListScope.CurrentProject: + { + var activeDocument = dte.ActiveDocument; + if (activeDocument == null) + { + notes.Add("No document is active, so the currentProject scope matches nothing. It uses the project containing the active document."); + break; + } + + var project = activeDocument.ProjectItem?.ContainingProject; + if (project == null || + string.Equals(project.Kind, EnvDTE.Constants.vsProjectKindMisc, StringComparison.OrdinalIgnoreCase)) + { + notes.Add("The active document does not belong to a project, so the currentProject scope matches nothing."); + break; + } + + context.CurrentProjectName = project.Name; + context.CurrentProjectGuid = GetProjectGuid(project); + break; + } + } + + return context; + } + + private Guid GetProjectGuid(EnvDTE.Project project) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + if (ServiceProvider.GetService(typeof(SVsSolution)) is IVsSolution solution && + ErrorHandler.Succeeded(solution.GetProjectOfUniqueName(project.UniqueName, out var hierarchy)) && + ErrorHandler.Succeeded(solution.GetGuidOfProject(hierarchy, out var projectGuid))) + { + return projectGuid; + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + + // Matching falls back to the project name + return Guid.Empty; + } + public async Task> GetOutputPanesAsync() { var panes = new List();