From 664c83b6fe5d273301646e1303a777f0d99c7f21 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 16 Jan 2026 10:08:12 -0500 Subject: [PATCH] feat(tools): add LSP-style navigation tools Add four navigation tools that mirror LSP protocol capabilities: - symbol_document: Get all symbols in a file using FileCodeModel - symbol_workspace: Search symbols across the entire solution - goto_definition: Navigate to symbol definition using Edit.GoToDefinition - find_references: Find all usages of a symbol via text search Includes new DTOs in SymbolModels.cs and implements the three-layer RPC pattern (NavigationTools -> RpcClient -> RpcServer -> VisualStudioService). --- .../RpcClient.cs | 10 +- .../Tools/NavigationTools.cs | 81 +++ .../Models/SymbolModels.cs | 70 +++ .../RpcContracts.cs | 5 + .../Services/IVisualStudioService.cs | 5 + .../Services/RpcServer.cs | 8 + .../Services/VisualStudioService.cs | 494 ++++++++++++++++++ 7 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs create mode 100644 src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 4616fc6..e45bae6 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -65,7 +65,7 @@ public Task> GetAvailableToolsAsync() } var tools = new List(); - var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools) }; + var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools) }; foreach (var toolType in toolTypes) { @@ -124,4 +124,12 @@ public Task> FindAsync(string searchText, bool matchCase, bool public Task CleanSolutionAsync() => Proxy.CleanSolutionAsync(); public Task CancelBuildAsync() => Proxy.CancelBuildAsync(); public Task GetBuildStatusAsync() => Proxy.GetBuildStatusAsync(); + + public Task> GetDocumentSymbolsAsync(string path) => Proxy.GetDocumentSymbolsAsync(path); + public Task SearchWorkspaceSymbolsAsync(string query, int maxResults = 100) + => Proxy.SearchWorkspaceSymbolsAsync(query, maxResults); + public Task GoToDefinitionAsync(string path, int line, int column) + => Proxy.GoToDefinitionAsync(path, line, column); + public Task FindReferencesAsync(string path, int line, int column, int maxResults = 100) + => Proxy.FindReferencesAsync(path, line, column, maxResults); } diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs new file mode 100644 index 0000000..ad3868a --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/NavigationTools.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; + +namespace CodingWithCalvin.MCPServer.Server.Tools; + +[McpServerToolType] +public class NavigationTools +{ + private readonly RpcClient _rpcClient; + private readonly JsonSerializerOptions _jsonOptions; + + public NavigationTools(RpcClient rpcClient) + { + _rpcClient = rpcClient; + _jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + } + + [McpServerTool(Name = "symbol_document", ReadOnly = true)] + [Description("Get all symbols (classes, methods, properties, etc.) defined in a file. Returns a hierarchical list of symbols with their names, kinds, and locations. The file must be part of a project in the open solution.")] + public async Task GetDocumentSymbolsAsync( + [Description("The full absolute path to the source file. Must be a file in a project within the open solution. Supports forward slashes (/) or backslashes (\\).")] string path) + { + var symbols = await _rpcClient.GetDocumentSymbolsAsync(path); + if (symbols.Count == 0) + { + return "No symbols found. The file may not be part of the solution or may not have a code model (only works with C#/VB files in projects)."; + } + + return JsonSerializer.Serialize(symbols, _jsonOptions); + } + + [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.")] + 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) + { + var result = await _rpcClient.SearchWorkspaceSymbolsAsync(query, maxResults); + if (result.Symbols.Count == 0) + { + return $"No symbols matching '{query}' found in the solution."; + } + + return JsonSerializer.Serialize(result, _jsonOptions); + } + + [McpServerTool(Name = "goto_definition", ReadOnly = true)] + [Description("Navigate to the definition of a symbol at a specific position in a file. Opens the file containing the definition and returns its location. Uses Visual Studio's 'Go To Definition' feature.")] + public async Task GoToDefinitionAsync( + [Description("The full absolute path to the source file containing the symbol reference. Supports forward slashes (/) or backslashes (\\).")] string path, + [Description("The line number (1-based) where the symbol reference is located.")] int line, + [Description("The column number (1-based) where the symbol reference is located. Position the cursor within or at the start of the symbol name.")] int column) + { + var result = await _rpcClient.GoToDefinitionAsync(path, line, column); + if (!result.Found) + { + return "Definition not found. The cursor may not be on a navigable symbol, or the definition may be in external/compiled code."; + } + + return JsonSerializer.Serialize(result, _jsonOptions); + } + + [McpServerTool(Name = "find_references", ReadOnly = true)] + [Description("Find all references to a symbol at a specific position in a file. Returns a list of locations where the symbol is used throughout the solution. Uses text-based search with word boundary matching.")] + public async Task FindReferencesAsync( + [Description("The full absolute path to the source file containing the symbol. Supports forward slashes (/) or backslashes (\\).")] string path, + [Description("The line number (1-based) where the symbol is located.")] int line, + [Description("The column number (1-based) where the symbol is located. Position within or at the start of the symbol name.")] int column, + [Description("Maximum number of references to return. Defaults to 100. Use lower values for faster results.")] int maxResults = 100) + { + var result = await _rpcClient.FindReferencesAsync(path, line, column, maxResults); + if (!result.Found) + { + return "No references found. The cursor may not be on a valid identifier."; + } + + return JsonSerializer.Serialize(result, _jsonOptions); + } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs new file mode 100644 index 0000000..a96265a --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/SymbolModels.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace CodingWithCalvin.MCPServer.Shared.Models; + +public enum SymbolKind +{ + Unknown, + Namespace, + Class, + Struct, + Interface, + Enum, + Delegate, + Function, + Property, + Field, + Event, + Variable, + Parameter, + EnumMember, + Constant +} + +public class SymbolInfo +{ + public string Name { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public SymbolKind Kind { get; set; } + public string FilePath { get; set; } = string.Empty; + public int StartLine { get; set; } + public int StartColumn { get; set; } + public int EndLine { get; set; } + public int EndColumn { get; set; } + public string ContainerName { get; set; } = string.Empty; + public List Children { get; set; } = new(); +} + +public class LocationInfo +{ + public string FilePath { get; set; } = string.Empty; + public int Line { get; set; } + public int Column { get; set; } + public int EndLine { get; set; } + public int EndColumn { get; set; } + public string Preview { get; set; } = string.Empty; +} + +public class WorkspaceSymbolResult +{ + public List Symbols { get; set; } = new(); + public int TotalCount { get; set; } + public bool Truncated { get; set; } +} + +public class DefinitionResult +{ + public bool Found { get; set; } + public List Definitions { get; set; } = new(); + public string SymbolName { get; set; } = string.Empty; + public SymbolKind SymbolKind { get; set; } +} + +public class ReferencesResult +{ + public bool Found { get; set; } + public List References { get; set; } = new(); + public string SymbolName { get; set; } = string.Empty; + public int TotalCount { get; set; } + public bool Truncated { get; set; } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index 8c5cb07..4875c6f 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -34,6 +34,11 @@ public interface IVisualStudioRpc Task CleanSolutionAsync(); Task CancelBuildAsync(); Task GetBuildStatusAsync(); + + Task> GetDocumentSymbolsAsync(string path); + Task SearchWorkspaceSymbolsAsync(string query, int maxResults = 100); + Task GoToDefinitionAsync(string path, int line, int column); + Task FindReferencesAsync(string path, int line, int column, int maxResults = 100); } /// diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index 3202428..230b68b 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -30,4 +30,9 @@ public interface IVisualStudioService Task CleanSolutionAsync(); Task CancelBuildAsync(); Task GetBuildStatusAsync(); + + Task> GetDocumentSymbolsAsync(string path); + Task SearchWorkspaceSymbolsAsync(string query, int maxResults = 100); + Task GoToDefinitionAsync(string path, int line, int column); + Task FindReferencesAsync(string path, int line, int column, int maxResults = 100); } diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index eba98e8..fc7789e 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -184,4 +184,12 @@ public Task> FindAsync(string searchText, bool matchCase, bool public Task CleanSolutionAsync() => _vsService.CleanSolutionAsync(); public Task CancelBuildAsync() => _vsService.CancelBuildAsync(); public Task GetBuildStatusAsync() => _vsService.GetBuildStatusAsync(); + + public Task> GetDocumentSymbolsAsync(string path) => _vsService.GetDocumentSymbolsAsync(path); + public Task SearchWorkspaceSymbolsAsync(string query, int maxResults = 100) + => _vsService.SearchWorkspaceSymbolsAsync(query, maxResults); + public Task GoToDefinitionAsync(string path, int line, int column) + => _vsService.GoToDefinitionAsync(path, line, column); + public Task FindReferencesAsync(string path, int line, int column, int maxResults = 100) + => _vsService.FindReferencesAsync(path, line, column, maxResults); } diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 010c8b4..e986a89 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Composition; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading.Tasks; using CodingWithCalvin.MCPServer.Shared.Models; using CodingWithCalvin.Otel4Vsix; @@ -547,4 +548,497 @@ public async Task GetBuildStatusAsync() FailedProjects = lastInfo }; } + + public async Task> GetDocumentSymbolsAsync(string path) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + var symbols = new List(); + + if (dte.Solution == null) + { + return symbols; + } + + var normalizedPath = NormalizePath(path); + var projectItem = dte.Solution.FindProjectItem(normalizedPath); + if (projectItem == null) + { + return symbols; + } + + var fileCodeModel = projectItem.FileCodeModel; + if (fileCodeModel == null) + { + return symbols; + } + + ExtractSymbols(fileCodeModel.CodeElements, symbols, normalizedPath, string.Empty); + return symbols; + } + + private void ExtractSymbols(CodeElements elements, List symbols, string filePath, string containerName) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + foreach (CodeElement element in elements) + { + try + { + var kind = MapElementKind(element.Kind); + if (kind == SymbolKind.Unknown) + { + if (element.Kind == vsCMElement.vsCMElementImportStmt || + element.Kind == vsCMElement.vsCMElementAttribute || + element.Kind == vsCMElement.vsCMElementParameter) + { + continue; + } + } + + var startPoint = element.StartPoint; + var endPoint = element.EndPoint; + + var symbolInfo = new SymbolInfo + { + Name = element.Name, + FullName = element.FullName, + Kind = kind, + FilePath = filePath, + StartLine = startPoint.Line, + StartColumn = startPoint.LineCharOffset, + EndLine = endPoint.Line, + EndColumn = endPoint.LineCharOffset, + ContainerName = containerName + }; + + var childElements = GetChildElements(element); + if (childElements != null && childElements.Count > 0) + { + ExtractSymbols(childElements, symbolInfo.Children, filePath, element.Name); + } + + symbols.Add(symbolInfo); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + } + + private static CodeElements? GetChildElements(CodeElement element) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + return element.Kind switch + { + vsCMElement.vsCMElementNamespace => ((CodeNamespace)element).Members, + vsCMElement.vsCMElementClass => ((CodeClass)element).Members, + vsCMElement.vsCMElementStruct => ((CodeStruct)element).Members, + vsCMElement.vsCMElementInterface => ((CodeInterface)element).Members, + vsCMElement.vsCMElementEnum => ((CodeEnum)element).Members, + _ => null + }; + } + catch + { + return null; + } + } + + private static SymbolKind MapElementKind(vsCMElement kind) => kind switch + { + vsCMElement.vsCMElementNamespace => SymbolKind.Namespace, + vsCMElement.vsCMElementClass => SymbolKind.Class, + vsCMElement.vsCMElementStruct => SymbolKind.Struct, + vsCMElement.vsCMElementInterface => SymbolKind.Interface, + vsCMElement.vsCMElementEnum => SymbolKind.Enum, + vsCMElement.vsCMElementFunction => SymbolKind.Function, + vsCMElement.vsCMElementProperty => SymbolKind.Property, + vsCMElement.vsCMElementVariable => SymbolKind.Field, + vsCMElement.vsCMElementEvent => SymbolKind.Event, + vsCMElement.vsCMElementDelegate => SymbolKind.Delegate, + _ => SymbolKind.Unknown + }; + + public async Task SearchWorkspaceSymbolsAsync(string query, int maxResults = 100) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + var result = new WorkspaceSymbolResult(); + + if (dte.Solution == null || string.IsNullOrWhiteSpace(query)) + { + return result; + } + + var allSymbols = new List(); + var lowerQuery = query.ToLowerInvariant(); + + foreach (EnvDTE.Project project in dte.Solution.Projects) + { + try + { + CollectProjectSymbols(project.ProjectItems, allSymbols, lowerQuery, maxResults * 2); + if (allSymbols.Count >= maxResults * 2) + { + break; + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + + 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; + } + + private void CollectProjectSymbols(ProjectItems? items, List allSymbols, string query, int limit) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (items == null || allSymbols.Count >= limit) + { + return; + } + + foreach (ProjectItem item in items) + { + try + { + if (item.FileCodeModel != null) + { + var filePath = item.FileNames[1]; + CollectCodeElements(item.FileCodeModel.CodeElements, allSymbols, filePath, string.Empty, query, limit); + } + + if (item.ProjectItems != null && item.ProjectItems.Count > 0) + { + CollectProjectSymbols(item.ProjectItems, allSymbols, query, limit); + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + } + + private void CollectCodeElements(CodeElements elements, List allSymbols, string filePath, string containerName, string query, int limit) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (allSymbols.Count >= limit) + { + return; + } + + foreach (CodeElement element in elements) + { + try + { + var kind = MapElementKind(element.Kind); + if (kind == SymbolKind.Unknown) + { + continue; + } + + var lowerName = element.Name.ToLowerInvariant(); + var lowerFullName = element.FullName.ToLowerInvariant(); + + if (lowerName.Contains(query) || lowerFullName.Contains(query)) + { + var startPoint = element.StartPoint; + var endPoint = element.EndPoint; + + allSymbols.Add(new SymbolInfo + { + Name = element.Name, + FullName = element.FullName, + Kind = kind, + FilePath = filePath, + StartLine = startPoint.Line, + StartColumn = startPoint.LineCharOffset, + EndLine = endPoint.Line, + EndColumn = endPoint.LineCharOffset, + ContainerName = containerName + }); + } + + var childElements = GetChildElements(element); + if (childElements != null) + { + CollectCodeElements(childElements, allSymbols, filePath, element.Name, query, limit); + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + } + + public async Task GoToDefinitionAsync(string path, int line, int column) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + var result = new DefinitionResult(); + + try + { + var opened = await OpenDocumentAsync(path); + if (!opened) + { + return result; + } + + var doc = dte.ActiveDocument; + if (doc == null) + { + return result; + } + + var textDoc = doc.Object("TextDocument") as TextDocument; + if (textDoc == null) + { + return result; + } + + textDoc.Selection.MoveToLineAndOffset(line, column); + + var originalPath = doc.FullName; + var originalLine = textDoc.Selection.ActivePoint.Line; + + dte.ExecuteCommand("Edit.GoToDefinition"); + + await Task.Delay(100); + + var newDoc = dte.ActiveDocument; + if (newDoc != null) + { + var newTextDoc = newDoc.Object("TextDocument") as TextDocument; + if (newTextDoc != null) + { + var newPath = newDoc.FullName; + var newLine = newTextDoc.Selection.ActivePoint.Line; + var newColumn = newTextDoc.Selection.ActivePoint.LineCharOffset; + + if (!PathsEqual(newPath, originalPath) || newLine != originalLine) + { + result.Found = true; + result.SymbolName = GetWordAtPosition(textDoc, line, column); + + var editPoint = newTextDoc.StartPoint.CreateEditPoint(); + editPoint.MoveToLineAndOffset(newLine, 1); + var lineText = editPoint.GetLines(newLine, newLine + 1).Trim(); + + result.Definitions.Add(new LocationInfo + { + FilePath = newPath, + Line = newLine, + Column = newColumn, + EndLine = newLine, + EndColumn = newColumn, + Preview = lineText + }); + } + } + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + + return result; + } + + private static string GetWordAtPosition(TextDocument textDoc, int line, int column) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + var editPoint = textDoc.StartPoint.CreateEditPoint(); + editPoint.MoveToLineAndOffset(line, column); + + var startPoint = editPoint.CreateEditPoint(); + startPoint.WordLeft(1); + var endPoint = editPoint.CreateEditPoint(); + endPoint.WordRight(1); + + return startPoint.GetText(endPoint).Trim(); + } + catch + { + return string.Empty; + } + } + + public async Task FindReferencesAsync(string path, int line, int column, int maxResults = 100) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + var result = new ReferencesResult(); + + try + { + var opened = await OpenDocumentAsync(path); + if (!opened) + { + return result; + } + + var doc = dte.ActiveDocument; + if (doc == null) + { + return result; + } + + var textDoc = doc.Object("TextDocument") as TextDocument; + if (textDoc == null) + { + return result; + } + + textDoc.Selection.MoveToLineAndOffset(line, column); + var symbolName = GetWordAtPosition(textDoc, line, column); + + if (string.IsNullOrWhiteSpace(symbolName)) + { + return result; + } + + result.SymbolName = symbolName; + + var references = await FindInSolutionAsync(dte, symbolName, maxResults); + result.References = references; + result.TotalCount = references.Count; + result.Found = references.Count > 0; + result.Truncated = references.Count >= maxResults; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + + return result; + } + + private async Task> FindInSolutionAsync(DTE2 dte, string searchText, int maxResults) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var locations = new List(); + + if (dte.Solution == null) + { + return locations; + } + + foreach (EnvDTE.Project project in dte.Solution.Projects) + { + try + { + await SearchProjectItemsAsync(project.ProjectItems, searchText, locations, maxResults); + if (locations.Count >= maxResults) + { + break; + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + + return locations; + } + + private async Task SearchProjectItemsAsync(ProjectItems? items, string searchText, List locations, int maxResults) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + if (items == null || locations.Count >= maxResults) + { + return; + } + + foreach (ProjectItem item in items) + { + try + { + if (item.FileNames[1] is string filePath && + (filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".vb", StringComparison.OrdinalIgnoreCase))) + { + var content = await Task.Run(() => + { + if (File.Exists(filePath)) + { + return File.ReadAllText(filePath); + } + return null; + }); + + if (content != null) + { + var lines = content.Split('\n'); + for (int i = 0; i < lines.Length && locations.Count < maxResults; i++) + { + var lineText = lines[i]; + var index = 0; + while ((index = lineText.IndexOf(searchText, index, StringComparison.Ordinal)) >= 0 && + locations.Count < maxResults) + { + if (IsWordBoundary(lineText, index, searchText.Length)) + { + locations.Add(new LocationInfo + { + FilePath = filePath, + Line = i + 1, + Column = index + 1, + EndLine = i + 1, + EndColumn = index + 1 + searchText.Length, + Preview = lineText.Trim() + }); + } + index += searchText.Length; + } + } + } + } + + if (item.ProjectItems != null && item.ProjectItems.Count > 0) + { + await SearchProjectItemsAsync(item.ProjectItems, searchText, locations, maxResults); + } + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + } + } + } + + private static bool IsWordBoundary(string text, int start, int length) + { + var beforeOk = start == 0 || !char.IsLetterOrDigit(text[start - 1]); + var afterOk = start + length >= text.Length || !char.IsLetterOrDigit(text[start + length]); + return beforeOk && afterOk; + } }