diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs index ad3868a..a56fd77 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs @@ -32,11 +32,16 @@ public async Task GetDocumentSymbolsAsync( } [McpServerTool(Name = "symbol_workspace", ReadOnly = true)] - [Description("Search for symbols (classes, methods, properties, etc.) across the entire solution. Returns symbols matching the query with their locations. Useful for finding types or members by name.")] + [Description("Search for symbols (classes, methods, properties, etc.) across the entire solution. Returns symbols matching the query with their locations. Useful for finding types or members by name. The search stops as soon as it has found more than maxResults matches, so there is no total count; Truncated is true when more symbols matched than were returned. Narrow the query or raise maxResults to see the rest.")] public async Task SearchWorkspaceSymbolsAsync( [Description("The search query to match against symbol names. Case-insensitive. Partial matches are supported.")] string query, - [Description("Maximum number of results to return. Defaults to 100. Use lower values for faster results on large solutions.")] int maxResults = 100) + [Description("Maximum number of results to return. Defaults to 100 and must be at least 1. The search stops once it has enough, so lower values are faster on large solutions.")] int maxResults = 100) { + if (maxResults < 1) + { + return $"Invalid maxResults '{maxResults}'. It must be at least 1."; + } + var result = await _rpcClient.SearchWorkspaceSymbolsAsync(query, maxResults); if (result.Symbols.Count == 0) { diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs index a96265a..0fff543 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs @@ -48,7 +48,11 @@ public class LocationInfo public class WorkspaceSymbolResult { public List Symbols { get; set; } = new(); - public int TotalCount { get; set; } + + /// + /// True when more symbols matched than were returned. There is deliberately no total count: + /// the search stops as soon as this is known, rather than walking the whole solution. + /// public bool Truncated { get; set; } } diff --git a/src/CodingWithCalvin.MCPServer.Tests/WorkspaceSymbolSearchTests.cs b/src/CodingWithCalvin.MCPServer.Tests/WorkspaceSymbolSearchTests.cs new file mode 100644 index 0000000..8b1849a --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/WorkspaceSymbolSearchTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Linq; +using CodingWithCalvin.MCPServer.Services; +using CodingWithCalvin.MCPServer.Shared.Models; +using Xunit; + +namespace CodingWithCalvin.MCPServer.Tests; + +/// +/// Covers the matching and stopping rules behind symbol_workspace (issue #115). +/// +public class WorkspaceSymbolSearchTests +{ + [Theory] + [InlineData("GetOrders", "Shop.OrderService.GetOrders")] + [InlineData("getorders", "Shop.OrderService.getorders")] + [InlineData("Load", "Shop.OrderService.Load")] + public void Matches_IgnoresCase_OnNameOrFullName(string name, string fullName) + { + var search = new WorkspaceSymbolSearch("ORDER", maxResults: 10); + + Assert.True(search.Matches(name, fullName)); + } + + [Fact] + public void Matches_False_WhenNeitherNameNorFullNameContainsQuery() + { + var search = new WorkspaceSymbolSearch("Order", maxResults: 10); + + Assert.False(search.Matches("Load", "Shop.Inventory.Load")); + } + + [Fact] + public void IsComplete_OnlyOnceOneMatchPastTheCapIsFound() + { + var search = new WorkspaceSymbolSearch("Get", maxResults: 3); + + AddSymbols(search, 3); + Assert.False(search.IsComplete); + + AddSymbols(search, 1, first: 3); + Assert.True(search.IsComplete); + } + + [Fact] + public void ToResult_ReturnsFirstMaxResults_AndIsTruncated_WhenMoreMatched() + { + var search = new WorkspaceSymbolSearch("Get", maxResults: 3); + AddSymbols(search, 4); + + var result = search.ToResult(); + + Assert.Equal(new[] { "Get0", "Get1", "Get2" }, result.Symbols.Select(s => s.Name).ToArray()); + Assert.True(result.Truncated); + } + + [Fact] + public void ToResult_IsNotTruncated_WhenExactlyMaxResultsMatched() + { + var search = new WorkspaceSymbolSearch("Get", maxResults: 3); + AddSymbols(search, 3); + + var result = search.ToResult(); + + Assert.Equal(3, result.Symbols.Count); + Assert.False(result.Truncated); + } + + [Fact] + public void ToResult_IsEmpty_WhenNothingMatched() + { + var result = new WorkspaceSymbolSearch("Get", maxResults: 3).ToResult(); + + Assert.Empty(result.Symbols); + Assert.False(result.Truncated); + } + + [Fact] + public void Add_IgnoresSymbols_OnceComplete() + { + // A walk that kept going past IsComplete must not change the result. + var search = new WorkspaceSymbolSearch("Get", maxResults: 2); + AddSymbols(search, 10); + + var result = search.ToResult(); + + Assert.Equal(new[] { "Get0", "Get1" }, result.Symbols.Select(s => s.Name).ToArray()); + Assert.True(result.Truncated); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_Rejects_MaxResultsBelowOne(int maxResults) + { + Assert.Throws(() => new WorkspaceSymbolSearch("Get", maxResults)); + } + + [Fact] + public void MaxResults_OfIntMaxValue_DoesNotOverflow() + { + var search = new WorkspaceSymbolSearch("Get", int.MaxValue); + AddSymbols(search, 1); + + Assert.False(search.IsComplete); + Assert.False(search.ToResult().Truncated); + } + + private static void AddSymbols(WorkspaceSymbolSearch search, int count, int first = 0) + { + for (var i = first; i < first + count; i++) + { + search.Add(new SymbolInfo { Name = $"Get{i}", FullName = $"Shop.Service.Get{i}", Kind = SymbolKind.Function }); + } + } +} diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 7228101..f53c7dd 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -1066,25 +1066,24 @@ public async Task SearchWorkspaceSymbolsAsync(string quer { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); var dte = await GetDteAsync(); - var result = new WorkspaceSymbolResult(); - if (dte.Solution == null || string.IsNullOrWhiteSpace(query)) + if (dte.Solution == null || string.IsNullOrWhiteSpace(query) || maxResults < 1) { - return result; + return new WorkspaceSymbolResult(); } - var allSymbols = new List(); - var lowerQuery = query.ToLowerInvariant(); + var search = new WorkspaceSymbolSearch(query, maxResults); foreach (EnvDTE.Project project in dte.Solution.Projects) { + if (search.IsComplete) + { + break; + } + try { - CollectProjectSymbols(project.ProjectItems, allSymbols, lowerQuery, maxResults * 2); - if (allSymbols.Count >= maxResults * 2) - { - break; - } + CollectProjectSymbols(project.ProjectItems, search); } catch (Exception ex) { @@ -1092,41 +1091,36 @@ public async Task SearchWorkspaceSymbolsAsync(string quer } } - var matchingSymbols = allSymbols - .Where(s => s.Name.ToLowerInvariant().Contains(lowerQuery) || - s.FullName.ToLowerInvariant().Contains(lowerQuery)) - .Take(maxResults) - .ToList(); - - result.Symbols = matchingSymbols; - result.TotalCount = allSymbols.Count; - result.Truncated = allSymbols.Count > maxResults; - - return result; + return search.ToResult(); } - private void CollectProjectSymbols(ProjectItems? items, List allSymbols, string query, int limit) + private void CollectProjectSymbols(ProjectItems? items, WorkspaceSymbolSearch search) { ThreadHelper.ThrowIfNotOnUIThread(); - if (items == null || allSymbols.Count >= limit) + if (items == null) { return; } foreach (ProjectItem item in items) { + if (search.IsComplete) + { + return; + } + try { - if (item.FileCodeModel != null) + var codeModel = item.FileCodeModel; + if (codeModel != null) { - var filePath = item.FileNames[1]; - CollectCodeElements(item.FileCodeModel.CodeElements, allSymbols, filePath, string.Empty, query, limit); + CollectCodeElements(codeModel.CodeElements, search, item.FileNames[1], string.Empty); } if (item.ProjectItems != null && item.ProjectItems.Count > 0) { - CollectProjectSymbols(item.ProjectItems, allSymbols, query, limit); + CollectProjectSymbols(item.ProjectItems, search); } } catch (Exception ex) @@ -1136,17 +1130,21 @@ private void CollectProjectSymbols(ProjectItems? items, List allSymb } } - private void CollectCodeElements(CodeElements elements, List allSymbols, string filePath, string containerName, string query, int limit) + /// + /// Checks before every element, so the walk stops + /// at the match that settles the result rather than at the end of the current file or type. + /// + private void CollectCodeElements(CodeElements elements, WorkspaceSymbolSearch search, string filePath, string containerName) { ThreadHelper.ThrowIfNotOnUIThread(); - if (allSymbols.Count >= limit) - { - return; - } - foreach (CodeElement element in elements) { + if (search.IsComplete) + { + return; + } + try { var kind = MapElementKind(element.Kind); @@ -1155,15 +1153,12 @@ private void CollectCodeElements(CodeElements elements, List allSymb continue; } - var lowerName = element.Name.ToLowerInvariant(); - var lowerFullName = element.FullName.ToLowerInvariant(); - - if (lowerName.Contains(query) || lowerFullName.Contains(query)) + if (search.Matches(element.Name, element.FullName)) { var startPoint = element.StartPoint; var endPoint = element.EndPoint; - allSymbols.Add(new SymbolInfo + search.Add(new SymbolInfo { Name = element.Name, FullName = element.FullName, @@ -1180,7 +1175,7 @@ private void CollectCodeElements(CodeElements elements, List allSymb var childElements = GetChildElements(element); if (childElements != null) { - CollectCodeElements(childElements, allSymbols, filePath, element.Name, query, limit); + CollectCodeElements(childElements, search, filePath, element.Name); } } catch (Exception ex) diff --git a/src/CodingWithCalvin.MCPServer/Services/WorkspaceSymbolSearch.cs b/src/CodingWithCalvin.MCPServer/Services/WorkspaceSymbolSearch.cs new file mode 100644 index 0000000..91aaa53 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer/Services/WorkspaceSymbolSearch.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodingWithCalvin.MCPServer.Shared.Models; + +namespace CodingWithCalvin.MCPServer.Services; + +/// +/// Collects the symbols that match a workspace symbol query and decides when the search can stop. +/// +/// +/// +/// The search walks every file's code model on the UI thread, so it stops as soon as the result is +/// settled instead of counting every match. One match past maxResults is enough to know the +/// results were truncated. That is also why there is no total count: reporting one would mean +/// finishing the walk and freezing Visual Studio for as long as that takes. +/// +/// +/// Kept free of Visual Studio types so the matching and stopping rules can be unit tested. The +/// caller walks the code model and checks before each element. +/// +/// +internal sealed class WorkspaceSymbolSearch +{ + private readonly string _query; + private readonly int _maxResults; + private readonly int _limit; + private readonly List _symbols = new(); + + public WorkspaceSymbolSearch(string query, int maxResults) + { + if (maxResults < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxResults), maxResults, "At least one result must be requested."); + } + + _query = query; + _maxResults = maxResults; + + // One past the cap, so Truncated is exact. int.MaxValue cannot be exceeded anyway. + _limit = maxResults == int.MaxValue ? maxResults : maxResults + 1; + } + + /// + /// True once enough matches have been found to settle the result, so the walk can stop. + /// + public bool IsComplete => _symbols.Count >= _limit; + + public bool Matches(string name, string fullName) => + name.IndexOf(_query, StringComparison.OrdinalIgnoreCase) >= 0 + || fullName.IndexOf(_query, StringComparison.OrdinalIgnoreCase) >= 0; + + /// + /// Records a matching symbol. Ignored once the search is complete. + /// + public void Add(SymbolInfo symbol) + { + if (!IsComplete) + { + _symbols.Add(symbol); + } + } + + public WorkspaceSymbolResult ToResult() => new() + { + Symbols = _symbols.Take(_maxResults).ToList(), + Truncated = _symbols.Count > _maxResults, + }; +}