This is page 2 of 3. Use http://codebase.md/kooshi/sharptoolsmcp?page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .github
│ └── copilot-instructions.md
├── .gitignore
├── LICENSE
├── Prompts
│ ├── github-copilot-sharptools.prompt
│ └── identity.prompt
├── README.md
├── SharpTools.sln
├── SharpTools.SseServer
│ ├── Program.cs
│ └── SharpTools.SseServer.csproj
├── SharpTools.StdioServer
│ ├── Program.cs
│ └── SharpTools.StdioServer.csproj
└── SharpTools.Tools
├── Extensions
│ ├── ServiceCollectionExtensions.cs
│ └── SyntaxTreeExtensions.cs
├── GlobalUsings.cs
├── Interfaces
│ ├── ICodeAnalysisService.cs
│ ├── ICodeModificationService.cs
│ ├── IComplexityAnalysisService.cs
│ ├── IDocumentOperationsService.cs
│ ├── IEditorConfigProvider.cs
│ ├── IFuzzyFqnLookupService.cs
│ ├── IGitService.cs
│ ├── ISemanticSimilarityService.cs
│ ├── ISolutionManager.cs
│ └── ISourceResolutionService.cs
├── Mcp
│ ├── ContextInjectors.cs
│ ├── ErrorHandlingHelpers.cs
│ ├── Prompts.cs
│ ├── ToolHelpers.cs
│ └── Tools
│ ├── AnalysisTools.cs
│ ├── DocumentTools.cs
│ ├── MemberAnalysisHelper.cs
│ ├── MiscTools.cs
│ ├── ModificationTools.cs
│ ├── PackageTools.cs
│ └── SolutionTools.cs
├── Services
│ ├── ClassSemanticFeatures.cs
│ ├── ClassSimilarityResult.cs
│ ├── CodeAnalysisService.cs
│ ├── CodeModificationService.cs
│ ├── ComplexityAnalysisService.cs
│ ├── DocumentOperationsService.cs
│ ├── EditorConfigProvider.cs
│ ├── EmbeddedSourceReader.cs
│ ├── FuzzyFqnLookupService.cs
│ ├── GitService.cs
│ ├── LegacyNuGetPackageReader.cs
│ ├── MethodSemanticFeatures.cs
│ ├── MethodSimilarityResult.cs
│ ├── NoOpGitService.cs
│ ├── PathInfo.cs
│ ├── SemanticSimilarityService.cs
│ ├── SolutionManager.cs
│ └── SourceResolutionService.cs
└── SharpTools.Tools.csproj
```
# Files
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/SourceResolutionService.cs:
--------------------------------------------------------------------------------
```csharp
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Text;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.TypeSystem;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Interfaces;
namespace SharpTools.Tools.Services {
public class SourceResolutionService : ISourceResolutionService {
private readonly ISolutionManager _solutionManager;
private readonly ILogger<SourceResolutionService> _logger;
private readonly HttpClient _httpClient;
public SourceResolutionService(ISolutionManager solutionManager, ILogger<SourceResolutionService> logger) {
_solutionManager = solutionManager ?? throw new ArgumentNullException(nameof(solutionManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = new HttpClient();
}
public async Task<SourceResult?> ResolveSourceAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken) {
if (symbol == null) {
_logger.LogWarning("Cannot resolve source: Symbol is null");
return null;
}
// 1. Try to get from syntax references (source available)
if (symbol.DeclaringSyntaxReferences.Length > 0) {
var syntaxRef = symbol.DeclaringSyntaxReferences[0];
var sourceText = await syntaxRef.GetSyntaxAsync(cancellationToken);
if (sourceText != null) {
var tree = syntaxRef.SyntaxTree;
return new SourceResult {
Source = sourceText.ToString(),
FilePath = tree.FilePath,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Local Source"
};
}
}
// 2. Try Source Link
var sourceLinkResult = await TrySourceLinkAsync(symbol, cancellationToken);
if (sourceLinkResult != null) return sourceLinkResult;
// 3. Try embedded source
var embeddedResult = await TryEmbeddedSourceAsync(symbol, cancellationToken);
if (embeddedResult != null) return embeddedResult;
// 4. Try decompilation as fallback
var decompiledResult = await TryDecompilationAsync(symbol, cancellationToken);
if (decompiledResult != null) return decompiledResult;
return null;
}
public async Task<SourceResult?> TrySourceLinkAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken) {
_logger.LogInformation("Attempting to retrieve source via Source Link for {SymbolName}", symbol.Name);
try {
// Get location of the assembly containing the symbol
var assembly = symbol.ContainingAssembly;
if (assembly == null) {
_logger.LogWarning("No containing assembly found for symbol {SymbolName}", symbol.Name);
return null;
}
// Find the PE reference for this assembly
var metadataReference = GetMetadataReferenceForAssembly(assembly);
if (metadataReference == null) {
_logger.LogWarning("No metadata reference found for assembly {AssemblyName}", assembly.Name);
return null;
}
// Check for PDB adjacent to the DLL
var dllPath = metadataReference.Display;
if (string.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) {
_logger.LogWarning("Assembly file not found: {DllPath}", dllPath);
return null;
}
var pdbPath = Path.ChangeExtension(dllPath, ".pdb");
if (!File.Exists(pdbPath)) {
_logger.LogWarning("PDB file not found: {PdbPath}", pdbPath);
return null;
}
_logger.LogInformation("Found PDB file: {PdbPath}", pdbPath);
// Open the PDB and look for Source Link information
using var pdbStream = File.OpenRead(pdbPath);
using var metadataReaderProvider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
var metadataReader = metadataReaderProvider.GetMetadataReader();
// Extract Source Link JSON document
string? sourceLinkJson = null;
foreach (var customDebugInfoHandle in metadataReader.CustomDebugInformation) {
var customDebugInfo = metadataReader.GetCustomDebugInformation(customDebugInfoHandle);
var kind = metadataReader.GetGuid(customDebugInfo.Kind);
// Source Link kind GUID
if (kind == new Guid("CC110556-A091-4D38-9FEC-25AB9A351A6A")) {
var blobReader = metadataReader.GetBlobReader(customDebugInfo.Value);
sourceLinkJson = Encoding.UTF8.GetString(blobReader.ReadBytes(blobReader.Length));
break;
}
}
if (string.IsNullOrEmpty(sourceLinkJson)) {
_logger.LogWarning("No Source Link information found in PDB");
return null;
}
_logger.LogInformation("Found Source Link JSON: {Json}", sourceLinkJson);
// Parse the JSON and extract source URLs
var sourceLinkDoc = System.Text.Json.JsonDocument.Parse(sourceLinkJson);
var urlsElement = sourceLinkDoc.RootElement.GetProperty("documents");
// Get the document containing our symbol
string symbolDocumentPath = GetSymbolDocumentPath(symbol);
if (string.IsNullOrEmpty(symbolDocumentPath)) {
_logger.LogWarning("Could not determine document path for symbol {SymbolName}", symbol.Name);
return null;
}
// Normalize path for comparison with Source Link entries
symbolDocumentPath = symbolDocumentPath.Replace('\\', '/');
// Find matching URL in Source Link data
string? sourceUrl = null;
foreach (var property in urlsElement.EnumerateObject()) {
var pattern = property.Name;
var url = property.Value.GetString();
// Source Link uses wildcard patterns like "C:/Projects/*" -> "https://raw.githubusercontent.com/user/repo/*"
if (IsPathMatch(symbolDocumentPath, pattern) && !string.IsNullOrEmpty(url)) {
// Replace the wildcard part in the URL
sourceUrl = url.Replace("*", GetWildcardMatch(symbolDocumentPath, pattern));
break;
}
}
if (string.IsNullOrEmpty(sourceUrl)) {
_logger.LogWarning("No matching source URL found for document {Path}", symbolDocumentPath);
return null;
}
// Download the source from the URL
_logger.LogInformation("Downloading source from URL: {Url}", sourceUrl);
var sourceCode = await _httpClient.GetStringAsync(sourceUrl, cancellationToken);
return new SourceResult {
Source = sourceCode,
FilePath = sourceUrl,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Source Link"
};
} catch (Exception ex) {
_logger.LogError(ex, "Error retrieving source via Source Link for {SymbolName}", symbol.Name);
return null;
}
}
public async Task<SourceResult?> TryEmbeddedSourceAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken) {
_logger.LogInformation("Attempting to retrieve embedded source for {SymbolName}", symbol.Name);
try {
cancellationToken.ThrowIfCancellationRequested();
// Get location of the assembly containing the symbol
var assembly = symbol.ContainingAssembly;
if (assembly == null) {
_logger.LogWarning("No containing assembly found for symbol {SymbolName}", symbol.Name);
return null;
}
// Find the PE reference for this assembly
var metadataReference = GetMetadataReferenceForAssembly(assembly);
if (metadataReference == null) {
_logger.LogWarning("No metadata reference found for assembly {AssemblyName}", assembly.Name);
return null;
}
// Get the assembly path
var assemblyPath = metadataReference.Display;
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath)) {
_logger.LogWarning("Assembly file not found: {AssemblyPath}", assemblyPath);
return null;
}
_logger.LogInformation("Checking for embedded source in assembly: {AssemblyPath}", assemblyPath);
// Get embedded source information for this symbol
var embeddedSourceInfo = EmbeddedSourceReader.GetEmbeddedSourceForSymbol(symbol);
if (embeddedSourceInfo == null) {
_logger.LogInformation("No embedded source info available for {SymbolName}", symbol.Name);
return null;
}
// Check for PDB embedded in the assembly
Dictionary<string, EmbeddedSourceReader.SourceResult> embeddedSources = new();
try {
embeddedSources = await Task.Run(() => EmbeddedSourceReader.ReadEmbeddedSourcesFromAssembly(assemblyPath), cancellationToken);
} catch (Exception ex) {
_logger.LogDebug(ex, "Error reading embedded sources from assembly: {AssemblyPath}", assemblyPath);
// Continue to check standalone PDB
}
// If no embedded sources found, check for standalone PDB
if (!embeddedSources.Any()) {
var pdbPath = Path.ChangeExtension(assemblyPath, ".pdb");
if (File.Exists(pdbPath)) {
_logger.LogInformation("Checking standalone PDB file: {PdbPath}", pdbPath);
try {
// Read embedded sources in a background task to avoid blocking
embeddedSources = await Task.Run(() => EmbeddedSourceReader.ReadEmbeddedSources(pdbPath), cancellationToken);
} catch (Exception ex) {
_logger.LogDebug(ex, "Error reading embedded sources from PDB: {PdbPath}", pdbPath);
}
}
}
if (!embeddedSources.Any()) {
_logger.LogInformation("No embedded sources found in assembly or PDB for {SymbolName}", symbol.Name);
return null;
}
// Try to find matching source based on file name
string symbolFileName = embeddedSourceInfo.FilePath ?? string.Empty;
// Try exact match first
if (!string.IsNullOrEmpty(symbolFileName) && embeddedSources.TryGetValue(symbolFileName, out var exactMatch)) {
_logger.LogInformation("Found exact matching source file: {FileName}", symbolFileName);
return new SourceResult {
Source = exactMatch.SourceCode ?? string.Empty,
FilePath = symbolFileName,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Embedded Source (Exact Match)"
};
}
// Try filename match (ignoring path)
string fileNameOnly = Path.GetFileName(symbolFileName);
foreach (var source in embeddedSources) {
string sourceFileName = Path.GetFileName(source.Key);
if (string.Equals(sourceFileName, fileNameOnly, StringComparison.OrdinalIgnoreCase)) {
_logger.LogInformation("Found matching source file by name: {FileName}", sourceFileName);
return new SourceResult {
Source = source.Value.SourceCode ?? string.Empty,
FilePath = source.Key,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Embedded Source (Filename Match)"
};
}
}
// If the symbol is a method, property, etc., try to find its containing type
if (symbol.ContainingType != null) {
string containingTypeName = symbol.ContainingType.Name + ".cs";
foreach (var source in embeddedSources) {
string sourceFileName = Path.GetFileName(source.Key);
if (string.Equals(sourceFileName, containingTypeName, StringComparison.OrdinalIgnoreCase)) {
_logger.LogInformation("Found source file for containing type: {TypeName}", symbol.ContainingType.Name);
return new SourceResult {
Source = source.Value.SourceCode ?? string.Empty,
FilePath = source.Key,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Embedded Source (Containing Type)"
};
}
}
}
// If we still don't have a match and there's just one source file, use it
// This is common for small libraries with a single source file
if (embeddedSources.Count == 1) {
var singleSource = embeddedSources.First();
_logger.LogInformation("Using single available source file: {FileName}", singleSource.Key);
return new SourceResult {
Source = singleSource.Value.SourceCode ?? string.Empty,
FilePath = singleSource.Key,
IsOriginalSource = true,
IsDecompiled = false,
ResolutionMethod = "Embedded Source (Single File)"
};
}
_logger.LogWarning("No matching embedded source found for symbol {SymbolName} among {Count} available files",
symbol.Name, embeddedSources.Count);
return null;
} catch (Exception ex) {
_logger.LogError(ex, "Error retrieving embedded source for {SymbolName}", symbol.Name);
return null;
}
}
public async Task<SourceResult?> TryDecompilationAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken) {
_logger.LogInformation("Attempting decompilation for {SymbolName}", symbol.Name);
try {
// Get location of the assembly containing the symbol
var assembly = symbol.ContainingAssembly;
if (assembly == null) {
_logger.LogWarning("No containing assembly found for symbol {SymbolName}", symbol.Name);
return null;
}
// Find the PE reference for this assembly
var metadataReference = GetMetadataReferenceForAssembly(assembly);
if (metadataReference == null) {
_logger.LogWarning("No metadata reference found for assembly {AssemblyName}", assembly.Name);
return null;
}
var assemblyPath = metadataReference.Display;
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath)) {
_logger.LogWarning("Assembly file not found: {AssemblyPath}", assemblyPath);
return null;
}
_logger.LogInformation("Decompiling from assembly: {AssemblyPath}", assemblyPath);
// Create settings for the decompiler
var decompilerSettings = new DecompilerSettings {
ThrowOnAssemblyResolveErrors = false,
UseExpressionBodyForCalculatedGetterOnlyProperties = true,
UsingDeclarations = true,
NullPropagation = true,
AlwaysUseBraces = true,
RemoveDeadCode = true
};
// Decompilation can be CPU intensive, so run it in a background task
return await Task.Run(() => {
try {
cancellationToken.ThrowIfCancellationRequested();
// Create the decompiler
var decompiler = new CSharpDecompiler(assemblyPath, decompilerSettings);
// Process based on symbol type
string? typeFullName = null;
string? memberName = null;
if (symbol is Microsoft.CodeAnalysis.INamedTypeSymbol namedType) {
typeFullName = namedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
} else if (symbol is Microsoft.CodeAnalysis.IMethodSymbol method) {
typeFullName = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
memberName = method.Name;
} else if (symbol is Microsoft.CodeAnalysis.IPropertySymbol property) {
typeFullName = property.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
memberName = property.Name;
} else if (symbol is Microsoft.CodeAnalysis.IFieldSymbol field) {
typeFullName = field.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
memberName = field.Name;
} else if (symbol is Microsoft.CodeAnalysis.IEventSymbol eventSymbol) {
typeFullName = eventSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
memberName = eventSymbol.Name;
}
if (string.IsNullOrEmpty(typeFullName)) {
_logger.LogWarning("Could not determine type name for symbol {SymbolName}", symbol.Name);
return null;
}
// Clean up the type name for the decompiler
typeFullName = typeFullName.Replace("global::", "")
.Replace("<", "{")
.Replace(">", "}");
try {
// Try to decompile the type or member
string decompiled;
if (string.IsNullOrEmpty(memberName)) {
// Decompile entire type
decompiled = decompiler.DecompileTypeAsString(new FullTypeName(typeFullName));
} else {
// Decompile specific member
var typeDef = decompiler.TypeSystem.FindType(new FullTypeName(typeFullName))?.GetDefinition();
if (typeDef == null) {
_logger.LogWarning("Could not find type definition for {TypeName}", typeFullName);
return null;
}
var memberDef = typeDef.Members.FirstOrDefault(m => m.Name == memberName);
if (memberDef == null) {
_logger.LogWarning("Could not find member {MemberName} in type {TypeName}", memberName, typeFullName);
return null;
}
decompiled = decompiler.DecompileAsString(memberDef.MetadataToken);
}
return new SourceResult {
Source = decompiled,
FilePath = $"{typeFullName}.cs (decompiled)",
IsOriginalSource = false,
IsDecompiled = true,
ResolutionMethod = "Decompilation"
};
} catch (Exception ex) {
_logger.LogWarning(ex, "Error during specific decompilation for {SymbolName}, falling back to full type decompilation", symbol.Name);
// Fallback: try to decompile just the containing type
try {
cancellationToken.ThrowIfCancellationRequested();
var containingTypeFullName = symbol.ContainingType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (!string.IsNullOrEmpty(containingTypeFullName)) {
containingTypeFullName = containingTypeFullName.Replace("global::", "")
.Replace("<", "{")
.Replace(">", "}");
var decompiled = decompiler.DecompileTypeAsString(new FullTypeName(containingTypeFullName));
return new SourceResult {
Source = decompiled,
FilePath = $"{containingTypeFullName}.cs (decompiled)",
IsOriginalSource = false,
IsDecompiled = true,
ResolutionMethod = "Decompilation (Fallback)"
};
}
} catch (Exception innerEx) {
_logger.LogError(innerEx, "Fallback decompilation failed for {SymbolName}", symbol.Name);
}
}
return null;
} catch (Exception ex) {
_logger.LogError(ex, "Error during decompilation for {SymbolName}", symbol.Name);
return null;
}
}, cancellationToken);
} catch (Exception ex) {
_logger.LogError(ex, "Error during decompilation for {SymbolName}", symbol.Name);
return null;
}
}
#region Helper Methods
private PortableExecutableReference? GetMetadataReferenceForAssembly(IAssemblySymbol assembly) {
if (!_solutionManager.IsSolutionLoaded) {
_logger.LogWarning("Cannot get metadata reference: Solution not loaded");
return null;
}
foreach (var project in _solutionManager.GetProjects()) {
foreach (var reference in project.MetadataReferences.OfType<PortableExecutableReference>()) {
if (Path.GetFileNameWithoutExtension(reference.FilePath) == assembly.Name) {
return reference;
}
}
}
return null;
}
private string GetSymbolDocumentPath(Microsoft.CodeAnalysis.ISymbol symbol) {
// For symbols with syntax references, get the file path directly
if (symbol.DeclaringSyntaxReferences.Length > 0) {
var syntaxRef = symbol.DeclaringSyntaxReferences[0];
return syntaxRef.SyntaxTree.FilePath;
}
// For metadata symbols, try to infer document path
// This is a simplistic approach and might not work in all cases
return $"{symbol.ContainingType?.Name ?? symbol.Name}.cs";
}
private bool IsPathMatch(string path, string pattern) {
// Source Link uses patterns with * wildcards
if (!pattern.Contains('*')) {
return string.Equals(path, pattern, StringComparison.OrdinalIgnoreCase);
}
// Simple wildcard matching for patterns like "C:/Projects/*"
var prefix = pattern.Substring(0, pattern.IndexOf('*'));
var suffix = pattern.Substring(pattern.IndexOf('*') + 1);
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(suffix.Length == 0 || path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
}
private string GetWildcardMatch(string path, string pattern) {
var prefix = pattern.Substring(0, pattern.IndexOf('*'));
var suffix = pattern.Substring(pattern.IndexOf('*') + 1);
if (suffix.Length == 0) {
return path.Substring(prefix.Length);
}
return path.Substring(prefix.Length, path.Length - prefix.Length - suffix.Length);
}
#endregion
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/CodeModificationService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Services;
public class CodeModificationService : ICodeModificationService {
private readonly ISolutionManager _solutionManager;
private readonly IGitService _gitService;
private readonly ILogger<CodeModificationService> _logger;
public CodeModificationService(
ISolutionManager solutionManager,
IGitService gitService,
ILogger<CodeModificationService> logger) {
_solutionManager = solutionManager ?? throw new ArgumentNullException(nameof(solutionManager));
_gitService = gitService ?? throw new ArgumentNullException(nameof(gitService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private Solution GetCurrentSolutionOrThrow() {
if (!_solutionManager.IsSolutionLoaded) {
throw new InvalidOperationException("No solution is currently loaded.");
}
return _solutionManager.CurrentSolution;
}
public async Task<Solution> AddMemberAsync(DocumentId documentId, INamedTypeSymbol targetTypeSymbol, MemberDeclarationSyntax newMember, int lineNumberHint = -1, CancellationToken cancellationToken = default) {
var solution = GetCurrentSolutionOrThrow();
var document = solution.GetDocument(documentId) ?? throw new ArgumentException($"Document with ID '{documentId}' not found in the current solution.", nameof(documentId));
var typeDeclarationNode = targetTypeSymbol.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(cancellationToken) as TypeDeclarationSyntax;
if (typeDeclarationNode == null) {
throw new InvalidOperationException($"Could not find syntax node for type '{targetTypeSymbol.Name}'.");
}
_logger.LogInformation("Adding member to type {TypeName} in document {DocumentPath}", targetTypeSymbol.Name, document.FilePath);
NormalizeMemberDeclarationTrivia(newMember);
if (lineNumberHint > 0) {
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root != null) {
var sourceText = await document.GetTextAsync(cancellationToken);
var members = typeDeclarationNode.Members
.Select(member => new {
Member = member,
LineSpan = member.GetLocation().GetLineSpan()
})
.OrderBy(m => m.LineSpan.StartLinePosition.Line)
.ToList();
int insertIndex = 0;
for (int i = 0; i < members.Count; i++) {
if (members[i].LineSpan.StartLinePosition.Line >= lineNumberHint) {
insertIndex = i;
break;
}
insertIndex = i + 1;
}
var editor = await DocumentEditor.CreateAsync(document, cancellationToken);
var membersList = typeDeclarationNode.Members.ToList();
membersList.Insert(insertIndex, newMember);
var newTypeDeclaration = typeDeclarationNode.WithMembers(SyntaxFactory.List(membersList));
editor.ReplaceNode(typeDeclarationNode, newTypeDeclaration);
var newDocument = editor.GetChangedDocument();
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
return formattedDocument.Project.Solution;
}
}
var defaultEditor = await DocumentEditor.CreateAsync(document, cancellationToken);
defaultEditor.AddMember(typeDeclarationNode, newMember);
var changedDocument = defaultEditor.GetChangedDocument();
var finalDocument = await FormatDocumentAsync(changedDocument, cancellationToken);
return finalDocument.Project.Solution;
}
public async Task<Solution> AddStatementAsync(DocumentId documentId, MethodDeclarationSyntax targetMethodNode, StatementSyntax newStatement, CancellationToken cancellationToken, bool addToBeginning = false) {
var solution = GetCurrentSolutionOrThrow();
var document = solution.GetDocument(documentId) ?? throw new ArgumentException($"Document with ID '{documentId}' not found in the current solution.", nameof(documentId));
_logger.LogInformation("Adding statement to method {MethodName} in document {DocumentPath}", targetMethodNode.Identifier.Text, document.FilePath);
var editor = await DocumentEditor.CreateAsync(document, cancellationToken);
if (targetMethodNode.Body != null) {
var currentBody = targetMethodNode.Body;
BlockSyntax newBody;
if (addToBeginning) {
var newStatements = currentBody.Statements.Insert(0, newStatement);
newBody = currentBody.WithStatements(newStatements);
} else {
newBody = currentBody.AddStatements(newStatement);
}
editor.ReplaceNode(currentBody, newBody);
} else if (targetMethodNode.ExpressionBody != null) {
// Converting expression body to block body
var returnStatement = SyntaxFactory.ReturnStatement(targetMethodNode.ExpressionBody.Expression);
BlockSyntax bodyBlock;
if (addToBeginning) {
bodyBlock = SyntaxFactory.Block(newStatement, returnStatement);
} else {
bodyBlock = SyntaxFactory.Block(returnStatement, newStatement);
}
// Create a new method node with the block body
var newMethod = targetMethodNode.WithBody(bodyBlock)
.WithExpressionBody(null) // Remove expression body
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.None)); // Remove semicolon if any
editor.ReplaceNode(targetMethodNode, newMethod);
} else {
// Method has no body (e.g. abstract, partial, extern). Create one.
var bodyBlock = SyntaxFactory.Block(newStatement);
var newMethodWithBody = targetMethodNode.WithBody(bodyBlock)
.WithExpressionBody(null)
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.None));
editor.ReplaceNode(targetMethodNode, newMethodWithBody);
}
var newDocument = editor.GetChangedDocument();
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
return formattedDocument.Project.Solution;
}
private static SyntaxTrivia newline = SyntaxFactory.EndOfLine("\n");
private SyntaxTriviaList NormalizeLeadingTrivia(SyntaxTriviaList trivia) {
// Remove all newlines
var filtered = trivia.Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia));
// Create a new list with a newline at the beginning followed by the filtered trivia
return SyntaxFactory.TriviaList(newline, newline).AddRange(filtered);
}
private SyntaxTriviaList NormalizeTrailingTrivia(SyntaxTriviaList trivia) {
// Remove all newlines
var filtered = trivia.Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia));
// Create a new SyntaxTriviaList from the filtered items
var result = SyntaxFactory.TriviaList(filtered);
// Add the end-of-line trivia and return
return result.Add(newline).Add(newline);
}
private SyntaxNode NormalizeMemberDeclarationTrivia(SyntaxNode member) {
if (member is MemberDeclarationSyntax memberDeclaration) {
var leadingTrivia = memberDeclaration.GetLeadingTrivia();
var trailingTrivia = memberDeclaration.GetTrailingTrivia();
// Normalize trivia
var normalizedLeading = NormalizeLeadingTrivia(leadingTrivia);
var normalizedTrailing = NormalizeTrailingTrivia(trailingTrivia);
// Apply the normalized trivia
return memberDeclaration.WithLeadingTrivia(normalizedLeading)
.WithTrailingTrivia(normalizedTrailing);
}
return member;
}
public async Task<Solution> ReplaceNodeAsync(DocumentId documentId, SyntaxNode oldNode, SyntaxNode newNode, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
var document = solution.GetDocument(documentId) ?? throw new ArgumentException($"Document with ID '{documentId}' not found in the current solution.", nameof(documentId));
_logger.LogInformation("Replacing node in document {DocumentPath}", document.FilePath);
// Check if this is a deletion operation (newNode is an EmptyStatement with a delete comment)
bool isDeleteOperation = newNode is Microsoft.CodeAnalysis.CSharp.Syntax.EmptyStatementSyntax emptyStmt &&
emptyStmt.HasLeadingTrivia &&
emptyStmt.GetLeadingTrivia().Any(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) &&
t.ToString().StartsWith("// Delete", StringComparison.OrdinalIgnoreCase));
if (isDeleteOperation) {
_logger.LogInformation("Detected deletion operation for node {NodeKind}", oldNode.Kind());
// For deletion, we need to remove the node from its parent
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root == null) {
throw new InvalidOperationException("Could not get syntax root for document.");
}
// Different approach based on the node's parent context
SyntaxNode newRoot;
if (oldNode.Parent is Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax compilationUnit) {
// Handle top-level members in the compilation unit
if (oldNode is Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax memberToRemove) {
var newMembers = compilationUnit.Members.Remove(memberToRemove);
newRoot = compilationUnit.WithMembers(newMembers);
} else {
throw new InvalidOperationException($"Cannot delete node of type {oldNode.GetType().Name} directly from compilation unit.");
}
} else if (oldNode.Parent is Microsoft.CodeAnalysis.CSharp.Syntax.NamespaceDeclarationSyntax namespaceDecl) {
// Handle members in a namespace
if (oldNode is Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax memberToRemove) {
var newMembers = namespaceDecl.Members.Remove(memberToRemove);
var newNamespace = namespaceDecl.WithMembers(newMembers);
newRoot = root.ReplaceNode(namespaceDecl, newNamespace);
} else {
throw new InvalidOperationException($"Cannot delete node of type {oldNode.GetType().Name} from namespace declaration.");
}
} else if (oldNode.Parent is Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax typeDecl) {
// Handle members in a type declaration (class, struct, interface, etc.)
if (oldNode is Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax memberToRemove) {
var newMembers = typeDecl.Members.Remove(memberToRemove);
var newType = typeDecl.WithMembers(newMembers);
newRoot = root.ReplaceNode(typeDecl, newType);
} else {
throw new InvalidOperationException($"Cannot delete node of type {oldNode.GetType().Name} from type declaration.");
}
} else {
throw new InvalidOperationException($"Cannot delete node of type {oldNode.GetType().Name} from parent of type {oldNode.Parent?.GetType().Name ?? "null"}.");
}
var newDocument = document.WithSyntaxRoot(newRoot);
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
return formattedDocument.Project.Solution;
} else {
// Standard node replacement
NormalizeMemberDeclarationTrivia(newNode);
var editor = await DocumentEditor.CreateAsync(document, cancellationToken);
editor.ReplaceNode(oldNode, newNode);
var newDocument = editor.GetChangedDocument();
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
return formattedDocument.Project.Solution;
}
}
public async Task<Solution> RenameSymbolAsync(ISymbol symbol, string newName, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogInformation("Renaming symbol {SymbolName} to {NewName}", symbol.ToDisplayString(), newName);
var options = solution.Workspace.Options;
// Using the older API for now
// Temporarily disable obsolete warning
#pragma warning disable CS0618
var newSolution = await Renamer.RenameSymbolAsync(solution, symbol, newName, options, cancellationToken);
#pragma warning restore CS0618
return newSolution;
}
public async Task<Solution> ReplaceAllReferencesAsync(ISymbol symbol, string replacementText, CancellationToken cancellationToken, Func<SyntaxNode, bool>? predicateFilter = null) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogInformation("Replacing all references to symbol {SymbolName} with text '{ReplacementText}'",
symbol.ToDisplayString(), replacementText);
// Find all references to the symbol
var referencedSymbols = await SymbolFinder.FindReferencesAsync(symbol, solution, cancellationToken);
var changedSolution = solution;
foreach (var referencedSymbol in referencedSymbols) {
foreach (var location in referencedSymbol.Locations) {
cancellationToken.ThrowIfCancellationRequested();
var document = changedSolution.GetDocument(location.Document.Id);
if (document == null) continue;
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root == null) continue;
var node = root.FindNode(location.Location.SourceSpan);
// Apply filter if provided
if (predicateFilter != null && !predicateFilter(node)) {
_logger.LogDebug("Skipping replacement for node at {Location} due to filter predicate",
location.Location.GetLineSpan());
continue;
}
// Create a new syntax node with the replacement text
var replacementNode = SyntaxFactory.ParseExpression(replacementText)
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia());
// Replace the node in the document
var newRoot = root.ReplaceNode(node, replacementNode);
var newDocument = document.WithSyntaxRoot(newRoot);
// Format the document and update the solution
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
changedSolution = formattedDocument.Project.Solution;
}
}
return changedSolution;
}
public async Task<Solution> FindAndReplaceAsync(string targetString, string regexPattern, string replacementText, CancellationToken cancellationToken, RegexOptions options = RegexOptions.Multiline) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogInformation("Performing find and replace with regex '{RegexPattern}' on target '{TargetString}'",
regexPattern, targetString);
// Create the regex with multiline option
var regex = new Regex(regexPattern, options);
Solution resultSolution = solution;
// Check if the target is a fully qualified name (no wildcards)
if (!targetString.Contains("*") && !targetString.Contains("?")) {
try {
// Try to resolve as a symbol
var symbol = await _solutionManager.FindRoslynSymbolAsync(targetString, cancellationToken);
if (symbol != null) {
_logger.LogInformation("Target is a valid symbol: {SymbolName}", symbol.ToDisplayString());
// For a symbol, we'll get its defining document and limit replacements to the symbol's span
var syntaxReferences = symbol.DeclaringSyntaxReferences;
if (syntaxReferences.Any()) {
foreach (var syntaxRef in syntaxReferences) {
cancellationToken.ThrowIfCancellationRequested();
var node = await syntaxRef.GetSyntaxAsync(cancellationToken);
var document = solution.GetDocument(node.SyntaxTree);
if (document == null) continue;
// Get the source text and limit replacement to the symbol's node span
var sourceText = await document.GetTextAsync(cancellationToken);
var originalText = sourceText.ToString();
// Extract only the text within the symbol's node span
var nodeSpan = node.Span;
var symbolText = originalText.Substring(nodeSpan.Start, nodeSpan.Length).NormalizeEndOfLines();
// Apply regex replacement only to the symbol's text
var newSymbolText = regex.Replace(symbolText, replacementText);
// Only update if changes were made to the symbol text
if (newSymbolText != symbolText) {
// Create new text by replacing the symbol's span with the modified text
var newFullText = originalText.Substring(0, nodeSpan.Start) +
newSymbolText +
originalText.Substring(nodeSpan.Start + nodeSpan.Length);
var newDocument = document.WithText(SourceText.From(newFullText, sourceText.Encoding));
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
resultSolution = formattedDocument.Project.Solution;
}
}
return resultSolution;
}
}
} catch (Exception ex) {
_logger.LogInformation("Target string is not a valid symbol: {Error}", ex.Message);
// Fall through to file-based search
}
}
// Handle as file path with potential wildcards
var documentIds = new List<DocumentId>();
// Log the pattern we're using
_logger.LogInformation("Treating '{Target}' as a file path pattern", targetString);
// Normalize path separators in the target pattern to use forward slashes consistently
string normalizedTarget = targetString.Replace('\\', '/');
Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
matcher.AddInclude(normalizedTarget);
string root = Path.GetPathRoot(solution.FilePath) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
// Process all projects and documents
foreach (var project in solution.Projects) {
foreach (var document in project.Documents) {
if (string.IsNullOrWhiteSpace(document.FilePath)) continue;
// Use wildcard matching
if (matcher.Match(root, document.FilePath).HasMatches) {
_logger.LogInformation("Document matched pattern: {DocumentPath}", document.FilePath);
documentIds.Add(document.Id);
}
}
}
_logger.LogInformation("Found {Count} documents matching pattern '{Pattern}'",
documentIds.Count, targetString);
resultSolution = solution;
// Process all matching documents
foreach (var documentId in documentIds) {
cancellationToken.ThrowIfCancellationRequested();
// Apply regex replacement
var document = resultSolution.GetDocument(documentId);
if (document == null) continue;
var sourceText = await document.GetTextAsync(cancellationToken);
var originalText = sourceText.ToString().NormalizeEndOfLines();
var newText = regex.Replace(originalText, replacementText);
// Only update if changes were made
if (newText != originalText) {
var newDocument = document.WithText(SourceText.From(newText, sourceText.Encoding));
var formattedDocument = await FormatDocumentAsync(newDocument, cancellationToken);
resultSolution = formattedDocument.Project.Solution;
}
}
return resultSolution;
}
public async Task<Document> FormatDocumentAsync(Document document, CancellationToken cancellationToken) {
_logger.LogDebug("Formatting document: {DocumentPath}", document.FilePath);
var formattingOptions = await document.GetOptionsAsync(cancellationToken);
var formattedDocument = await Formatter.FormatAsync(document, formattingOptions, cancellationToken);
_logger.LogDebug("Document formatted: {DocumentPath}", document.FilePath);
return formattedDocument;
}
public async Task ApplyChangesAsync(Solution newSolution, CancellationToken cancellationToken, string commitMessage, IEnumerable<string>? additionalFilePaths = null) {
if (_solutionManager.CurrentWorkspace is not MSBuildWorkspace workspace) {
_logger.LogError("Cannot apply changes: Workspace is not an MSBuildWorkspace or is null.");
throw new InvalidOperationException("Workspace is not suitable for applying changes.");
}
var originalSolution = _solutionManager.CurrentSolution ?? throw new InvalidOperationException("Original solution is null before applying changes.");
var solutionPath = originalSolution.FilePath ?? "";
var solutionChanges = newSolution.GetChanges(originalSolution);
var finalSolutionToApply = newSolution;
// Collect changed file paths for git operations - include both changed and new documents
var changedFilePaths = new List<string>();
foreach (var projectChange in solutionChanges.GetProjectChanges()) {
// Handle changed documents
foreach (var changedDocumentId in projectChange.GetChangedDocuments()) {
var documentToFormat = finalSolutionToApply.GetDocument(changedDocumentId);
if (documentToFormat != null) {
_logger.LogDebug("Pre-apply formatting for changed document: {DocumentPath}", documentToFormat.FilePath);
var formattedDocument = await FormatDocumentAsync(documentToFormat, cancellationToken);
finalSolutionToApply = formattedDocument.Project.Solution;
if (!string.IsNullOrEmpty(documentToFormat.FilePath)) {
changedFilePaths.Add(documentToFormat.FilePath);
}
}
}
// Handle added documents (new files)
foreach (var addedDocumentId in projectChange.GetAddedDocuments()) {
var addedDocument = finalSolutionToApply.GetDocument(addedDocumentId);
if (addedDocument != null) {
_logger.LogDebug("Pre-apply formatting for added document: {DocumentPath}", addedDocument.FilePath);
var formattedDocument = await FormatDocumentAsync(addedDocument, cancellationToken);
finalSolutionToApply = formattedDocument.Project.Solution;
if (!string.IsNullOrEmpty(addedDocument.FilePath)) {
changedFilePaths.Add(addedDocument.FilePath);
_logger.LogInformation("Added new document for git tracking: {DocumentPath}", addedDocument.FilePath);
}
}
}
// Handle removed documents
foreach (var removedDocumentId in projectChange.GetRemovedDocuments()) {
var removedDocument = originalSolution.GetDocument(removedDocumentId);
if (removedDocument != null && !string.IsNullOrEmpty(removedDocument.FilePath)) {
changedFilePaths.Add(removedDocument.FilePath);
_logger.LogInformation("Marked removed document for git tracking: {DocumentPath}", removedDocument.FilePath);
}
}
}
_logger.LogInformation("Applying changes to workspace for {DocumentCount} changed documents across {ProjectCount} projects.",
solutionChanges.GetProjectChanges().SelectMany(pc => pc.GetChangedDocuments().Concat(pc.GetAddedDocuments()).Concat(pc.GetRemovedDocuments())).Count(),
solutionChanges.GetProjectChanges().Count());
if (workspace.TryApplyChanges(finalSolutionToApply)) {
_logger.LogInformation("Changes applied successfully to the workspace.");
// If additional file paths are provided, add them to the changed file paths
if (additionalFilePaths != null) {
changedFilePaths.AddRange(additionalFilePaths.Where(fp => !string.IsNullOrEmpty(fp) && File.Exists(fp)));
}
// Git operations after successful changes
await ProcessGitOperationsAsync(solutionPath, changedFilePaths, commitMessage, cancellationToken);
_solutionManager.RefreshCurrentSolution();
} else {
_logger.LogError("Failed to apply changes to the workspace.");
throw new InvalidOperationException("Failed to apply changes to the workspace. Files might have been modified externally.");
}
}
private async Task ProcessGitOperationsAsync(string solutionPath, List<string> changedFilePaths, string commitMessage, CancellationToken cancellationToken) {
if (string.IsNullOrEmpty(solutionPath) || changedFilePaths.Count == 0) {
return;
}
try {
// Check if solution is in a git repo
if (!await _gitService.IsRepositoryAsync(solutionPath, cancellationToken)) {
_logger.LogDebug("Solution is not in a Git repository, skipping Git operations");
return;
}
_logger.LogDebug("Solution is in a Git repository, processing Git operations");
// Check if already on sharptools branch
if (!await _gitService.IsOnSharpToolsBranchAsync(solutionPath, cancellationToken)) {
_logger.LogInformation("Not on a SharpTools branch, creating one");
await _gitService.EnsureSharpToolsBranchAsync(solutionPath, cancellationToken);
}
// Commit changes with the provided commit message
await _gitService.CommitChangesAsync(solutionPath, changedFilePaths, commitMessage, cancellationToken);
_logger.LogInformation("Git operations completed successfully with commit message: {CommitMessage}", commitMessage);
} catch (Exception ex) {
// Log but don't fail the operation if Git operations fail
_logger.LogWarning(ex, "Git operations failed but code changes were still applied");
}
}
public async Task<(bool success, string message)> UndoLastChangeAsync(CancellationToken cancellationToken) {
if (_solutionManager.CurrentWorkspace is not MSBuildWorkspace workspace) {
_logger.LogError("Cannot undo changes: Workspace is not an MSBuildWorkspace or is null.");
var message = "Error: Workspace is not an MSBuildWorkspace or is null. Cannot undo.";
return (false, message);
}
var currentSolution = _solutionManager.CurrentSolution;
if (currentSolution?.FilePath == null) {
_logger.LogError("Cannot undo changes: Current solution or its file path is null.");
var message = "Error: No solution loaded or solution file path is null. Cannot undo.";
return (false, message);
}
var solutionPath = currentSolution.FilePath;
// Check if solution is in a git repository
if (!await _gitService.IsRepositoryAsync(solutionPath, cancellationToken)) {
_logger.LogError("Cannot undo changes: Solution is not in a Git repository.");
throw new McpException("Error: Solution is not in a Git repository. Undo functionality requires Git version control.");
}
// Check if we're on a sharptools branch
if (!await _gitService.IsOnSharpToolsBranchAsync(solutionPath, cancellationToken)) {
_logger.LogError("Cannot undo changes: Not on a SharpTools branch.");
var message = "Error: Not on a SharpTools branch. Undo is only available on SharpTools branches.";
return (false, message);
}
_logger.LogInformation("Attempting to undo last change by reverting last Git commit.");
// Perform git revert with diff
var (revertSuccess, diff) = await _gitService.RevertLastCommitAsync(solutionPath, cancellationToken);
if (!revertSuccess) {
_logger.LogError("Git revert operation failed.");
var message = "Error: Failed to revert the last Git commit. There may be no commits to revert or the operation failed.";
return (false, message);
}
// Reload the solution from disk to reflect the reverted changes
await _solutionManager.ReloadSolutionFromDiskAsync(cancellationToken);
_logger.LogInformation("Successfully reverted the last change using Git.");
var successMessage = "Successfully reverted the last change by reverting the last Git commit. Solution reloaded from disk.";
// Add the diff to the success message if available
if (!string.IsNullOrEmpty(diff)) {
successMessage += "\n\nChanges undone:\n" + diff;
}
return (true, successMessage);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/SolutionManager.cs:
--------------------------------------------------------------------------------
```csharp
using System.Runtime.InteropServices;
using System.Xml.Linq;
using ModelContextProtocol;
using SharpTools.Tools.Mcp.Tools;
namespace SharpTools.Tools.Services;
public sealed class SolutionManager : ISolutionManager {
private readonly ILogger<SolutionManager> _logger;
private readonly IFuzzyFqnLookupService _fuzzyFqnLookupService;
private MSBuildWorkspace? _workspace;
private Solution? _currentSolution;
private MetadataLoadContext? _metadataLoadContext;
private PathAssemblyResolver? _pathAssemblyResolver;
private HashSet<string> _assemblyPathsForReflection = new();
private readonly ConcurrentDictionary<ProjectId, Compilation> _compilationCache = new();
private readonly ConcurrentDictionary<DocumentId, SemanticModel> _semanticModelCache = new();
private readonly ConcurrentDictionary<string, Type> _allLoadedReflectionTypesCache = new();
[MemberNotNullWhen(true, nameof(_workspace), nameof(_currentSolution))]
public bool IsSolutionLoaded => _workspace != null && _currentSolution != null;
public MSBuildWorkspace? CurrentWorkspace => _workspace;
public Solution? CurrentSolution => _currentSolution;
private readonly string? _buildConfiguration;
public SolutionManager(ILogger<SolutionManager> logger, IFuzzyFqnLookupService fuzzyFqnLookupService, string? buildConfiguration = null) {
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fuzzyFqnLookupService = fuzzyFqnLookupService ?? throw new ArgumentNullException(nameof(fuzzyFqnLookupService));
_buildConfiguration = buildConfiguration;
}
public async Task LoadSolutionAsync(string solutionPath, CancellationToken cancellationToken) {
if (!File.Exists(solutionPath)) {
_logger.LogError("Solution file not found: {SolutionPath}", solutionPath);
throw new FileNotFoundException("Solution file not found.", solutionPath);
}
UnloadSolution(); // Clears previous state including _allLoadedReflectionTypesCache
try {
_logger.LogInformation("Creating MSBuildWorkspace...");
var properties = new Dictionary<string, string> {
{ "DesignTimeBuild", "true" }
};
if (!string.IsNullOrEmpty(_buildConfiguration)) {
properties.Add("Configuration", _buildConfiguration);
}
_workspace = MSBuildWorkspace.Create(properties, MefHostServices.DefaultHost);
_workspace.WorkspaceFailed += OnWorkspaceFailed;
_logger.LogInformation("Loading solution: {SolutionPath}", solutionPath);
_currentSolution = await _workspace.OpenSolutionAsync(solutionPath, new ProgressReporter(_logger), cancellationToken);
_logger.LogInformation("Solution loaded successfully with {ProjectCount} projects.", _currentSolution.Projects.Count());
InitializeMetadataContextAndReflectionCache(_currentSolution, cancellationToken);
} catch (Exception ex) {
_logger.LogError(ex, "Failed to load solution: {SolutionPath}", solutionPath);
UnloadSolution();
throw;
}
}
private void InitializeMetadataContextAndReflectionCache(Solution solution, CancellationToken cancellationToken = default) {
// Check cancellation at entry point
cancellationToken.ThrowIfCancellationRequested();
_assemblyPathsForReflection.Clear();
// Add runtime assemblies
string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");
foreach (var assemblyPath in runtimeAssemblies) {
// Check cancellation periodically
cancellationToken.ThrowIfCancellationRequested();
if (!_assemblyPathsForReflection.Contains(assemblyPath)) {
_assemblyPathsForReflection.Add(assemblyPath);
}
}
// Load NuGet package assemblies from global cache instead of output directories
var nugetAssemblies = GetNuGetAssemblyPaths(solution, cancellationToken);
foreach (var assemblyPath in nugetAssemblies) {
cancellationToken.ThrowIfCancellationRequested();
_assemblyPathsForReflection.Add(assemblyPath);
}
// Check cancellation before cleanup operations
cancellationToken.ThrowIfCancellationRequested();
// Remove mscorlib.dll from the list of assemblies as it is loaded by default
_assemblyPathsForReflection.RemoveWhere(p => p.EndsWith("mscorlib.dll", StringComparison.OrdinalIgnoreCase));
// Remove duplicate files regardless of path
_assemblyPathsForReflection = _assemblyPathsForReflection
.GroupBy(Path.GetFileName)
.Select(g => g.First())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Check cancellation before creating context
cancellationToken.ThrowIfCancellationRequested();
_pathAssemblyResolver = new PathAssemblyResolver(_assemblyPathsForReflection);
_metadataLoadContext = new MetadataLoadContext(_pathAssemblyResolver);
_logger.LogInformation("MetadataLoadContext initialized with {PathCount} distinct search paths.", _assemblyPathsForReflection.Count);
// Check cancellation before populating cache
cancellationToken.ThrowIfCancellationRequested();
PopulateReflectionCache(_assemblyPathsForReflection, cancellationToken);
}
private void PopulateReflectionCache(IEnumerable<string> assemblyPathsToInspect, CancellationToken cancellationToken = default) {
// Check cancellation at entry point
cancellationToken.ThrowIfCancellationRequested();
if (_metadataLoadContext == null) {
_logger.LogWarning("Cannot populate reflection cache: MetadataLoadContext not initialized.");
return;
}
// _allLoadedReflectionTypesCache is cleared in UnloadSolution
_logger.LogInformation("Starting population of reflection type cache...");
int typesCachedCount = 0;
// Convert to list to avoid multiple enumeration and enable progress tracking
var pathsList = assemblyPathsToInspect.ToList();
int totalPaths = pathsList.Count;
int processedPaths = 0;
const int progressCheckInterval = 10; // Report progress and check cancellation every 10 assemblies
foreach (var assemblyPath in pathsList) {
// Check cancellation and log progress periodically
if (++processedPaths % progressCheckInterval == 0) {
cancellationToken.ThrowIfCancellationRequested();
_logger.LogTrace("Reflection cache population progress: {Progress}% ({Current}/{Total})",
(int)((float)processedPaths / totalPaths * 100), processedPaths, totalPaths);
}
LoadTypesFromAssembly(assemblyPath, ref typesCachedCount, cancellationToken);
}
_logger.LogInformation("Reflection type cache population complete. Cached {Count} types from {AssemblyCount} unique assembly paths processed.", typesCachedCount, pathsList.Count);
}
private void LoadTypesFromAssembly(string assemblyPath, ref int typesCachedCount, CancellationToken cancellationToken = default) {
// Check cancellation at entry point
cancellationToken.ThrowIfCancellationRequested();
if (_metadataLoadContext == null || string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath)) {
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath)) {
_logger.LogTrace("Assembly path is invalid or file does not exist, skipping for reflection cache: {Path}", assemblyPath);
}
return;
}
try {
var assembly = _metadataLoadContext.LoadFromAssemblyPath(assemblyPath);
// For large assemblies, check cancellation periodically during type collection
// We can't check during GetTypes() directly since it's atomic, but we can after
cancellationToken.ThrowIfCancellationRequested();
var types = assembly.GetTypes();
int processedTypes = 0;
const int typeCheckInterval = 50; // Check cancellation every 50 types
foreach (var type in types) {
// Check cancellation periodically when processing many types
if (++processedTypes % typeCheckInterval == 0) {
cancellationToken.ThrowIfCancellationRequested();
}
if (type?.FullName != null && !_allLoadedReflectionTypesCache.ContainsKey(type.FullName)) {
_allLoadedReflectionTypesCache.TryAdd(type.FullName, type);
typesCachedCount++;
}
}
} catch (ReflectionTypeLoadException rtlex) {
_logger.LogWarning("Could not load all types from assembly {Path} for reflection cache. LoaderExceptions: {Count}", assemblyPath, rtlex.LoaderExceptions.Length);
foreach (var loaderEx in rtlex.LoaderExceptions.Where(e => e != null)) {
_logger.LogTrace("LoaderException: {Message}", loaderEx!.Message);
}
// For partial load errors, still process the types that did load
int processedTypes = 0;
const int typeCheckInterval = 20; // Check cancellation more frequently when dealing with problematic assemblies
foreach (var type in rtlex.Types.Where(t => t != null)) {
// Check cancellation periodically
if (++processedTypes % typeCheckInterval == 0) {
cancellationToken.ThrowIfCancellationRequested();
}
if (type!.FullName != null && !_allLoadedReflectionTypesCache.ContainsKey(type.FullName)) {
_allLoadedReflectionTypesCache.TryAdd(type.FullName, type);
typesCachedCount++;
}
}
} catch (FileNotFoundException) { // Should be rare due to File.Exists check, but MLC might have its own resolution logic
_logger.LogTrace("Assembly file not found by MetadataLoadContext: {Path}", assemblyPath);
} catch (BadImageFormatException) {
_logger.LogTrace("Bad image format for assembly file: {Path}", assemblyPath);
} catch (Exception ex) {
_logger.LogWarning(ex, "Error loading types from assembly {Path} for reflection cache.", assemblyPath);
}
}
public void UnloadSolution() {
_logger.LogInformation("Unloading current solution and workspace.");
_compilationCache.Clear();
_semanticModelCache.Clear();
_allLoadedReflectionTypesCache.Clear();
if (_workspace != null) {
_workspace.WorkspaceFailed -= OnWorkspaceFailed;
_workspace.CloseSolution();
_workspace.Dispose();
_workspace = null;
}
_metadataLoadContext?.Dispose();
_metadataLoadContext = null;
_pathAssemblyResolver = null; // PathAssemblyResolver doesn't implement IDisposable
_assemblyPathsForReflection.Clear();
}
public void RefreshCurrentSolution() {
if (_workspace == null) {
_logger.LogWarning("Cannot refresh solution: Workspace is null.");
return;
}
if (_workspace.CurrentSolution == null) {
_logger.LogWarning("Cannot refresh solution: No solution loaded.");
return;
}
_currentSolution = _workspace.CurrentSolution;
_compilationCache.Clear();
_semanticModelCache.Clear();
_logger.LogDebug("Current solution state has been refreshed from workspace.");
}
public async Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken) {
if (_workspace == null) {
_logger.LogWarning("Cannot reload solution: Workspace is null.");
return;
}
if (_workspace.CurrentSolution == null) {
_logger.LogWarning("Cannot reload solution: No solution loaded.");
return;
}
await LoadSolutionAsync(_workspace.CurrentSolution.FilePath!, cancellationToken);
_logger.LogDebug("Current solution state has been refreshed from workspace.");
}
private void OnWorkspaceFailed(object? sender, WorkspaceDiagnosticEventArgs e) {
var diagnostic = e.Diagnostic;
var level = diagnostic.Kind == WorkspaceDiagnosticKind.Failure ? LogLevel.Error : LogLevel.Warning;
_logger.Log(level, "Workspace diagnostic ({Kind}): {Message}", diagnostic.Kind, diagnostic.Message);
}
public async Task<INamedTypeSymbol?> FindRoslynNamedTypeSymbolAsync(string fullyQualifiedTypeName, CancellationToken cancellationToken) {
if (!IsSolutionLoaded) {
_logger.LogWarning("Cannot find Roslyn symbol: No solution loaded.");
return null;
}
// Check cancellation before starting lookup
cancellationToken.ThrowIfCancellationRequested();
// Use fuzzy FQN lookup service
var matches = await _fuzzyFqnLookupService.FindMatchesAsync(fullyQualifiedTypeName, this, cancellationToken);
var matchList = matches.Where(m => m.Symbol is INamedTypeSymbol).ToList();
// Check cancellation after initial matching
cancellationToken.ThrowIfCancellationRequested();
if (matchList.Count == 1) {
var match = matchList.First();
_logger.LogDebug("Roslyn named type symbol found: {FullyQualifiedTypeName} (score: {Score}, reason: {Reason})",
match.CanonicalFqn, match.Score, match.MatchReason);
return (INamedTypeSymbol)match.Symbol;
}
if (matchList.Count > 1) {
_logger.LogWarning("Multiple matches found for {FullyQualifiedTypeName}", fullyQualifiedTypeName);
throw new McpException($"FQN was ambiguous, did you mean one of these?\n{string.Join("\n", matchList.Select(m => m.CanonicalFqn))}");
}
// Direct lookup as fallback
foreach (var project in CurrentSolution.Projects) {
// Check cancellation before each project
cancellationToken.ThrowIfCancellationRequested();
var compilation = await GetCompilationAsync(project.Id, cancellationToken);
if (compilation == null) {
continue;
}
var symbol = compilation.GetTypeByMetadataName(fullyQualifiedTypeName);
if (symbol != null) {
_logger.LogDebug("Roslyn named type symbol found via direct lookup: {FullyQualifiedTypeName} in project {ProjectName}",
fullyQualifiedTypeName, project.Name);
return symbol;
}
}
// Check cancellation before nested type check
cancellationToken.ThrowIfCancellationRequested();
// Check for nested type with dot notation as last resort
var lastDotIndex = fullyQualifiedTypeName.LastIndexOf('.');
if (lastDotIndex > 0) {
var parentTypeName = fullyQualifiedTypeName.Substring(0, lastDotIndex);
var nestedTypeName = fullyQualifiedTypeName.Substring(lastDotIndex + 1);
foreach (var project in CurrentSolution.Projects) {
// Check cancellation before each project
cancellationToken.ThrowIfCancellationRequested();
var compilation = await GetCompilationAsync(project.Id, cancellationToken);
if (compilation == null) {
continue;
}
var parentSymbol = compilation.GetTypeByMetadataName(parentTypeName);
if (parentSymbol != null) {
// Check if there's a nested type with this name
var nestedType = parentSymbol.GetTypeMembers(nestedTypeName).FirstOrDefault();
if (nestedType != null) {
var correctName = $"{parentTypeName}+{nestedTypeName}";
_logger.LogWarning("Type not found: '{FullyQualifiedTypeName}'. This appears to be a nested type - use '{CorrectName}' instead (use + instead of . for nested types)",
fullyQualifiedTypeName, correctName);
throw new McpException(
$"Type not found: '{fullyQualifiedTypeName}'. This appears to be a nested type - use '{correctName}' instead (use + instead of . for nested types)");
}
}
}
}
_logger.LogDebug("Roslyn named type symbol not found: {FullyQualifiedTypeName}", fullyQualifiedTypeName);
return null;
}
public async Task<ISymbol?> FindRoslynSymbolAsync(string fullyQualifiedName, CancellationToken cancellationToken) {
if (!IsSolutionLoaded) {
_logger.LogWarning("Cannot find Roslyn symbol: No solution loaded.");
return null;
}
// Check cancellation before starting lookup
cancellationToken.ThrowIfCancellationRequested();
// Use fuzzy FQN lookup service
var matches = await _fuzzyFqnLookupService.FindMatchesAsync(fullyQualifiedName, this, cancellationToken);
var matchList = matches.ToList();
// Check cancellation after initial matching
cancellationToken.ThrowIfCancellationRequested();
if (matchList.Count == 1) {
var match = matchList.First();
_logger.LogDebug("Roslyn symbol found: {FullyQualifiedName} (score: {Score}, reason: {Reason})",
match.CanonicalFqn, match.Score, match.MatchReason);
return match.Symbol;
}
if (matchList.Count > 1) {
_logger.LogWarning("Multiple matches found for {FullyQualifiedName}", fullyQualifiedName);
throw new McpException($"FQN was ambiguous, did you mean one of these?\n{string.Join("\n", matchList.Select(m => m.CanonicalFqn))}");
}
// Check cancellation before fallback lookup
cancellationToken.ThrowIfCancellationRequested();
// Fall back to type lookup
var typeSymbol = await FindRoslynNamedTypeSymbolAsync(fullyQualifiedName, cancellationToken);
if (typeSymbol != null) {
return typeSymbol;
}
// Check cancellation before member lookup
cancellationToken.ThrowIfCancellationRequested();
// Check for member of a type as fallback
var lastDotIndex = fullyQualifiedName.LastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < fullyQualifiedName.Length - 1) {
var typeName = fullyQualifiedName.Substring(0, lastDotIndex);
var memberName = fullyQualifiedName.Substring(lastDotIndex + 1);
var parentTypeSymbol = await FindRoslynNamedTypeSymbolAsync(typeName, cancellationToken);
if (parentTypeSymbol != null) {
// Check cancellation before final lookup step
cancellationToken.ThrowIfCancellationRequested();
var members = parentTypeSymbol.GetMembers(memberName);
if (members.Any()) {
// TODO: Handle overloads if necessary, for now, take the first.
var memberSymbol = members.First();
_logger.LogDebug("Roslyn member symbol found: {FullyQualifiedName}", fullyQualifiedName);
return memberSymbol;
}
}
}
_logger.LogDebug("Roslyn symbol not found: {FullyQualifiedName}", fullyQualifiedName);
return null;
}
public Task<Type?> FindReflectionTypeAsync(string fullyQualifiedTypeName, CancellationToken cancellationToken) {
// Check cancellation at the beginning of the method
cancellationToken.ThrowIfCancellationRequested();
if (_metadataLoadContext == null) {
_logger.LogWarning("Cannot find reflection type: MetadataLoadContext not initialized.");
return Task.FromResult<Type?>(null);
}
if (_allLoadedReflectionTypesCache.TryGetValue(fullyQualifiedTypeName, out var type)) {
_logger.LogDebug("Reflection type found in cache: {Name}", fullyQualifiedTypeName);
return Task.FromResult<Type?>(type);
}
_logger.LogDebug("Reflection type '{FullyQualifiedTypeName}' not found in cache. It might not exist in the loaded solution's dependencies or was not loadable.", fullyQualifiedTypeName);
return Task.FromResult<Type?>(null);
}
public Task<IEnumerable<Type>> SearchReflectionTypesAsync(string regexPattern, CancellationToken cancellationToken) {
// Check cancellation at the method entry point
cancellationToken.ThrowIfCancellationRequested();
if (_metadataLoadContext == null) {
_logger.LogWarning("Cannot search reflection types: MetadataLoadContext not initialized.");
return Task.FromResult(Enumerable.Empty<Type>());
}
if (!_allLoadedReflectionTypesCache.Any()) {
_logger.LogInformation("Reflection type cache is empty. Search will yield no results.");
return Task.FromResult(Enumerable.Empty<Type>());
}
// Check cancellation before regex compilation
cancellationToken.ThrowIfCancellationRequested();
var regex = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
var matchedTypes = new List<Type>();
// Consider batching in chunks to check cancellation more frequently on large type caches
int processedCount = 0;
const int batchSize = 100; // Check cancellation every 100 types
foreach (var typeEntry in _allLoadedReflectionTypesCache) { // Iterate KeyValuePair to access FQN directly
if (++processedCount % batchSize == 0) {
cancellationToken.ThrowIfCancellationRequested();
}
// Key is type.FullName which should not be null for cached types
if (regex.IsMatch(typeEntry.Key)) { // Search FQN
matchedTypes.Add(typeEntry.Value);
} else if (regex.IsMatch(typeEntry.Value.Name)) { // Search simple name
matchedTypes.Add(typeEntry.Value);
}
}
// Check cancellation before returning results
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Found {Count} reflection types matching pattern '{Pattern}'.", matchedTypes.Count, regexPattern);
return Task.FromResult<IEnumerable<Type>>(matchedTypes.Distinct());
}
public IEnumerable<Project> GetProjects() {
return CurrentSolution?.Projects ?? Enumerable.Empty<Project>();
}
public Project? GetProjectByName(string projectName) {
if (!IsSolutionLoaded) {
_logger.LogWarning("Cannot get project by name: No solution loaded.");
return null;
}
var project = CurrentSolution?.Projects.FirstOrDefault(p => p.Name.Equals(projectName, StringComparison.OrdinalIgnoreCase));
if (project == null) {
_logger.LogWarning("Project not found: {ProjectName}", projectName);
}
return project;
}
public async Task<SemanticModel?> GetSemanticModelAsync(DocumentId documentId, CancellationToken cancellationToken) {
// Check cancellation at entry point
cancellationToken.ThrowIfCancellationRequested();
if (!IsSolutionLoaded) {
_logger.LogWarning("Cannot get semantic model: No solution loaded.");
return null;
}
// Fast path: check cache first
if (_semanticModelCache.TryGetValue(documentId, out var cachedModel)) {
_logger.LogTrace("Returning cached semantic model for document ID: {DocumentId}", documentId);
return cachedModel;
}
// Check cancellation before document lookup
cancellationToken.ThrowIfCancellationRequested();
var document = CurrentSolution.GetDocument(documentId);
if (document == null) {
_logger.LogWarning("Document not found for ID: {DocumentId}", documentId);
return null;
}
_logger.LogTrace("Requesting semantic model for document: {DocumentFilePath}", document.FilePath);
// Check cancellation before expensive GetSemanticModelAsync call
cancellationToken.ThrowIfCancellationRequested();
var model = await document.GetSemanticModelAsync(cancellationToken);
if (model != null) {
_semanticModelCache.TryAdd(documentId, model);
} else {
_logger.LogWarning("Failed to get semantic model for document: {DocumentFilePath}", document.FilePath);
}
return model;
}
public async Task<Compilation?> GetCompilationAsync(ProjectId projectId, CancellationToken cancellationToken) {
// Check cancellation at entry point
cancellationToken.ThrowIfCancellationRequested();
if (!IsSolutionLoaded) {
_logger.LogWarning("Cannot get compilation: No solution loaded.");
return null;
}
// Fast path: check cache first
if (_compilationCache.TryGetValue(projectId, out var cachedCompilation)) {
_logger.LogTrace("Returning cached compilation for project ID: {ProjectId}", projectId);
return cachedCompilation;
}
// Check cancellation before project lookup
cancellationToken.ThrowIfCancellationRequested();
var project = CurrentSolution.GetProject(projectId);
if (project == null) {
_logger.LogWarning("Project not found for ID: {ProjectId}", projectId);
return null;
}
_logger.LogTrace("Requesting compilation for project: {ProjectName}", project.Name);
// Check cancellation before expensive GetCompilationAsync call
cancellationToken.ThrowIfCancellationRequested();
var compilation = await project.GetCompilationAsync(cancellationToken);
if (compilation != null) {
_compilationCache.TryAdd(projectId, compilation);
} else {
_logger.LogWarning("Failed to get compilation for project: {ProjectName}", project.Name);
}
return compilation;
}
public void Dispose() {
UnloadSolution();
GC.SuppressFinalize(this);
}
private class ProgressReporter : IProgress<ProjectLoadProgress> {
private readonly Microsoft.Extensions.Logging.ILogger _logger;
public ProgressReporter(Microsoft.Extensions.Logging.ILogger logger) {
_logger = logger;
}
public void Report(ProjectLoadProgress loadProgress) {
var projectDisplay = Path.GetFileName(loadProgress.FilePath);
_logger.LogTrace("Project Load Progress: {ProjectDisplayName}, Operation: {Operation}, Time: {TimeElapsed}",
projectDisplay, loadProgress.Operation, loadProgress.ElapsedTime);
}
}
private HashSet<string> GetNuGetAssemblyPaths(Solution solution, CancellationToken cancellationToken = default) {
var nugetAssemblyPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var nugetCacheDir = GetNuGetGlobalPackagesFolder();
if (string.IsNullOrEmpty(nugetCacheDir) || !Directory.Exists(nugetCacheDir)) {
_logger.LogWarning("NuGet global packages folder not found or inaccessible: {NuGetCacheDir}", nugetCacheDir);
return nugetAssemblyPaths;
}
foreach (var project in solution.Projects) {
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(project.FilePath)) {
continue;
}
var packageReferences = LegacyNuGetPackageReader.GetAllPackageReferences(project.FilePath);
var projectTargetFramework = SolutionTools.ExtractTargetFrameworkFromProjectFile(project.FilePath);
foreach (var package in packageReferences) {
cancellationToken.ThrowIfCancellationRequested();
var packageDir = Path.Combine(nugetCacheDir, package.PackageId.ToLowerInvariant(), package.Version);
if (!Directory.Exists(packageDir)) {
_logger.LogTrace("Package directory not found: {PackageDir}", packageDir);
continue;
}
var libDir = Path.Combine(packageDir, "lib");
if (!Directory.Exists(libDir)) {
_logger.LogTrace("No lib directory found for package {PackageId} {Version}", package.PackageId, package.Version);
continue;
}
// Find assemblies using the project's target framework
var assemblyPaths = GetAssembliesForTargetFramework(libDir, package.TargetFramework ?? projectTargetFramework, package.PackageId, package.Version);
foreach (var assemblyPath in assemblyPaths) {
nugetAssemblyPaths.Add(assemblyPath);
}
}
}
_logger.LogInformation("Found {AssemblyCount} NuGet assemblies from global packages cache", nugetAssemblyPaths.Count);
return nugetAssemblyPaths;
}
private static string GetNuGetGlobalPackagesFolder() {
// Check environment variable first
var globalPackagesPath = Environment.GetEnvironmentVariable("NUGET_PACKAGES");
if (!string.IsNullOrEmpty(globalPackagesPath)) {
return globalPackagesPath;
}
// Default location based on OS
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(userProfile, ".nuget", "packages");
}
private List<string> GetAssembliesForTargetFramework(string libDir, string targetFramework, string packageId, string version) {
var assemblies = new List<string>();
if (!Directory.Exists(libDir)) {
return assemblies;
}
// First try exact target framework match
var exactFrameworkDir = Path.Combine(libDir, targetFramework);
if (Directory.Exists(exactFrameworkDir)) {
var exactAssemblies = Directory.GetFiles(exactFrameworkDir, "*.dll", SearchOption.TopDirectoryOnly);
assemblies.AddRange(exactAssemblies);
_logger.LogTrace("Found {AssemblyCount} assemblies in exact framework match {Framework} for {PackageId} {Version}",
exactAssemblies.Length, targetFramework, packageId, version);
return assemblies;
}
// Try compatible frameworks in order of preference
var compatibleFrameworks = GetCompatibleFrameworks(targetFramework);
foreach (var framework in compatibleFrameworks) {
var frameworkDir = Path.Combine(libDir, framework);
if (Directory.Exists(frameworkDir)) {
var frameworkAssemblies = Directory.GetFiles(frameworkDir, "*.dll", SearchOption.TopDirectoryOnly);
assemblies.AddRange(frameworkAssemblies);
_logger.LogTrace("Found {AssemblyCount} assemblies in compatible framework {Framework} for {PackageId} {Version}",
frameworkAssemblies.Length, framework, packageId, version);
return assemblies; // Take the first compatible framework found
}
}
// Fallback: check if there are any DLLs directly in lib directory
if (assemblies.Count == 0) {
var libAssemblies = Directory.GetFiles(libDir, "*.dll", SearchOption.TopDirectoryOnly);
assemblies.AddRange(libAssemblies);
if (libAssemblies.Length > 0) {
_logger.LogTrace("Found {AssemblyCount} assemblies in lib root for {PackageId} {Version}",
libAssemblies.Length, packageId, version);
}
}
return assemblies;
}
private static string[] GetCompatibleFrameworks(string targetFramework) {
// Return frameworks in order of compatibility preference
return targetFramework.ToLowerInvariant() switch {
"net10.0" => new[] { "net10.0", "net9.0", "net8.0", "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"net9.0" => new[] { "net9.0", "net8.0", "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"net8.0" => new[] { "net8.0", "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"net7.0" => new[] { "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"net6.0" => new[] { "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"net5.0" => new[] { "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"netcoreapp3.1" => new[] { "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"netcoreapp3.0" => new[] { "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"netcoreapp2.1" => new[] { "netcoreapp2.1", "netstandard2.0", "netstandard1.6" },
"netstandard2.1" => new[] { "netstandard2.1", "netstandard2.0", "netstandard1.6" },
"netstandard2.0" => new[] { "netstandard2.0", "netstandard1.6" },
_ => new[] { "net8.0", "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" }
};
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/FuzzyFqnLookupService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using SharpTools.Tools.Interfaces;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Mcp;
namespace SharpTools.Tools.Services {
public class FuzzyFqnLookupService : IFuzzyFqnLookupService {
private readonly ILogger<FuzzyFqnLookupService> _logger;
private ISolutionManager? _solutionManager;
// Match scoring constants - higher score is better
private const double PerfectMatchScore = 1.0;
private const double ConstructorShorthandTypeNameMatchScore = 0.98; // User typed "Namespace.Type" for constructor
private const double ConstructorShorthandFullMatchScore = 0.97; // User typed "Namespace.Type.Type" for constructor
private const double ParamsOmittedScore = 0.9;
private const double ArityOmittedScore = 0.85;
private const double GenericArgsOmittedScore = 0.80;
private const double NestedTypeDotForPlusScore = 0.75;
private const double CaseInsensitiveMatchScore = 0.99;
private const double ParametersContentMismatchScore = 0.7; // Base name matches, but params differ
private const double MinScoreThreshold = 0.7; // Minimum score to be considered a match
// Regex patterns for normalization
private static readonly Regex ParamsRegex = new(@"\s*\([\s\S]*\)\s*$", RegexOptions.Compiled);
private static readonly Regex ArityRegex = new(@"`\d+", RegexOptions.Compiled);
private static readonly Regex GenericArgsRegex = new(@"<[^<>]+>", RegexOptions.Compiled); // Simplistic: removes <...>
public FuzzyFqnLookupService(ILogger<FuzzyFqnLookupService> logger) {
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public static bool IsPartialType(ISymbol typeSymbol) {
if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) {
return false; // Not a type symbol
}
return typeSymbol.DeclaringSyntaxReferences.Length > 1 ||
typeSymbol.DeclaringSyntaxReferences.Any(syntax =>
syntax.GetSyntax() is Microsoft.CodeAnalysis.CSharp.Syntax.BaseTypeDeclarationSyntax declaration &&
declaration.Modifiers.Any(modifier =>
modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)));
}
/// <inheritdoc />
public async Task<IEnumerable<FuzzyMatchResult>> FindMatchesAsync(string fuzzyFqnInput, ISolutionManager solutionManager, CancellationToken cancellationToken) {
_solutionManager = solutionManager;
if (!solutionManager.IsSolutionLoaded) {
_logger.LogWarning("Cannot perform fuzzy FQN lookup: No solution loaded.");
return Enumerable.Empty<FuzzyMatchResult>();
}
var potentialMatches = new List<FuzzyMatchResult>();
string trimmedFuzzyFqn = fuzzyFqnInput.Trim().Replace(" ", string.Empty);
var allRelevantSymbols = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
// Process each document in the solution to collect symbols
foreach (var project in solutionManager.CurrentSolution.Projects) {
// Check cancellation before starting work on each project
cancellationToken.ThrowIfCancellationRequested();
foreach (var document in project.Documents) {
// Check cancellation before starting work on each document
cancellationToken.ThrowIfCancellationRequested();
var semanticModel = await solutionManager.GetSemanticModelAsync(document.Id, cancellationToken);
if (semanticModel == null) {
_logger.LogWarning("Could not get semantic model for document {DocumentPath}", document.FilePath);
continue;
}
// Get all symbols from the semantic model
CollectSymbolsFromSemanticModel(semanticModel, allRelevantSymbols, cancellationToken);
}
}
_logger.LogDebug("Collected {SymbolCount} symbols from solution documents", allRelevantSymbols.Count);
int prefilter = allRelevantSymbols.Count;
//Remove all duplicates, no idea why this is needed. //TODO
var partialTypes = allRelevantSymbols
//.OfType<INamedTypeSymbol>()
//.Where(IsPartialType)
.GroupBy(GetSearchableString)
.ToList();
allRelevantSymbols = new HashSet<ISymbol>(allRelevantSymbols.Except(partialTypes.SelectMany(g => g.Skip(1))), SymbolEqualityComparer.Default); // Remove all but one from each group
_logger.LogDebug("Filtered {PrefilterCount} symbols to {SymbolCount} after removing duplicates", prefilter, allRelevantSymbols.Count);
// Match symbols against the fuzzy FQN
foreach (var symbol in allRelevantSymbols) {
// Check cancellation periodically during symbol processing
cancellationToken.ThrowIfCancellationRequested();
//string canonicalFqn = symbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
string canonicalFqn = GetSearchableString(symbol);
//_logger.LogDebug("Checking symbol: {SymbolName} with FQN: {CanonicalFqn}", symbol.Name, canonicalFqn);
var (score, reason) = CalculateMatchScore(trimmedFuzzyFqn, symbol, canonicalFqn, cancellationToken);
if (score >= MinScoreThreshold) {
potentialMatches.Add(new FuzzyMatchResult(canonicalFqn, symbol, score, reason));
}
}
// Check cancellation before sorting results
cancellationToken.ThrowIfCancellationRequested();
// Sort by score descending, then by FQN alphabetically for stable results
var results = potentialMatches
.OrderByDescending(m => m.Score)
.ThenBy(m => m.CanonicalFqn)
.ToList();
_logger.LogDebug("Found {MatchCount} matches for fuzzy FQN '{FuzzyFqn}'", results.Count, fuzzyFqnInput);
// If multiple matches are found, but one is perfect, filter to that one
if (results.Count > 1) {
var perfectMatches = results.Where(m => m.Score >= PerfectMatchScore - 0.01).ToList();
if (perfectMatches.Count == 1) {
_logger.LogDebug("Filtered to single perfect match for '{FuzzyFqn}'", fuzzyFqnInput);
return perfectMatches;
}
}
// Log ambiguity details when multiple high-scoring matches are found
await LogAmbiguityDetailsAsync(fuzzyFqnInput, results, cancellationToken);
return results;
}
private void CollectSymbolsFromSemanticModel(SemanticModel semanticModel, HashSet<ISymbol> collectedSymbols, CancellationToken cancellationToken) {
// Get the global namespace from the compilation
var compilation = semanticModel.Compilation;
// Collect from global namespace
//CollectSymbols(compilation.GlobalNamespace, collectedSymbols, cancellationToken);
// Also collect any declarations from the syntax tree of this document
var root = semanticModel.SyntaxTree.GetRoot(cancellationToken);
foreach (var node in root
.DescendantNodes(descendIntoChildren: n => n is MemberDeclarationSyntax or TypeDeclarationSyntax or NamespaceDeclarationSyntax or CompilationUnitSyntax)
.Where(n => n is MemberDeclarationSyntax or TypeDeclarationSyntax or NamespaceDeclarationSyntax)) {
// Check cancellation periodically during node traversal
cancellationToken.ThrowIfCancellationRequested();
var declaredSymbol = semanticModel.GetDeclaredSymbol(node, cancellationToken);
if (declaredSymbol != null) {
collectedSymbols.Add(declaredSymbol);
}
}
}
private void CollectSymbols(INamespaceOrTypeSymbol containerSymbol, HashSet<ISymbol> collectedSymbols, CancellationToken cancellationToken) {
// Check cancellation at the beginning of recursive operations
cancellationToken.ThrowIfCancellationRequested();
if (containerSymbol is INamedTypeSymbol typeSymbol) {
// Add the type itself
collectedSymbols.Add(typeSymbol);
}
foreach (var member in containerSymbol.GetMembers()) {
// Check cancellation periodically during member processing
cancellationToken.ThrowIfCancellationRequested();
switch (member.Kind) {
case SymbolKind.Namespace:
CollectSymbols((INamespaceSymbol)member, collectedSymbols, cancellationToken);
break;
case SymbolKind.NamedType:
CollectSymbols((INamedTypeSymbol)member, collectedSymbols, cancellationToken); // Recurse for nested types
break;
case SymbolKind.Method:
// Includes constructors, operators, accessors, destructors
collectedSymbols.Add(member);
break;
case SymbolKind.Property:
var prop = (IPropertySymbol)member;
collectedSymbols.Add(prop); // Add property itself for FQNs like Namespace.Type.Property
if (prop.GetMethod != null) collectedSymbols.Add(prop.GetMethod);
if (prop.SetMethod != null) collectedSymbols.Add(prop.SetMethod);
break;
case SymbolKind.Event:
var evt = (IEventSymbol)member;
collectedSymbols.Add(evt); // Add event itself for FQNs like Namespace.Type.Event
if (evt.AddMethod != null) collectedSymbols.Add(evt.AddMethod);
if (evt.RemoveMethod != null) collectedSymbols.Add(evt.RemoveMethod);
break;
case SymbolKind.Field:
collectedSymbols.Add(member);
break;
}
}
}
private (double score, string reason) CalculateMatchScore(string userInputFqn, ISymbol symbol, string canonicalFqn, CancellationToken cancellationToken) {
// Periodically check cancellation during the scoring process
cancellationToken.ThrowIfCancellationRequested();
// 0. Direct case-sensitive match
if (userInputFqn.Equals(canonicalFqn, StringComparison.Ordinal)) {
return (PerfectMatchScore, "Exact match");
}
// 0.1. Direct case-insensitive match
if (userInputFqn.Equals(canonicalFqn, StringComparison.OrdinalIgnoreCase)) {
return (CaseInsensitiveMatchScore, "Case-insensitive exact match");
}
// Prepare normalized versions for comparison
string userInputNoParams = RemoveParameters(userInputFqn);
string canonicalFqnNoParams = RemoveParameters(canonicalFqn);
// Normalize generic arguments - keep the angle brackets but normalize contents
// For example, "List<int>" and "List<T>" might be considered equivalent in some cases
string userInputWithNormalizedGenerics = NormalizeGenericArgs(userInputNoParams);
string canonicalFqnWithNormalizedGenerics = NormalizeGenericArgs(canonicalFqnNoParams);
// Check cancellation after regex processing
cancellationToken.ThrowIfCancellationRequested();
// 1. Constructor shorthands
if (symbol is IMethodSymbol methodSymbol &&
(methodSymbol.MethodKind == MethodKind.Constructor || methodSymbol.MethodKind == MethodKind.StaticConstructor)) {
// Get containing type name with proper generic arguments
string typeFullName = GetSearchableString(methodSymbol.ContainingType);
// User typed "Namespace.Type.Type" or "Namespace.Type.Type()"
if (userInputNoParams.Equals(typeFullName + "." + methodSymbol.ContainingType.Name, StringComparison.OrdinalIgnoreCase)) {
return (ConstructorShorthandFullMatchScore, "Constructor shorthand (Type.Type)");
}
// User typed "Namespace.Type" or "Namespace.Type()"
if (userInputNoParams.Equals(typeFullName, StringComparison.OrdinalIgnoreCase)) {
return (ConstructorShorthandTypeNameMatchScore, "Constructor shorthand (Type)");
}
}
// Check cancellation before the next set of comparisons
cancellationToken.ThrowIfCancellationRequested();
// 2. Match after removing parameters (user omitted them or canonical was parameterless)
if (userInputNoParams.Equals(canonicalFqnNoParams, StringComparison.OrdinalIgnoreCase)) {
bool userInputHadParams = userInputFqn.Length != userInputNoParams.Length;
bool canonicalHadParams = canonicalFqn.Length != canonicalFqnNoParams.Length;
if (userInputHadParams && canonicalHadParams) { // Both had params, but content differed (caught by initial exact match if same)
return (ParametersContentMismatchScore, "Parameter content mismatch");
}
return (ParamsOmittedScore, "Parameter list omitted/matched empty");
}
// 3. Match with normalized generic arguments
if (userInputWithNormalizedGenerics.Equals(canonicalFqnWithNormalizedGenerics, StringComparison.OrdinalIgnoreCase)) {
return (0.95, "Generic arguments normalized match");
}
// 4. Match with generic arguments stripped (user might search for base name)
string userInputNoGenerics = StripGenericArgs(userInputNoParams);
string canonicalFqnNoGenerics = StripGenericArgs(canonicalFqnNoParams);
if (userInputNoGenerics.Equals(canonicalFqnNoGenerics, StringComparison.OrdinalIgnoreCase)) {
// If the user actually included generic args, but we had to strip them to match,
// it could mean their generic args don't match the canonical ones
bool userHadGenericArgs = userInputNoParams.Contains("<") && userInputNoParams != userInputNoGenerics;
bool canonicalHadGenericArgs = canonicalFqnNoParams.Contains("<") && canonicalFqnNoParams != canonicalFqnNoGenerics;
if (userHadGenericArgs && canonicalHadGenericArgs) {
// Both had generic args but they didn't match exactly
return (GenericArgsOmittedScore, "Generic arguments content mismatch");
}
return (ArityOmittedScore, "Generic arguments omitted/matched empty");
}
// 5. Nested Type: User used '.' where canonical FQN uses '+'
// This should be less relevant now that we normalize '+' to '.' in GetSearchableString,
// but kept for backward compatibility
string? userNestedFixed = TryFixNestedTypeSeparator(userInputNoGenerics, canonicalFqnNoGenerics, cancellationToken);
if (userNestedFixed != null && userNestedFixed.Equals(canonicalFqnNoGenerics, StringComparison.OrdinalIgnoreCase)) {
return (NestedTypeDotForPlusScore, "Nested type separator '.' used for '+'");
}
return (0.0, "No significant match");
}
private string RemoveParameters(string fqn) {
// A more robust way to find the first '(':
int openParenIndex = fqn.IndexOf('(');
if (openParenIndex != -1) {
// Check if it's part of a generic type argument list like `Method(List<string>)`
// or a method generic parameter list like `Method<T>(T p)`
// SymbolDisplayFormat.FullyQualifiedFormat puts method type parameters like `Method``1`
// and parameters like `(System.Int32)`.
// So, `(` should reliably indicate start of parameter list for methods.
// For delegates, it might be `System.Action()`
string result = fqn.Substring(0, openParenIndex);
return result;
}
return fqn;
}
private string? TryFixNestedTypeSeparator(string userInputFqnPart, string canonicalFqnPart, CancellationToken cancellationToken) {
// Check cancellation at the beginning of processing
cancellationToken.ThrowIfCancellationRequested();
// This heuristic attempts to replace '.' with '+' in the type path part of the FQN.
// It assumes member names (after the last type segment) don't contain '.' or '+'.
// Example: User "N.O.N.M", Canonical "N.O+N.M"
// We need to identify "N.O.N" vs "N.O+N" and "M" vs "M"
int userLastDot = userInputFqnPart.LastIndexOf('.');
int canonicalLastPlusOrDot = Math.Max(canonicalFqnPart.LastIndexOf('+'), canonicalFqnPart.LastIndexOf('.'));
// If no dot/plus, it might be a global type or just a member name if type path was empty.
string userTypePath = userLastDot > -1 ? userInputFqnPart.Substring(0, userLastDot) : "";
string userMemberName = userLastDot > -1 ? userInputFqnPart.Substring(userLastDot) : userInputFqnPart; // Member name includes the leading '.' if present
string canonicalTypePath = canonicalLastPlusOrDot > -1 ? canonicalFqnPart.Substring(0, canonicalLastPlusOrDot) : "";
string canonicalMemberName = canonicalLastPlusOrDot > -1 ? canonicalFqnPart.Substring(canonicalLastPlusOrDot) : canonicalFqnPart;
if (userMemberName.Equals(canonicalMemberName, StringComparison.OrdinalIgnoreCase)) {
if (userTypePath.Replace('.', '+').Equals(canonicalTypePath, StringComparison.OrdinalIgnoreCase)) {
string result = canonicalTypePath + userMemberName;
return result; // Return the "fixed" version based on canonical structure
}
}
// Special case: if the whole thing is a type name (no distinct member part)
else if (string.IsNullOrEmpty(userMemberName) && string.IsNullOrEmpty(canonicalMemberName) || userLastDot == -1 && canonicalLastPlusOrDot == -1) {
if (userInputFqnPart.Replace('.', '+').Equals(canonicalFqnPart, StringComparison.OrdinalIgnoreCase)) {
return canonicalFqnPart;
}
}
return null; // No simple fix found
}
public static string GetSearchableString(ISymbol symbol) {
var fullFormat = new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | SymbolDisplayGenericsOptions.IncludeVariance,
memberOptions: SymbolDisplayMemberOptions.IncludeParameters |
SymbolDisplayMemberOptions.IncludeType |
SymbolDisplayMemberOptions.IncludeRef |
SymbolDisplayMemberOptions.IncludeContainingType,
parameterOptions: SymbolDisplayParameterOptions.IncludeType |
SymbolDisplayParameterOptions.IncludeName |
SymbolDisplayParameterOptions.IncludeParamsRefOut |
SymbolDisplayParameterOptions.IncludeDefaultValue,
propertyStyle: SymbolDisplayPropertyStyle.NameOnly,
localOptions: SymbolDisplayLocalOptions.None,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
// For parameters and return types, use a format that doesn't qualify types
var shortFormat = new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeParameters |
SymbolDisplayMemberOptions.IncludeType,
parameterOptions: SymbolDisplayParameterOptions.IncludeType,
//SymbolDisplayParameterOptions.IncludeName |
//SymbolDisplayParameterOptions.IncludeParamsRefOut |
//SymbolDisplayParameterOptions.IncludeDefaultValue,
propertyStyle: SymbolDisplayPropertyStyle.NameOnly,
localOptions: SymbolDisplayLocalOptions.None,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
var fqn = new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeContainingType,
parameterOptions: SymbolDisplayParameterOptions.None,
propertyStyle: SymbolDisplayPropertyStyle.NameOnly,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
if (symbol is IMethodSymbol methodSymbol) {
// Format the method name and containing type with full qualification
var methodNameAndType = methodSymbol.ToDisplayString(fqn);
// Find the indices where return type ends and method name begins
//var returnTypeEndIndex = methodParts.TakeWhile(p => p.Kind != SymbolDisplayPartKind.MethodName).Count();
//var nameStartIndex = returnTypeEndIndex;
//var nameEndIndex = methodParts.TakeWhile(p => p.Kind != SymbolDisplayPartKind.Punctuation && p.ToString() != "(").Count();
// Get the parts we need
//var modifiers = methodParts.Take(methodParts.TakeWhile(p => p.Kind == SymbolDisplayPartKind.Keyword).Count());
//var returnType = methodSymbol.ReturnType.ToDisplayString(shortFormat);
//var nameAndContainingType = string.Concat(methodParts.Take(nameEndIndex));
// Get param types only
var parameters = string.Join(", ", methodSymbol.Parameters
.Select(p => string.Concat(p.ToDisplayParts(shortFormat)
.TakeWhile(part => part.Kind != SymbolDisplayPartKind.ParameterName)
.Select(part => part.ToString()))));
// Combine all parts
var signature = methodNameAndType + "(" + parameters + ")";
return signature.Replace(" ", string.Empty);
}
// For non-method symbols, use the original full format
return symbol.ToDisplayString(fqn).Replace(" ", string.Empty);
}
private async Task LogAmbiguityDetailsAsync(string fuzzyFqnInput, List<FuzzyMatchResult> results, CancellationToken cancellationToken) {
const double HighScoreThreshold = 0.8; // Threshold for considering a match "high-scoring"
const int MaxDetailedLogsPerAmbiguity = 10; // Limit detailed logs to prevent spam
// Group matches by score ranges to identify ambiguity patterns
var highScoreMatches = results.Where(r => r.Score >= HighScoreThreshold).ToList();
var perfectMatches = results.Where(r => r.Score >= PerfectMatchScore - 0.01).ToList();
// Log ambiguity when we have multiple high-scoring matches
if (highScoreMatches.Count > 1) {
_logger.LogWarning("Ambiguity detected for input '{FuzzyFqn}': Found {HighScoreCount} high-scoring matches (>= {Threshold})",
fuzzyFqnInput, highScoreMatches.Count, HighScoreThreshold);
// Group by project to understand cross-project ambiguity
var matchesByProject = highScoreMatches
.GroupBy(match => GetProjectName(match.Symbol))
.ToList();
if (matchesByProject.Count > 1) {
_logger.LogWarning("Cross-project ambiguity detected: Matches span {ProjectCount} projects", matchesByProject.Count);
foreach (var projectGroup in matchesByProject) {
_logger.LogInformation("Project '{ProjectName}' has {MatchCount} ambiguous matches",
projectGroup.Key, projectGroup.Count());
}
}
// Log detailed information for the top matches
var detailedMatches = highScoreMatches.Take(MaxDetailedLogsPerAmbiguity).ToList();
for (int i = 0; i < detailedMatches.Count; i++) {
var match = detailedMatches[i];
LogDetailedMatchInfoAsync(match, i + 1, cancellationToken);
}
// Log summary statistics about the ambiguity
LogAmbiguitySummary(fuzzyFqnInput, results, highScoreMatches, perfectMatches);
}
}
private void LogDetailedMatchInfoAsync(FuzzyMatchResult match, int rank, CancellationToken cancellationToken) {
try {
var symbol = match.Symbol;
var projectName = GetProjectName(symbol);
var symbolKind = GetSymbolKindString(symbol);
var containingType = GetContainingTypeInfo(symbol);
var assemblyName = GetAssemblyName(symbol);
var location = GetLocationInfo(symbol);
var formattedSignature = CodeAnalysisService.GetFormattedSignatureAsync(symbol, false);
_logger.LogWarning("Ambiguous Match #{Rank}: Score={Score:F3}, Reason='{Reason}'", rank, match.Score, match.MatchReason);
_logger.LogInformation(" Symbol Details:");
_logger.LogInformation(" FQN: {CanonicalFqn}", match.CanonicalFqn);
_logger.LogInformation(" Formatted Signature: {FormattedSignature}", formattedSignature);
_logger.LogInformation(" Symbol Kind: {SymbolKind}", symbolKind);
_logger.LogInformation(" Symbol Name: {SymbolName}", symbol.Name);
_logger.LogInformation(" Project: {ProjectName}", projectName);
_logger.LogInformation(" Assembly: {AssemblyName}", assemblyName);
_logger.LogInformation(" Location: {Location}", location);
if (!string.IsNullOrEmpty(containingType)) {
_logger.LogInformation(" Containing Type: {ContainingType}", containingType);
}
// Log additional type-specific information
LogTypeSpecificDetails(symbol);
// Log accessibility and modifiers
var accessibility = symbol.DeclaredAccessibility.ToString();
var modifiers = GetSymbolModifiers(symbol);
_logger.LogInformation(" Accessibility: {Accessibility}", accessibility);
if (!string.IsNullOrEmpty(modifiers)) {
_logger.LogInformation(" Modifiers: {Modifiers}", modifiers);
}
} catch (Exception ex) {
_logger.LogError(ex, "Error logging detailed match info for symbol {SymbolName}", match.Symbol.Name);
}
}
private void LogTypeSpecificDetails(ISymbol symbol) {
switch (symbol) {
case INamedTypeSymbol namedType:
_logger.LogInformation(" Type Kind: {TypeKind}", namedType.TypeKind);
_logger.LogInformation(" Is Generic: {IsGeneric}", namedType.IsGenericType);
_logger.LogInformation(" Arity: {Arity}", namedType.Arity);
_logger.LogInformation(" Member Count: {MemberCount}", namedType.GetMembers().Length);
if (namedType.BaseType != null) {
_logger.LogInformation(" Base Type: {BaseType}", namedType.BaseType.ToDisplayString());
}
if (namedType.Interfaces.Any()) {
_logger.LogInformation(" Implements: {InterfaceCount} interfaces", namedType.Interfaces.Length);
}
break;
case IMethodSymbol method:
_logger.LogInformation(" Method Kind: {MethodKind}", method.MethodKind);
_logger.LogInformation(" Return Type: {ReturnType}", method.ReturnType.ToDisplayString());
_logger.LogInformation(" Parameter Count: {ParameterCount}", method.Parameters.Length);
_logger.LogInformation(" Is Generic: {IsGeneric}", method.IsGenericMethod);
_logger.LogInformation(" Is Extension: {IsExtension}", method.IsExtensionMethod);
_logger.LogInformation(" Is Async: {IsAsync}", method.IsAsync);
if (method.Parameters.Any()) {
var parameterTypes = string.Join(", ", method.Parameters.Select(p => p.Type.ToDisplayString()));
_logger.LogInformation(" Parameters: {ParameterTypes}", parameterTypes);
}
break;
case IPropertySymbol property:
_logger.LogInformation(" Property Type: {PropertyType}", property.Type.ToDisplayString());
_logger.LogInformation(" Is ReadOnly: {IsReadOnly}", property.IsReadOnly);
_logger.LogInformation(" Is WriteOnly: {IsWriteOnly}", property.IsWriteOnly);
_logger.LogInformation(" Is Indexer: {IsIndexer}", property.IsIndexer);
break;
case IFieldSymbol field:
_logger.LogInformation(" Field Type: {FieldType}", field.Type.ToDisplayString());
_logger.LogInformation(" Is Const: {IsConst}", field.IsConst);
_logger.LogInformation(" Is ReadOnly: {IsReadOnly}", field.IsReadOnly);
_logger.LogInformation(" Is Static: {IsStatic}", field.IsStatic);
if (field.IsConst && field.ConstantValue != null) {
_logger.LogInformation(" Constant Value: {ConstantValue}", field.ConstantValue);
}
break;
case IEventSymbol eventSymbol:
_logger.LogInformation(" Event Type: {EventType}", eventSymbol.Type.ToDisplayString());
break;
}
}
private void LogAmbiguitySummary(string fuzzyFqnInput, List<FuzzyMatchResult> allResults, List<FuzzyMatchResult> highScoreMatches, List<FuzzyMatchResult> perfectMatches) {
_logger.LogInformation("Ambiguity Summary for '{FuzzyFqn}':", fuzzyFqnInput);
_logger.LogInformation(" Total matches: {TotalMatches}", allResults.Count);
_logger.LogInformation(" Perfect matches (>= {PerfectThreshold:F3}): {PerfectCount}", PerfectMatchScore - 0.01, perfectMatches.Count);
_logger.LogInformation(" High-scoring matches (>= 0.8): {HighScoreCount}", highScoreMatches.Count);
// Score distribution
var scoreRanges = new[] {
(1.0, 0.95, "Excellent"),
(0.95, 0.9, "Very Good"),
(0.9, 0.8, "Good"),
(0.8, 0.7, "Acceptable")
};
foreach (var (upper, lower, label) in scoreRanges) {
var count = allResults.Count(r => r.Score < upper && r.Score >= lower);
if (count > 0) {
_logger.LogInformation(" {Label} matches ({Lower:F1}-{Upper:F1}): {Count}", label, lower, upper, count);
}
}
// Symbol kind distribution
var symbolKinds = highScoreMatches
.GroupBy(m => GetSymbolKindString(m.Symbol))
.OrderByDescending(g => g.Count())
.ToList();
if (symbolKinds.Any()) {
_logger.LogInformation(" Symbol kinds in high-scoring matches:");
foreach (var kind in symbolKinds) {
_logger.LogInformation(" {SymbolKind}: {Count}", kind.Key, kind.Count());
}
}
// Project distribution
var projects = highScoreMatches
.GroupBy(m => GetProjectName(m.Symbol))
.OrderByDescending(g => g.Count())
.ToList();
if (projects.Count > 1) {
_logger.LogInformation(" Project distribution:");
foreach (var project in projects) {
_logger.LogInformation(" {ProjectName}: {Count}", project.Key, project.Count());
}
}
}
private string GetProjectName(ISymbol symbol) {
try {
// Try to get the project from the symbol's containing assembly
var assembly = symbol.ContainingAssembly;
if (assembly?.Name != null) {
return assembly.Name;
}
// Fallback: look through loaded projects
var syntaxRefs = symbol.DeclaringSyntaxReferences;
if (syntaxRefs.Any()) {
var syntaxTree = syntaxRefs.First().SyntaxTree;
foreach (var project in _solutionManager?.CurrentSolution?.Projects ?? Enumerable.Empty<Project>()) {
if (project.Documents.Any(d => d.GetSyntaxTreeAsync().Result == syntaxTree)) {
return project.Name;
}
}
}
return "Unknown Project";
} catch {
return "Unknown Project";
}
}
private string GetSymbolKindString(ISymbol symbol) {
return symbol.Kind.ToString();
}
private string GetContainingTypeInfo(ISymbol symbol) {
if (symbol.ContainingType != null) {
return symbol.ContainingType.ToDisplayString();
}
return string.Empty;
}
private string GetAssemblyName(ISymbol symbol) {
return symbol.ContainingAssembly?.Name ?? "Unknown Assembly";
}
private string GetLocationInfo(ISymbol symbol) {
var location = symbol.Locations.FirstOrDefault(loc => loc.IsInSource);
if (location?.SourceTree?.FilePath != null) {
var lineSpan = location.GetLineSpan();
return $"{Path.GetFileName(location.SourceTree.FilePath)}:{lineSpan.StartLinePosition.Line + 1}";
}
return "No source location";
}
private string GetSymbolModifiers(ISymbol symbol) {
var modifiers = new List<string>();
if (symbol.IsStatic) modifiers.Add("static");
if (symbol.IsVirtual) modifiers.Add("virtual");
if (symbol.IsOverride) modifiers.Add("override");
if (symbol.IsAbstract) modifiers.Add("abstract");
if (symbol.IsSealed) modifiers.Add("sealed");
if (symbol is IMethodSymbol method) {
if (method.IsAsync) modifiers.Add("async");
if (method.IsExtern) modifiers.Add("extern");
}
if (symbol is IFieldSymbol field) {
if (field.IsReadOnly) modifiers.Add("readonly");
if (field.IsConst) modifiers.Add("const");
if (field.IsVolatile) modifiers.Add("volatile");
}
return string.Join(" ", modifiers);
}
/// <summary>
/// Normalizes generic arguments in a type name by replacing specific type names with placeholders.
/// This allows matching between different generic instantiations.
/// </summary>
/// <param name="typeName">The type name with generic arguments</param>
/// <returns>Type name with normalized generic arguments</returns>
private string NormalizeGenericArgs(string typeName) {
// If there are no generic arguments, return as is
if (!typeName.Contains("<")) {
return typeName;
}
// Match angle bracket content, keeping the brackets
return Regex.Replace(typeName, @"<([^<>]*)>", match => {
// Replace the content with a normalized form
string content = match.Groups[1].Value;
if (string.IsNullOrWhiteSpace(content)) {
return "<>";
}
// Handle multiple type arguments separated by commas
var args = content.Split(',').Select(arg => "T").ToArray();
return $"<{string.Join(",", args)}>";
});
}
/// <summary>
/// Completely strips generic arguments from a type name.
/// </summary>
/// <param name="typeName">The type name with generic arguments</param>
/// <returns>Type name without generic arguments</returns>
private string StripGenericArgs(string typeName) {
// First, remove Roslyn-style arity indicators like List`1
string withoutArity = ArityRegex.Replace(typeName, "");
// Then remove angle bracket content including brackets
return GenericArgsRegex.Replace(withoutArity, "");
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/SemanticSimilarityService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.FlowAnalysis;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Extensions;
using SharpTools.Tools.Mcp;
using System;
using System.Collections.Concurrent; // Added
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Services {
public class SemanticSimilarityService : ISemanticSimilarityService {
private static class Tuning {
public static readonly int MaxDegreesOfParallelism = Math.Max(1, Environment.ProcessorCount / 2);
public const int MethodLineCountFilter = 10;
public const double DefaultSimilarityThreshold = 0.7;
public static class Normalization {
public const int MaxBasicBlockCount = 60;
public const int MaxConditionalBranchCount = 25;
public const int MaxLoopCount = 8;
public const int MaxCyclomaticComplexity = 30;
}
public static class Weights {
public enum Feature {
ReturnType,
ParamCount,
ParamTypes,
InvokedMethods,
BasicBlocks,
ConditionalBranches,
Loops,
CyclomaticComplexity,
OperationCounts,
AccessedMemberTypes
}
public static readonly Dictionary<Feature, double> FeatureWeights = new() {
{ Feature.InvokedMethods, 0.25 },
{ Feature.OperationCounts, 0.20 },
{ Feature.AccessedMemberTypes, 0.15 },
{ Feature.ParamTypes, 0.10 },
{ Feature.CyclomaticComplexity, 0.075 },
{ Feature.ReturnType, 0.05 },
{ Feature.BasicBlocks, 0.05 },
{ Feature.ConditionalBranches, 0.05 },
{ Feature.Loops, 0.05 },
{ Feature.ParamCount, 0.025 },
};
public static double ReturnType => FeatureWeights[Feature.ReturnType];
public static double ParamCount => FeatureWeights[Feature.ParamCount];
public static double ParamTypes => FeatureWeights[Feature.ParamTypes];
public static double InvokedMethods => FeatureWeights[Feature.InvokedMethods];
public static double BasicBlocks => FeatureWeights[Feature.BasicBlocks];
public static double ConditionalBranches => FeatureWeights[Feature.ConditionalBranches];
public static double Loops => FeatureWeights[Feature.Loops];
public static double CyclomaticComplexity => FeatureWeights[Feature.CyclomaticComplexity];
public static double OperationCounts => FeatureWeights[Feature.OperationCounts];
public static double AccessedMemberTypes => FeatureWeights[Feature.AccessedMemberTypes];
public static double TotalWeight => FeatureWeights.Values.Sum();
}
public const int ClassLineCountFilter = 20;
public static class ClassNormalization {
public const int MaxPropertyCount = 30;
public const int MaxFieldCount = 50;
public const int MaxMethodCount = 50;
public const int MaxImplementedInterfaces = 10;
public const int MaxReferencedExternalTypes = 75;
public const int MaxUsedNamespaces = 20;
public const double MaxAverageMethodComplexity = 15.0;
}
public static class ClassWeights {
public enum Feature {
BaseClassName,
ImplementedInterfaceNames,
PublicMethodCount,
ProtectedMethodCount,
PrivateMethodCount,
StaticMethodCount,
AbstractMethodCount,
VirtualMethodCount,
PropertyCount,
ReadOnlyPropertyCount,
StaticPropertyCount,
FieldCount,
StaticFieldCount,
ReadonlyFieldCount,
ConstFieldCount,
EventCount,
NestedClassCount,
NestedStructCount,
NestedEnumCount,
NestedInterfaceCount,
AverageMethodComplexity,
DistinctReferencedExternalTypeFqns,
DistinctUsedNamespaceFqns,
TotalLinesOfCode,
MethodMatchingSimilarity // Added
}
public static readonly Dictionary<Feature, double> FeatureWeights = new()
{
{ Feature.MethodMatchingSimilarity, 0.20 }, // New and significant
{ Feature.ImplementedInterfaceNames, 0.15 }, // Was 0.20
{ Feature.DistinctReferencedExternalTypeFqns, 0.15 }, // Was 0.20
{ Feature.BaseClassName, 0.07 }, // Was 0.10
{ Feature.AverageMethodComplexity, 0.05 }, // Was 0.05
{ Feature.PublicMethodCount, 0.03 }, // Was 0.05
{ Feature.PropertyCount, 0.03 }, // Was 0.05
{ Feature.FieldCount, 0.03 }, // Was 0.05
{ Feature.DistinctUsedNamespaceFqns, 0.03 }, // Was 0.05
{ Feature.TotalLinesOfCode, 0.02 }, // Was 0.025
{ Feature.ProtectedMethodCount, 0.02 }, // Was 0.025
{ Feature.PrivateMethodCount, 0.02 }, // Was 0.025
{ Feature.StaticMethodCount, 0.02 }, // Was 0.025
{ Feature.AbstractMethodCount, 0.02 }, // Was 0.025
{ Feature.VirtualMethodCount, 0.02 }, // Was 0.025
{ Feature.ReadOnlyPropertyCount, 0.01 },
{ Feature.StaticPropertyCount, 0.01 },
{ Feature.StaticFieldCount, 0.01 },
{ Feature.ReadonlyFieldCount, 0.01 },
{ Feature.ConstFieldCount, 0.01 },
{ Feature.EventCount, 0.01 },
{ Feature.NestedClassCount, 0.01 },
{ Feature.NestedStructCount, 0.01 },
{ Feature.NestedEnumCount, 0.01 },
{ Feature.NestedInterfaceCount, 0.01 }
};
public static double BaseClassName => FeatureWeights[Feature.BaseClassName];
public static double ImplementedInterfaceNames => FeatureWeights[Feature.ImplementedInterfaceNames];
public static double PublicMethodCount => FeatureWeights[Feature.PublicMethodCount];
public static double ProtectedMethodCount => FeatureWeights[Feature.ProtectedMethodCount];
public static double PrivateMethodCount => FeatureWeights[Feature.PrivateMethodCount];
public static double StaticMethodCount => FeatureWeights[Feature.StaticMethodCount];
public static double AbstractMethodCount => FeatureWeights[Feature.AbstractMethodCount];
public static double VirtualMethodCount => FeatureWeights[Feature.VirtualMethodCount];
public static double PropertyCount => FeatureWeights[Feature.PropertyCount];
public static double ReadOnlyPropertyCount => FeatureWeights[Feature.ReadOnlyPropertyCount];
public static double StaticPropertyCount => FeatureWeights[Feature.StaticPropertyCount];
public static double FieldCount => FeatureWeights[Feature.FieldCount];
public static double StaticFieldCount => FeatureWeights[Feature.StaticFieldCount];
public static double ReadonlyFieldCount => FeatureWeights[Feature.ReadonlyFieldCount];
public static double ConstFieldCount => FeatureWeights[Feature.ConstFieldCount];
public static double EventCount => FeatureWeights[Feature.EventCount];
public static double NestedClassCount => FeatureWeights[Feature.NestedClassCount];
public static double NestedStructCount => FeatureWeights[Feature.NestedStructCount];
public static double NestedEnumCount => FeatureWeights[Feature.NestedEnumCount];
public static double NestedInterfaceCount => FeatureWeights[Feature.NestedInterfaceCount];
public static double AverageMethodComplexity => FeatureWeights[Feature.AverageMethodComplexity];
public static double DistinctReferencedExternalTypeFqns => FeatureWeights[Feature.DistinctReferencedExternalTypeFqns];
public static double DistinctUsedNamespaceFqns => FeatureWeights[Feature.DistinctUsedNamespaceFqns];
public static double TotalLinesOfCode => FeatureWeights[Feature.TotalLinesOfCode];
public static double MethodMatchingSimilarity => FeatureWeights[Feature.MethodMatchingSimilarity]; // Added
public static double TotalWeight => FeatureWeights.Values.Sum();
}
}
private readonly ISolutionManager _solutionManager;
private readonly ICodeAnalysisService _codeAnalysisService;
private readonly ILogger<SemanticSimilarityService> _logger;
private readonly IComplexityAnalysisService _complexityAnalysisService;
public SemanticSimilarityService(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<SemanticSimilarityService> logger,
IComplexityAnalysisService complexityAnalysisService) {
_solutionManager = solutionManager ?? throw new ArgumentNullException(nameof(solutionManager));
_codeAnalysisService = codeAnalysisService ?? throw new ArgumentNullException(nameof(codeAnalysisService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_complexityAnalysisService = complexityAnalysisService ?? throw new ArgumentNullException(nameof(complexityAnalysisService));
}
public async Task<List<MethodSimilarityResult>> FindSimilarMethodsAsync(
double similarityThreshold,
CancellationToken cancellationToken) {
ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarMethodsAsync));
_logger.LogInformation("Starting semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism);
var allMethodFeatures = new System.Collections.Concurrent.ConcurrentBag<MethodSemanticFeatures>();
var parallelOptions = new ParallelOptions {
MaxDegreeOfParallelism = Tuning.MaxDegreesOfParallelism,
CancellationToken = cancellationToken
};
var projects = _solutionManager.GetProjects().ToList(); // Materialize to avoid issues with concurrent modification if GetProjects() is lazy
await Parallel.ForEachAsync(projects, parallelOptions, async (project, ct) => {
if (ct.IsCancellationRequested) {
_logger.LogInformation("Semantic similarity analysis cancelled during project iteration for {ProjectName}.", project.Name);
return;
}
_logger.LogDebug("Analyzing project: {ProjectName}", project.Name);
var compilation = await project.GetCompilationAsync(ct);
if (compilation == null) {
_logger.LogWarning("Could not get compilation for project {ProjectName}", project.Name);
return;
}
var documents = project.Documents.ToList(); // Materialize documents for the current project
await Parallel.ForEachAsync(documents, parallelOptions, async (document, docCt) => {
if (docCt.IsCancellationRequested) return;
if (!document.SupportsSyntaxTree || !document.SupportsSemanticModel) return;
_logger.LogTrace("Analyzing document: {DocumentFilePath}", document.FilePath);
var syntaxTree = await document.GetSyntaxTreeAsync(docCt);
var semanticModel = await document.GetSemanticModelAsync(docCt);
if (syntaxTree == null || semanticModel == null) return;
var methodDeclarations = syntaxTree.GetRoot(docCt).DescendantNodes().OfType<MethodDeclarationSyntax>();
foreach (var methodDecl in methodDeclarations) {
if (docCt.IsCancellationRequested) break;
var methodSymbol = semanticModel.GetDeclaredSymbol(methodDecl, docCt) as IMethodSymbol;
if (methodSymbol == null || methodSymbol.IsAbstract || methodSymbol.IsExtern || ToolHelpers.IsPropertyAccessor(methodSymbol)) {
continue;
}
try {
var features = await ExtractFeaturesAsync(methodSymbol, methodDecl, document, semanticModel, docCt);
if (features != null) {
allMethodFeatures.Add(features);
}
} catch (OperationCanceledException) {
_logger.LogInformation("Feature extraction cancelled for method {MethodName} in {FilePath}", methodSymbol?.Name ?? "Unknown", document.FilePath);
} catch (Exception ex) {
_logger.LogWarning(ex, "Failed to extract features for method {MethodName} in {FilePath}", methodSymbol?.Name ?? "Unknown", document.FilePath);
}
}
});
});
if (cancellationToken.IsCancellationRequested) {
_logger.LogInformation("Semantic similarity analysis was cancelled before comparison.");
throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
}
_logger.LogInformation("Extracted features for {MethodCount} methods. Starting similarity comparison.", allMethodFeatures.Count);
return CompareFeatures(allMethodFeatures.ToList(), similarityThreshold, cancellationToken);
}
private async Task<MethodSemanticFeatures?> ExtractFeaturesAsync(
IMethodSymbol methodSymbol,
MethodDeclarationSyntax methodDecl,
Document document,
SemanticModel semanticModel,
CancellationToken cancellationToken) {
var filePath = document.FilePath ?? "unknown";
var startLine = methodDecl.GetLocation().GetLineSpan().StartLinePosition.Line;
var endLine = methodDecl.GetLocation().GetLineSpan().EndLinePosition.Line;
var lineCount = endLine - startLine + 1;
if (lineCount < Tuning.MethodLineCountFilter) {
_logger.LogDebug("Method {MethodName} in {FilePath} has {LineCount} lines, which is less than the filter of {FilterCount}. Skipping.", methodSymbol.Name, filePath, lineCount, Tuning.MethodLineCountFilter);
return null;
}
var methodName = methodSymbol.Name;
var fullyQualifiedMethodName = methodSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
var returnTypeName = methodSymbol.ReturnType.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
var parameterTypeNames = methodSymbol.Parameters.Select(p => p.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)).ToList();
var invokedMethodSignatures = new HashSet<string>();
var operationCounts = new Dictionary<string, int>();
var distinctAccessedMemberTypes = new HashSet<string>();
int basicBlockCount = 0;
int conditionalBranchCount = 0;
int loopCount = 0;
var methodMetrics = new Dictionary<string, object>();
var recommendations = new List<string>();
await _complexityAnalysisService.AnalyzeMethodAsync(methodSymbol, methodMetrics, recommendations, cancellationToken);
int cyclomaticComplexity = methodMetrics.TryGetValue("cyclomaticComplexity", out var cc) && cc is int ccVal ? ccVal : 1;
SyntaxNode? bodyOrExpressionBody = methodDecl.Body ?? (SyntaxNode?)methodDecl.ExpressionBody?.Expression;
if (bodyOrExpressionBody != null) {
try {
// Use methodDecl directly for CFG creation
var controlFlowGraph = ControlFlowGraph.Create(methodDecl, semanticModel, cancellationToken);
if (controlFlowGraph != null && controlFlowGraph.Blocks.Any()) {
basicBlockCount = controlFlowGraph.Blocks.Length;
}
_logger.LogDebug("ControlFlowGraph created for method {MethodName} in {FilePath}. BasicBlockCount: {BasicBlockCount}", methodName, filePath, basicBlockCount);
} catch (Exception ex) {
_logger.LogWarning(ex, "Failed to create ControlFlowGraph for method {MethodName} in {FilePath}. CFG-based features will be zero.", methodName, filePath);
}
var operation = semanticModel.GetOperation(bodyOrExpressionBody, cancellationToken);
if (operation != null) {
foreach (var opNode in operation.DescendantsAndSelf()) {
if (cancellationToken.IsCancellationRequested) {
return null;
}
var opKindName = opNode.Kind.ToString();
operationCounts[opKindName] = operationCounts.GetValueOrDefault(opKindName, 0) + 1;
if (opNode is IInvocationOperation invocation) {
invokedMethodSignatures.Add(invocation.TargetMethod.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat));
} else if (opNode is IFieldReferenceOperation fieldRef) {
distinctAccessedMemberTypes.Add(fieldRef.Field.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
} else if (opNode is IPropertyReferenceOperation propRef) {
distinctAccessedMemberTypes.Add(propRef.Property.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
}
if (opNode is ILoopOperation) {
loopCount++;
} else if (opNode is IConditionalOperation) {
conditionalBranchCount++;
}
}
}
}
return new MethodSemanticFeatures(
fullyQualifiedMethodName,
filePath,
startLine,
methodName,
returnTypeName,
parameterTypeNames,
invokedMethodSignatures,
basicBlockCount,
conditionalBranchCount,
loopCount,
cyclomaticComplexity,
operationCounts,
distinctAccessedMemberTypes);
}
private List<MethodSimilarityResult> CompareFeatures(
List<MethodSemanticFeatures> allMethodFeatures,
double similarityThreshold,
CancellationToken cancellationToken) {
var results = new List<MethodSimilarityResult>();
var processedIndices = new HashSet<int>();
_logger.LogInformation("Starting similarity comparison for {MethodCount} methods.", allMethodFeatures.Count);
for (int i = 0; i < allMethodFeatures.Count; i++) {
if (cancellationToken.IsCancellationRequested) {
throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
}
if (processedIndices.Contains(i)) {
continue;
}
var currentMethod = allMethodFeatures[i];
var similarGroup = new List<MethodSemanticFeatures> { currentMethod };
processedIndices.Add(i);
double groupTotalScore = 0;
int comparisonsMade = 0;
_logger.LogDebug("Comparing method {MethodName} ({FQN}) with other methods.", currentMethod.MethodName, currentMethod.FullyQualifiedMethodName);
for (int j = i + 1; j < allMethodFeatures.Count; j++) {
if (cancellationToken.IsCancellationRequested) {
throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
}
if (processedIndices.Contains(j)) {
continue;
}
var otherMethod = allMethodFeatures[j];
// Skip comparison if methods are overloads of each other
if (currentMethod.FullyQualifiedMethodName == otherMethod.FullyQualifiedMethodName &&
!currentMethod.ParameterTypeNames.SequenceEqual(otherMethod.ParameterTypeNames)) {
_logger.LogDebug("Skipping comparison between overloads: {Method1FQN} ({Params1}) and {Method2FQN} ({Params2})",
currentMethod.FullyQualifiedMethodName, string.Join(", ", currentMethod.ParameterTypeNames),
otherMethod.FullyQualifiedMethodName, string.Join(", ", otherMethod.ParameterTypeNames));
continue;
}
double similarity = CalculateSimilarity(currentMethod, otherMethod);
if (similarity >= similarityThreshold) {
similarGroup.Add(otherMethod);
processedIndices.Add(j);
groupTotalScore += similarity;
comparisonsMade++;
_logger.LogDebug("Method {OtherMethodName} ({OtherFQN}) is similar to {CurrentMethodName} ({CurrentFQN}) with score {SimilarityScore}",
otherMethod.MethodName, otherMethod.FullyQualifiedMethodName,
currentMethod.MethodName, currentMethod.FullyQualifiedMethodName,
similarity);
}
}
if (similarGroup.Count > 1) {
double averageScore = comparisonsMade > 0 ? groupTotalScore / comparisonsMade : 1.0; // Avoid division by zero if only self-comparison
results.Add(new MethodSimilarityResult(similarGroup, averageScore));
_logger.LogInformation("Found similarity group of {GroupSize} methods, starting with {MethodName} ({FQN}), Avg Score: {Score:F2}",
similarGroup.Count,
currentMethod.MethodName,
currentMethod.FullyQualifiedMethodName,
averageScore);
}
}
_logger.LogInformation("Semantic similarity analysis complete. Found {GroupCount} groups.", results.Count);
return results.OrderByDescending(r => r.AverageSimilarityScore).ToList();
}
private double CalculateSimilarity(MethodSemanticFeatures method1, MethodSemanticFeatures method2) {
double returnTypeSimilarity = (method1.ReturnTypeName == method2.ReturnTypeName) ? 1.0 : 0.0;
double paramCountSimilarity = (method1.ParameterTypeNames.Count == method2.ParameterTypeNames.Count) ? 1.0 : 0.0;
double paramTypeSimilarity = 0.0;
if (method1.ParameterTypeNames.Count == method2.ParameterTypeNames.Count && method1.ParameterTypeNames.Any()) {
int matchingParams = 0;
for (int k = 0; k < method1.ParameterTypeNames.Count; k++) {
if (method1.ParameterTypeNames[k] == method2.ParameterTypeNames[k]) {
matchingParams++;
}
}
paramTypeSimilarity = (double)matchingParams / method1.ParameterTypeNames.Count;
} else if (method1.ParameterTypeNames.Count == 0 && method2.ParameterTypeNames.Count == 0) {
paramTypeSimilarity = 1.0;
}
double invokedSimilarity = 0.0;
if (method1.InvokedMethodSignatures.Any() || method2.InvokedMethodSignatures.Any()) {
var intersection = method1.InvokedMethodSignatures.Intersect(method2.InvokedMethodSignatures).Count();
var union = method1.InvokedMethodSignatures.Union(method2.InvokedMethodSignatures).Count();
invokedSimilarity = union > 0 ? (double)intersection / union : 1.0;
} else {
invokedSimilarity = 1.0;
}
double basicBlockSimilarity = 1.0 - CalculateNormalizedDifference(method1.BasicBlockCount, method2.BasicBlockCount, Tuning.Normalization.MaxBasicBlockCount);
double conditionalBranchSimilarity = 1.0 - CalculateNormalizedDifference(method1.ConditionalBranchCount, method2.ConditionalBranchCount, Tuning.Normalization.MaxConditionalBranchCount);
double loopSimilarity = 1.0 - CalculateNormalizedDifference(method1.LoopCount, method2.LoopCount, Tuning.Normalization.MaxLoopCount);
double cyclomaticComplexitySimilarity = 1.0 - CalculateNormalizedDifference(method1.CyclomaticComplexity, method2.CyclomaticComplexity, Tuning.Normalization.MaxCyclomaticComplexity);
double operationCountsSimilarity = CalculateCosineSimilarity(method1.OperationCounts, method2.OperationCounts);
double accessedTypesSimilarity = 0.0;
if (method1.DistinctAccessedMemberTypes.Any() || method2.DistinctAccessedMemberTypes.Any()) {
var intersectionTypes = method1.DistinctAccessedMemberTypes.Intersect(method2.DistinctAccessedMemberTypes).Count();
var unionTypes = method1.DistinctAccessedMemberTypes.Union(method2.DistinctAccessedMemberTypes).Count();
accessedTypesSimilarity = unionTypes > 0 ? (double)intersectionTypes / unionTypes : 1.0;
} else {
accessedTypesSimilarity = 1.0;
}
double totalWeightedScore =
returnTypeSimilarity * Tuning.Weights.ReturnType +
paramCountSimilarity * Tuning.Weights.ParamCount +
paramTypeSimilarity * Tuning.Weights.ParamTypes +
invokedSimilarity * Tuning.Weights.InvokedMethods +
basicBlockSimilarity * Tuning.Weights.BasicBlocks +
conditionalBranchSimilarity * Tuning.Weights.ConditionalBranches +
loopSimilarity * Tuning.Weights.Loops +
cyclomaticComplexitySimilarity * Tuning.Weights.CyclomaticComplexity +
operationCountsSimilarity * Tuning.Weights.OperationCounts +
accessedTypesSimilarity * Tuning.Weights.AccessedMemberTypes;
return totalWeightedScore / Tuning.Weights.TotalWeight;
}
private double CalculateNormalizedDifference(int val1, int val2, int maxValue) {
if (maxValue == 0) return (val1 == val2) ? 0.0 : 1.0;
double diff = Math.Abs(val1 - val2);
return diff / maxValue;
}
private double CalculateCosineSimilarity(Dictionary<string, int> vec1, Dictionary<string, int> vec2) {
if (!vec1.Any() && !vec2.Any()) return 1.0;
if (!vec1.Any() || !vec2.Any()) return 0.0;
var allKeys = vec1.Keys.Union(vec2.Keys).ToList();
double dotProduct = 0.0;
double magnitude1 = 0.0;
double magnitude2 = 0.0;
foreach (var key in allKeys) {
int val1 = vec1.GetValueOrDefault(key, 0);
int val2 = vec2.GetValueOrDefault(key, 0);
dotProduct += val1 * val2;
magnitude1 += val1 * val1;
magnitude2 += val2 * val2;
}
magnitude1 = Math.Sqrt(magnitude1);
magnitude2 = Math.Sqrt(magnitude2);
if (magnitude1 == 0 || magnitude2 == 0) return 0.0;
return dotProduct / (magnitude1 * magnitude2);
}
public async Task<List<ClassSimilarityResult>> FindSimilarClassesAsync(
double similarityThreshold,
CancellationToken cancellationToken) {
ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarClassesAsync));
_logger.LogInformation("Starting class semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism);
var allClassFeatures = new System.Collections.Concurrent.ConcurrentBag<ClassSemanticFeatures>();
var parallelOptions = new ParallelOptions {
MaxDegreeOfParallelism = Tuning.MaxDegreesOfParallelism,
CancellationToken = cancellationToken
};
var projects = _solutionManager.GetProjects().ToList(); // Materialize
await Parallel.ForEachAsync(projects, parallelOptions, async (project, ct) => {
if (ct.IsCancellationRequested) {
_logger.LogInformation("Class semantic similarity analysis cancelled during project iteration for {ProjectName}.", project.Name);
return;
}
_logger.LogDebug("Analyzing project for classes: {ProjectName}", project.Name);
var compilation = await project.GetCompilationAsync(ct);
if (compilation == null) {
_logger.LogWarning("Could not get compilation for project {ProjectName}", project.Name);
return;
}
var documents = project.Documents.ToList(); // Materialize
await Parallel.ForEachAsync(documents, parallelOptions, async (document, docCt) => {
if (docCt.IsCancellationRequested) return;
if (!document.SupportsSyntaxTree || !document.SupportsSemanticModel) return;
_logger.LogTrace("Analyzing document for classes: {DocumentFilePath}", document.FilePath);
var syntaxTree = await document.GetSyntaxTreeAsync(docCt);
var semanticModel = await document.GetSemanticModelAsync(docCt);
if (syntaxTree == null || semanticModel == null) return;
var classDeclarations = syntaxTree.GetRoot(docCt).DescendantNodes()
.OfType<TypeDeclarationSyntax>()
.Where(tds => tds.Kind() == Microsoft.CodeAnalysis.CSharp.SyntaxKind.ClassDeclaration ||
tds.Kind() == Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordDeclaration);
foreach (var classDecl in classDeclarations) {
if (docCt.IsCancellationRequested) break;
var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, docCt) as INamedTypeSymbol;
if (classSymbol == null || classSymbol.IsAbstract || classSymbol.IsStatic) {
continue;
}
try {
var features = await ExtractClassFeaturesAsync(classSymbol, classDecl, document, semanticModel, docCt);
if (features != null) {
allClassFeatures.Add(features);
}
} catch (OperationCanceledException) {
_logger.LogInformation("Feature extraction cancelled for class {ClassName} in {FilePath}", classSymbol?.Name ?? "Unknown", document.FilePath);
} catch (Exception ex) {
_logger.LogWarning(ex, "Failed to extract features for class {ClassName} in {FilePath}", classSymbol?.Name ?? "Unknown", document.FilePath);
}
}
});
});
if (cancellationToken.IsCancellationRequested) {
_logger.LogInformation("Class semantic similarity analysis was cancelled before comparison.");
throw new OperationCanceledException("Class semantic similarity analysis was cancelled.");
}
_logger.LogInformation("Extracted features for {ClassCount} classes. Starting similarity comparison.", allClassFeatures.Count);
return CompareClassFeatures(allClassFeatures.ToList(), similarityThreshold, cancellationToken);
}
private async Task<ClassSemanticFeatures?> ExtractClassFeaturesAsync(
INamedTypeSymbol classSymbol,
TypeDeclarationSyntax classDecl,
Document document,
SemanticModel semanticModel,
CancellationToken cancellationToken) {
var filePath = document.FilePath ?? "unknown";
var startLine = classDecl.GetLocation().GetLineSpan().StartLinePosition.Line;
var endLine = classDecl.GetLocation().GetLineSpan().EndLinePosition.Line;
var totalLinesOfCode = endLine - startLine + 1;
if (totalLinesOfCode < Tuning.ClassLineCountFilter) {
_logger.LogDebug("Class {ClassName} in {FilePath} has {LineCount} lines, less than filter {FilterCount}. Skipping.",
classSymbol.Name, filePath, totalLinesOfCode, Tuning.ClassLineCountFilter);
return null;
}
var className = classSymbol.Name;
var fullyQualifiedClassName = classSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
var distinctReferencedExternalTypeFqns = new HashSet<string>();
var distinctUsedNamespaceFqns = new HashSet<string>();
AddTypeAndNamespaceIfExternal(classSymbol.BaseType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
var baseClassName = classSymbol.BaseType?.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
var implementedInterfaceNames = new List<string>();
foreach (var iface in classSymbol.AllInterfaces) {
AddTypeAndNamespaceIfExternal(iface, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
implementedInterfaceNames.Add(iface.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
}
int publicMethodCount = 0, protectedMethodCount = 0, privateMethodCount = 0, staticMethodCount = 0, abstractMethodCount = 0, virtualMethodCount = 0;
int propertyCount = 0, readOnlyPropertyCount = 0, staticPropertyCount = 0;
int fieldCount = 0, staticFieldCount = 0, readonlyFieldCount = 0, constFieldCount = 0;
int eventCount = 0;
int nestedClassCount = 0, nestedStructCount = 0, nestedEnumCount = 0, nestedInterfaceCount = 0;
double totalMethodComplexity = 0;
int analyzedMethodCount = 0;
var classMethodFeatures = new List<MethodSemanticFeatures>();
// Collect used namespaces from using directives in the current file
if (classDecl.SyntaxTree.GetRoot(cancellationToken) is CompilationUnitSyntax compilationUnit) {
foreach (var usingDirective in compilationUnit.Usings) {
if (usingDirective.Name != null) {
var namespaceSymbol = semanticModel.GetSymbolInfo(usingDirective.Name, cancellationToken).Symbol as INamespaceSymbol;
if (namespaceSymbol != null && !namespaceSymbol.IsGlobalNamespace) {
distinctUsedNamespaceFqns.Add(namespaceSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
}
}
}
}
foreach (var memberSymbol in classSymbol.GetMembers()) {
if (cancellationToken.IsCancellationRequested) return null;
if (memberSymbol is IMethodSymbol methodMember) {
if (ToolHelpers.IsPropertyAccessor(methodMember) || methodMember.IsImplicitlyDeclared) continue;
if (methodMember.DeclaredAccessibility == Accessibility.Public) publicMethodCount++;
else if (methodMember.DeclaredAccessibility == Accessibility.Protected) protectedMethodCount++;
else if (methodMember.DeclaredAccessibility == Accessibility.Private) privateMethodCount++;
if (methodMember.IsStatic) staticMethodCount++;
if (methodMember.IsAbstract) abstractMethodCount++;
if (methodMember.IsVirtual) virtualMethodCount++;
AddTypeAndNamespaceIfExternal(methodMember.ReturnType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
foreach (var param in methodMember.Parameters) {
AddTypeAndNamespaceIfExternal(param.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
}
if (memberSymbol.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(cancellationToken) is MethodDeclarationSyntax methodDeclSyntax) {
var features = await ExtractFeaturesAsync(methodMember, methodDeclSyntax, document, semanticModel, cancellationToken);
if (features != null) {
classMethodFeatures.Add(features);
totalMethodComplexity += features.CyclomaticComplexity;
analyzedMethodCount++;
// Add types and namespaces from method body analysis
foreach (var invokedSig in features.InvokedMethodSignatures) {
// This is tricky as InvokedMethodSignatures are strings. A more robust way would be to get IMethodSymbol during ExtractFeaturesAsync
// For now, we'll skip adding these to avoid parsing strings back to symbols.
}
foreach (var accessedType in features.DistinctAccessedMemberTypes) {
// Similar to above, these are strings.
}
}
}
} else if (memberSymbol is IPropertySymbol propertyMember) {
propertyCount++;
if (propertyMember.IsReadOnly) readOnlyPropertyCount++;
if (propertyMember.IsStatic) staticPropertyCount++;
AddTypeAndNamespaceIfExternal(propertyMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (memberSymbol is IFieldSymbol fieldMember) {
fieldCount++;
if (fieldMember.IsStatic) staticFieldCount++;
if (fieldMember.IsReadOnly) readonlyFieldCount++;
if (fieldMember.IsConst) constFieldCount++;
AddTypeAndNamespaceIfExternal(fieldMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (memberSymbol is IEventSymbol eventMember) {
eventCount++;
AddTypeAndNamespaceIfExternal(eventMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (memberSymbol is INamedTypeSymbol nestedTypeMember) {
if (nestedTypeMember.TypeKind == TypeKind.Class) nestedClassCount++;
else if (nestedTypeMember.TypeKind == TypeKind.Struct) nestedStructCount++;
else if (nestedTypeMember.TypeKind == TypeKind.Enum) nestedEnumCount++;
else if (nestedTypeMember.TypeKind == TypeKind.Interface) nestedInterfaceCount++;
// We don't add nested types to external types/namespaces as they are part of the class itself.
}
}
// Deeper scan for referenced types and namespaces within method bodies and other syntax elements
// This is more robust than just looking at IdentifierNameSyntax.
foreach (var node in classDecl.DescendantNodes(descendIntoChildren: n => n is not TypeDeclarationSyntax || n == classDecl)) { // Avoid descending into nested types again
if (cancellationToken.IsCancellationRequested) return null;
ISymbol? referencedSymbol = null;
if (node is IdentifierNameSyntax identifierName) {
referencedSymbol = semanticModel.GetSymbolInfo(identifierName, cancellationToken).Symbol;
} else if (node is MemberAccessExpressionSyntax memberAccess) {
referencedSymbol = semanticModel.GetSymbolInfo(memberAccess.Name, cancellationToken).Symbol;
} else if (node is ObjectCreationExpressionSyntax objectCreation) {
referencedSymbol = semanticModel.GetSymbolInfo(objectCreation.Type, cancellationToken).Symbol;
} else if (node is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax maes) {
referencedSymbol = semanticModel.GetSymbolInfo(maes.Name, cancellationToken).Symbol;
}
if (referencedSymbol is ITypeSymbol typeSym) {
AddTypeAndNamespaceIfExternal(typeSym, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (referencedSymbol is IMethodSymbol methodSym) {
AddTypeAndNamespaceIfExternal(methodSym.ReturnType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
foreach (var param in methodSym.Parameters) {
AddTypeAndNamespaceIfExternal(param.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
}
} else if (referencedSymbol is IPropertySymbol propSym) {
AddTypeAndNamespaceIfExternal(propSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (referencedSymbol is IFieldSymbol fieldSym) {
AddTypeAndNamespaceIfExternal(fieldSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (referencedSymbol is IEventSymbol eventSym) {
AddTypeAndNamespaceIfExternal(eventSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
} else if (referencedSymbol is INamespaceSymbol nsSym && !nsSym.IsGlobalNamespace) {
// Check if the namespace itself is from an external assembly (less common for direct usage like this, but possible)
if (nsSym.ContainingAssembly != null && classSymbol.ContainingAssembly != null &&
!SymbolEqualityComparer.Default.Equals(nsSym.ContainingAssembly, classSymbol.ContainingAssembly)) {
distinctUsedNamespaceFqns.Add(nsSym.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
} else if (nsSym.ContainingAssembly == null && classSymbol.ContainingAssembly != null) {
// Namespace is likely global or part of the current compilation but not tied to a specific assembly in the same way types are.
// We primarily add namespaces based on types they contain.
}
}
}
double averageMethodComplexity = analyzedMethodCount > 0 ? totalMethodComplexity / analyzedMethodCount : 0;
return new ClassSemanticFeatures(
fullyQualifiedClassName,
filePath,
startLine,
className,
baseClassName,
implementedInterfaceNames.Distinct().ToList(), // Ensure distinct
publicMethodCount,
protectedMethodCount,
privateMethodCount,
staticMethodCount,
abstractMethodCount,
virtualMethodCount,
propertyCount,
readOnlyPropertyCount,
staticPropertyCount,
fieldCount,
staticFieldCount,
readonlyFieldCount,
constFieldCount,
eventCount,
nestedClassCount,
nestedStructCount,
nestedEnumCount,
nestedInterfaceCount,
averageMethodComplexity,
distinctReferencedExternalTypeFqns, // Already a HashSet
distinctUsedNamespaceFqns, // Already a HashSet
totalLinesOfCode,
classMethodFeatures
);
}
private List<ClassSimilarityResult> CompareClassFeatures(
List<ClassSemanticFeatures> allClassFeatures,
double similarityThreshold,
CancellationToken cancellationToken) {
var results = new List<ClassSimilarityResult>();
var processedIndices = new HashSet<int>();
_logger.LogInformation("Starting class similarity comparison for {ClassCount} classes.", allClassFeatures.Count);
for (int i = 0; i < allClassFeatures.Count; i++) {
if (cancellationToken.IsCancellationRequested) {
throw new OperationCanceledException("Class similarity analysis was cancelled.");
}
if (processedIndices.Contains(i)) continue;
var currentClass = allClassFeatures[i];
var similarGroup = new List<ClassSemanticFeatures> { currentClass };
processedIndices.Add(i);
double groupTotalScore = 0;
int comparisonsMade = 0;
for (int j = i + 1; j < allClassFeatures.Count; j++) {
if (cancellationToken.IsCancellationRequested) {
throw new OperationCanceledException("Class similarity analysis was cancelled.");
}
if (processedIndices.Contains(j)) continue;
var otherClass = allClassFeatures[j];
double similarity = CalculateClassSimilarity(currentClass, otherClass);
if (similarity >= similarityThreshold) {
similarGroup.Add(otherClass);
processedIndices.Add(j);
groupTotalScore += similarity;
comparisonsMade++;
}
}
if (similarGroup.Count > 1) {
double averageScore = comparisonsMade > 0 ? groupTotalScore / comparisonsMade : 1.0;
results.Add(new ClassSimilarityResult(similarGroup, averageScore));
_logger.LogInformation("Found class similarity group of {GroupSize}, starting with {ClassName}, Avg Score: {Score:F2}",
similarGroup.Count, currentClass.ClassName, averageScore);
}
}
_logger.LogInformation("Class semantic similarity analysis complete. Found {GroupCount} groups.", results.Count);
return results.OrderByDescending(r => r.AverageSimilarityScore).ToList();
}
private double CalculateClassSimilarity(ClassSemanticFeatures class1, ClassSemanticFeatures class2) {
double baseClassSimilarity = (class1.BaseClassName == class2.BaseClassName) ? 1.0 :
(string.IsNullOrEmpty(class1.BaseClassName) && string.IsNullOrEmpty(class2.BaseClassName) ? 1.0 : 0.0);
double interfaceSimilarity = CalculateJaccardSimilarity(class1.ImplementedInterfaceNames, class2.ImplementedInterfaceNames);
double referencedTypesSimilarity = CalculateJaccardSimilarity(class1.DistinctReferencedExternalTypeFqns, class2.DistinctReferencedExternalTypeFqns);
double usedNamespacesSimilarity = CalculateJaccardSimilarity(class1.DistinctUsedNamespaceFqns, class2.DistinctUsedNamespaceFqns);
double methodMatchingSimilarity = 0.0;
if (class1.MethodFeatures.Any() && class2.MethodFeatures.Any()) {
var smallerList = class1.MethodFeatures.Count < class2.MethodFeatures.Count ? class1.MethodFeatures : class2.MethodFeatures;
var largerList = class1.MethodFeatures.Count < class2.MethodFeatures.Count ? class2.MethodFeatures : class1.MethodFeatures;
double totalMaxSimilarity = 0.0;
HashSet<int> usedLargerListIndices = new HashSet<int>();
foreach (var method1Feat in smallerList) {
double maxSimForMethod1 = 0.0;
int bestMatchIndex = -1;
for (int k = 0; k < largerList.Count; k++) {
if (usedLargerListIndices.Contains(k)) {
continue;
}
double sim = CalculateSimilarity(method1Feat, largerList[k]); // Uses existing method similarity
if (sim > maxSimForMethod1) {
maxSimForMethod1 = sim;
bestMatchIndex = k;
}
}
if (bestMatchIndex != -1) {
totalMaxSimilarity += maxSimForMethod1;
usedLargerListIndices.Add(bestMatchIndex);
}
}
methodMatchingSimilarity = smallerList.Any() ? totalMaxSimilarity / smallerList.Count : 1.0;
} else if (!class1.MethodFeatures.Any() && !class2.MethodFeatures.Any()) {
methodMatchingSimilarity = 1.0; // Both have no methods, considered perfectly similar in this aspect
}
double totalWeightedScore =
baseClassSimilarity * Tuning.ClassWeights.BaseClassName +
interfaceSimilarity * Tuning.ClassWeights.ImplementedInterfaceNames +
referencedTypesSimilarity * Tuning.ClassWeights.DistinctReferencedExternalTypeFqns +
usedNamespacesSimilarity * Tuning.ClassWeights.DistinctUsedNamespaceFqns +
methodMatchingSimilarity * Tuning.ClassWeights.MethodMatchingSimilarity + // Added
(1.0 - CalculateNormalizedDifference(class1.PublicMethodCount, class2.PublicMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.PublicMethodCount +
(1.0 - CalculateNormalizedDifference(class1.ProtectedMethodCount, class2.ProtectedMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.ProtectedMethodCount +
(1.0 - CalculateNormalizedDifference(class1.PrivateMethodCount, class2.PrivateMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.PrivateMethodCount +
(1.0 - CalculateNormalizedDifference(class1.StaticMethodCount, class2.StaticMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.StaticMethodCount +
(1.0 - CalculateNormalizedDifference(class1.AbstractMethodCount, class2.AbstractMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.AbstractMethodCount +
(1.0 - CalculateNormalizedDifference(class1.VirtualMethodCount, class2.VirtualMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.VirtualMethodCount +
(1.0 - CalculateNormalizedDifference(class1.PropertyCount, class2.PropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.PropertyCount +
(1.0 - CalculateNormalizedDifference(class1.ReadOnlyPropertyCount, class2.ReadOnlyPropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.ReadOnlyPropertyCount +
(1.0 - CalculateNormalizedDifference(class1.StaticPropertyCount, class2.StaticPropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.StaticPropertyCount +
(1.0 - CalculateNormalizedDifference(class1.FieldCount, class2.FieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.FieldCount +
(1.0 - CalculateNormalizedDifference(class1.StaticFieldCount, class2.StaticFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.StaticFieldCount +
(1.0 - CalculateNormalizedDifference(class1.ReadonlyFieldCount, class2.ReadonlyFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.ReadonlyFieldCount +
(1.0 - CalculateNormalizedDifference(class1.ConstFieldCount, class2.ConstFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.ConstFieldCount +
(1.0 - CalculateNormalizedDifference(class1.EventCount, class2.EventCount, 20)) * Tuning.ClassWeights.EventCount + // Max 20 events (consider making this a ClassNormalization constant)
(1.0 - CalculateNormalizedDifference(class1.NestedClassCount, class2.NestedClassCount, 10)) * Tuning.ClassWeights.NestedClassCount + // Max 10 (consider ClassNormalization)
(1.0 - CalculateNormalizedDifference(class1.NestedStructCount, class2.NestedStructCount, 10)) * Tuning.ClassWeights.NestedStructCount + // Max 10 (consider ClassNormalization)
(1.0 - CalculateNormalizedDifference(class1.NestedEnumCount, class2.NestedEnumCount, 10)) * Tuning.ClassWeights.NestedEnumCount + // Max 10 (consider ClassNormalization)
(1.0 - CalculateNormalizedDifference(class1.NestedInterfaceCount, class2.NestedInterfaceCount, 10)) * Tuning.ClassWeights.NestedInterfaceCount + // Max 10 (consider ClassNormalization)
(1.0 - CalculateNormalizedDifference(class1.AverageMethodComplexity, class2.AverageMethodComplexity, Tuning.ClassNormalization.MaxAverageMethodComplexity)) * Tuning.ClassWeights.AverageMethodComplexity +
(1.0 - CalculateNormalizedDifference(class1.TotalLinesOfCode, class2.TotalLinesOfCode, 2000)) * Tuning.ClassWeights.TotalLinesOfCode; // Assuming max 2000 LOC (consider ClassNormalization)
// Ensure total weight is not zero to prevent division by zero if all weights are somehow zero.
double totalWeight = Tuning.ClassWeights.TotalWeight;
return totalWeight > 0 ? totalWeightedScore / totalWeight : 0.0;
}
private double CalculateJaccardSimilarity<T>(ICollection<T> set1, ICollection<T> set2) {
if (!set1.Any() && !set2.Any()) return 1.0;
if (!set1.Any() || !set2.Any()) return 0.0;
var intersection = set1.Intersect(set2).Count();
var union = set1.Union(set2).Count();
return union > 0 ? (double)intersection / union : 0.0;
}
private double CalculateNormalizedDifference(double val1, double val2, double maxValue) {
if (maxValue == 0.0) {
return (val1 == val2) ? 0.0 : 1.0;
}
double diff = Math.Abs(val1 - val2);
return diff / maxValue;
}
private void AddTypeAndNamespaceIfExternal(
ITypeSymbol? typeSymbol,
INamedTypeSymbol containingClassSymbol,
HashSet<string> externalTypeFqns,
HashSet<string> usedNamespaceFqns) {
if (typeSymbol == null || typeSymbol.TypeKind == TypeKind.Error || typeSymbol.SpecialType == SpecialType.System_Void) {
return;
}
// Add namespace
if (typeSymbol.ContainingNamespace != null && !typeSymbol.ContainingNamespace.IsGlobalNamespace) {
usedNamespaceFqns.Add(typeSymbol.ContainingNamespace.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
}
// Add type if external
if (typeSymbol.ContainingAssembly != null && containingClassSymbol.ContainingAssembly != null &&
!SymbolEqualityComparer.Default.Equals(typeSymbol.ContainingAssembly, containingClassSymbol.ContainingAssembly)) {
externalTypeFqns.Add(typeSymbol.OriginalDefinition.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
}
// Handle generic type arguments
if (typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.IsGenericType) {
foreach (var typeArg in namedTypeSymbol.TypeArguments) {
AddTypeAndNamespaceIfExternal(typeArg, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
}
}
// Handle array element type
if (typeSymbol is IArrayTypeSymbol arrayTypeSymbol) {
AddTypeAndNamespaceIfExternal(arrayTypeSymbol.ElementType, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
}
// Handle pointer element type
if (typeSymbol is IPointerTypeSymbol pointerTypeSymbol) {
AddTypeAndNamespaceIfExternal(pointerTypeSymbol.PointedAtType, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
}
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/SolutionTools.cs:
--------------------------------------------------------------------------------
```csharp
using ModelContextProtocol;
using SharpTools.Tools.Services;
namespace SharpTools.Tools.Mcp.Tools;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis.CSharp.Syntax;
// Marker class for ILogger<T> category specific to SolutionTools
public class SolutionToolsLogCategory { }
[McpServerToolType]
public static class SolutionTools {
private const int MaxOutputLength = 50000;
private enum DetailLevel {
Full,
NoConstantFieldNames,
NoCommonDerivedOrImplementedClasses,
NoEventEnumNames,
NoMethodParamTypes,
NoPropertyTypes,
NoMethodParamNames,
FiftyPercentPropertyNames,
NoPropertyNames,
FiftyPercentMethodNames,
NoMethodNames,
NamespacesAndTypesOnly
}
private const string LoadSolutionDescriptionText =
"The the `SharpTool` suite provides you with focused, high quality, and high information density dotnet analysis and editing tools. " +
"When using `SharpTool`s, you focus on individual components, and navigate with type hierarchies and call graphs instead of raw code. " +
"Because of this, you create more modular, coherent, composable, type-safe, and thus inherently correct code. " +
$"`{ToolHelpers.SharpToolPrefix}{nameof(LoadSolution)}` is the entry point for the suite, and should be called once at the beginning of your session to initialize the other tools with data from the solution.";
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(LoadSolution), Idempotent = true, Destructive = false, OpenWorld = false, ReadOnly = true)]
[Description(LoadSolutionDescriptionText)]
public static async Task<object> LoadSolution(
ISolutionManager solutionManager,
IEditorConfigProvider editorConfigProvider,
ILogger<SolutionToolsLogCategory> logger,
[Description("The absolute file path to the .sln or .slnx solution file.")] string solutionPath,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(solutionPath, "solutionPath", logger);
logger.LogInformation("Executing '{LoadSolution}' tool for path: {SolutionPath}", nameof(LoadSolution), solutionPath);
// Validate solution file exists and has correct extension
if (!File.Exists(solutionPath)) {
logger.LogError("Solution file not found at path: {SolutionPath}", solutionPath);
throw new McpException($"Solution file does not exist at path: {solutionPath}");
}
var ext = Path.GetExtension(solutionPath);
if (!ext.Equals(".sln", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".slnx", StringComparison.OrdinalIgnoreCase)) {
logger.LogError("File is not a valid solution file: {SolutionPath}", solutionPath);
throw new McpException($"File at path '{solutionPath}' is not a .sln or .slnx file.");
}
try {
await solutionManager.LoadSolutionAsync(solutionPath, cancellationToken);
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to load solution at {SolutionPath}", solutionPath);
throw new McpException($"Failed to load solution: {ex.Message}");
}
// Get solution directory and initialize editor config
var solutionDir = Path.GetDirectoryName(solutionPath);
if (string.IsNullOrEmpty(solutionDir)) {
logger.LogWarning(".editorconfig provider could not determine solution directory from path: {SolutionPath}", solutionPath);
throw new McpException($"Could not determine directory for solution path: {solutionPath}");
}
try {
await editorConfigProvider.InitializeAsync(solutionDir, cancellationToken);
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
// Log but don't fail - editor config is helpful but not critical
logger.LogWarning(ex, "Failed to initialize .editorconfig from {SolutionDir}", solutionDir);
// Continue execution, don't throw
}
var projectCount = solutionManager.GetProjects().Count();
var successMessage = $"Solution '{Path.GetFileName(solutionPath)}' loaded successfully with {projectCount} project(s). Caches and .editorconfig initialized.";
logger.LogInformation(successMessage);
try {
return await GetProjectStructure(solutionManager, logger, cancellationToken);
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogWarning(ex, "Successfully loaded solution but failed to retrieve project structure");
// Return basic info instead of detailed structure
return ToolHelpers.ToJson(new {
solutionName = Path.GetFileName(solutionPath),
projectCount,
status = "Solution loaded successfully, but project structure retrieval failed."
});
}
}, logger, nameof(LoadSolution), cancellationToken);
}
private static async Task<object> GetProjectStructure(
ISolutionManager solutionManager,
ILogger<SolutionToolsLogCategory> logger,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ToolHelpers.EnsureSolutionLoaded(solutionManager);
var projectsData = new List<object>();
try {
foreach (var project in solutionManager.GetProjects()) {
cancellationToken.ThrowIfCancellationRequested();
try {
var compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
var targetFramework = "Unknown";
// Get the actual target framework from the project file
if (!string.IsNullOrEmpty(project.FilePath) && File.Exists(project.FilePath)) {
targetFramework = ExtractTargetFrameworkFromProjectFile(project.FilePath);
}
// Get top level namespaces
var topLevelNamespaces = new HashSet<string>();
try {
foreach (var document in project.Documents) {
if (document.SourceCodeKind != SourceCodeKind.Regular || !document.SupportsSyntaxTree) {
continue;
}
try {
var syntaxRoot = await document.GetSyntaxRootAsync(cancellationToken);
if (syntaxRoot == null) {
continue;
}
foreach (var nsNode in syntaxRoot.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>()) {
topLevelNamespaces.Add(nsNode.Name.ToString());
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error getting namespaces from document {DocumentPath}", document.FilePath);
// Continue with other documents
}
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error getting namespaces for project {ProjectName}", project.Name);
// Continue with basic project info
}
// Get project references safely
var projectRefs = new List<string>();
try {
if (solutionManager.CurrentSolution != null) {
projectRefs = project.ProjectReferences
.Select(pr => solutionManager.CurrentSolution.GetProject(pr.ProjectId)?.Name)
.Where(name => name != null)
.OrderBy(name => name)
.ToList()!;
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error getting project references for {ProjectName}", project.Name);
// Continue with empty project references
}
// Get NuGet package references from project file (with enhanced format detection)
var packageRefs = new List<string>();
try {
if (!string.IsNullOrEmpty(project.FilePath) && File.Exists(project.FilePath)) {
// Get all packages
var packages = Services.LegacyNuGetPackageReader.GetAllPackages(project.FilePath);
foreach (var package in packages) {
packageRefs.Add($"{package.PackageId} ({package.Version})");
}
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error getting NuGet package references for {ProjectName}", project.Name);
// Continue with empty package references
}
// Build namespace hierarchy as a nested tree representation
var namespaceTree = new Dictionary<string, HashSet<string>>();
foreach (var ns in topLevelNamespaces) {
var parts = ns.Split('.');
var current = "";
for (int i = 0; i < parts.Length; i++) {
var part = parts[i];
var nextNamespace = string.IsNullOrEmpty(current) ? part : $"{current}.{part}";
if (!namespaceTree.TryGetValue(current, out var children)) {
children = new HashSet<string>();
namespaceTree[current] = children;
}
children.Add(part);
current = nextNamespace;
}
}
// Format the namespace tree as a string representation
var namespaceTreeBuilder = new StringBuilder();
BuildNamespaceTreeString("", namespaceTree, namespaceTreeBuilder);
var namespaceStructure = namespaceTreeBuilder.ToString();
// Local function to recursively build the tree string
void BuildNamespaceTreeString(string current, Dictionary<string, HashSet<string>> tree, StringBuilder builder) {
if (!tree.TryGetValue(current, out var children) || children.Count == 0) {
return;
}
bool first = true;
foreach (var child in children.OrderBy(c => c)) {
if (!first) {
builder.Append(',');
}
first = false;
builder.Append(child);
string nextNamespace = string.IsNullOrEmpty(current) ? child : $"{current}.{child}";
if (tree.ContainsKey(nextNamespace)) {
builder.Append('{');
BuildNamespaceTreeString(nextNamespace, tree, builder);
builder.Append('}');
}
}
}
// Build the project data
projectsData.Add(new {
name = project.Name + (project.AssemblyName.Equals(project.Name, StringComparison.OrdinalIgnoreCase) ? "" : $" ({project.AssemblyName})"),
version = project.Version.ToString(),
targetFramework,
namespaces = namespaceStructure,
documentCount = project.DocumentIds.Count,
projectReferences = projectRefs,
packageReferences = packageRefs
});
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing project {ProjectName}, adding basic info only", project.Name);
// Add minimal project info when there's an error
projectsData.Add(new {
name = project.Name,
//filePath = project.FilePath,
language = project.Language,
error = $"Error processing project: {ex.Message}",
documentCount = project.DocumentIds.Count
});
}
}
// Create the result safely
string? solutionName = null;
try {
solutionName = Path.GetFileName(solutionManager.CurrentSolution?.FilePath ?? "unknown");
} catch {
solutionName = "unknown";
}
var result = new {
solutionName,
projects = projectsData.OrderBy(p => ((dynamic)p).name).ToList(),
nextStep = $"Use `{ToolHelpers.SharpToolPrefix}{nameof(LoadProject)}` to get a detailed view of a specific project's structure."
};
logger.LogInformation("Project structure retrieved successfully for {ProjectCount} projects.", projectsData.Count);
return ToolHelpers.ToJson(result);
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error retrieving project structure");
throw new McpException($"Failed to retrieve project structure: {ex.Message}");
}
}, logger, nameof(GetProjectStructure), cancellationToken);
}
public static string ExtractTargetFrameworkFromProjectFile(string projectFilePath) {
try {
if (string.IsNullOrEmpty(projectFilePath)) {
return "Unknown";
}
if (!File.Exists(projectFilePath)) {
return "Unknown";
}
var xDoc = XDocument.Load(projectFilePath);
// New-style .csproj (SDK-style)
var propertyGroupElements = xDoc.Descendants("PropertyGroup");
foreach (var propertyGroup in propertyGroupElements) {
var targetFrameworkElement = propertyGroup.Element("TargetFramework");
if (targetFrameworkElement != null) {
var value = targetFrameworkElement.Value.Trim();
return !string.IsNullOrEmpty(value) ? value : "Unknown";
}
var targetFrameworksElement = propertyGroup.Element("TargetFrameworks");
if (targetFrameworksElement != null) {
var value = targetFrameworksElement.Value.Trim();
return !string.IsNullOrEmpty(value) ? value : "Unknown";
}
}
// Old-style .csproj format
var targetFrameworkVersionElement = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault();
if (targetFrameworkVersionElement != null) {
var version = targetFrameworkVersionElement.Value.Trim();
// Map from old-style version format (v4.x) to new-style (.NETFramework,Version=v4.x)
if (!string.IsNullOrEmpty(version)) {
if (version.StartsWith("v")) {
return $"net{version.Substring(1).Replace(".", "")}";
}
return version;
}
}
// Additional old-style property check
var targetFrameworkProfile = xDoc.Descendants("TargetFrameworkProfile").FirstOrDefault()?.Value?.Trim();
var targetFrameworkIdentifier = xDoc.Descendants("TargetFrameworkIdentifier").FirstOrDefault()?.Value?.Trim();
if (!string.IsNullOrEmpty(targetFrameworkIdentifier)) {
// Parse the old-style framework identifier
if (targetFrameworkIdentifier.Contains(".NETFramework")) {
var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
return $"net{version.Substring(1).Replace(".", "")}";
}
} else if (targetFrameworkIdentifier.Contains(".NETCore")) {
var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
return $"netcoreapp{version.Substring(1).Replace(".", "")}";
}
} else if (targetFrameworkIdentifier.Contains(".NETStandard")) {
var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
return $"netstandard{version.Substring(1).Replace(".", "")}";
}
}
// Add profile if present
if (!string.IsNullOrEmpty(targetFrameworkProfile)) {
return $"{targetFrameworkIdentifier},{targetFrameworkProfile}";
}
return targetFrameworkIdentifier;
}
return "Unknown";
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) {
// File access issues
return "Unknown (Access Error)";
} catch (Exception ex) when (ex is XmlException) {
// XML parsing issues
return "Unknown (XML Error)";
} catch (Exception) {
// Any other exceptions
return "Unknown";
}
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(LoadProject), ReadOnly = true, OpenWorld = false, Destructive = false, Idempotent = false)]
[Description($"Use this immediately after {nameof(LoadSolution)}. This injects a comprehensive understanding of the project structure into your context.")]
public static async Task<object> LoadProject(
ISolutionManager solutionManager,
ILogger<SolutionToolsLogCategory> logger,
ICodeAnalysisService codeAnalysisService,
string projectName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(projectName, "projectName", logger);
logger.LogInformation("Executing '{LoadProjectToolName}' tool for project: {ProjectName}", nameof(LoadProject), projectName);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(LoadProject));
int indexOfParen = projectName.IndexOf('(');
string projectNameNormalized = indexOfParen == -1
? projectName.Trim()
: projectName[..indexOfParen].Trim();
var project = solutionManager.GetProjects().FirstOrDefault(
p => p.Name == projectName
|| p.AssemblyName == projectName
|| p.Name == projectNameNormalized);
if (project == null) {
logger.LogError("Project '{ProjectName}' not found in the loaded solution", projectName);
throw new McpException($"Project '{projectName}' not found in the solution.");
}
cancellationToken.ThrowIfCancellationRequested();
logger.LogDebug("Processing project: {ProjectName}", project.Name);
// Get the compilation for the project
Compilation? compilation;
try {
compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
if (compilation == null) {
logger.LogError("Failed to get compilation for project: {ProjectName}", project.Name);
throw new McpException($"Failed to get compilation for project: {project.Name}");
}
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error getting compilation for project {ProjectName}", project.Name);
throw new McpException($"Error getting compilation for project '{project.Name}': {ex.Message}");
}
// For display (excludes nested types)
var namespaceContents = new Dictionary<string, List<INamedTypeSymbol>>();
var typesByNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
// For analysis (includes all types including nested)
var allNamespaceContents = new Dictionary<string, List<INamedTypeSymbol>>();
var allTypesByNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
try {
// First pass: Collect all source symbols and organize types by namespace
foreach (var document in project.Documents) {
cancellationToken.ThrowIfCancellationRequested();
if (document.SourceCodeKind != SourceCodeKind.Regular || !document.SupportsSyntaxTree) {
continue;
}
try {
var syntaxTree = await document.GetSyntaxTreeAsync(cancellationToken);
if (syntaxTree == null) continue;
var semanticModel = compilation.GetSemanticModel(syntaxTree);
if (semanticModel == null) continue;
var root = await syntaxTree.GetRootAsync(cancellationToken);
// Get all type declarations
foreach (var typeDecl in root.DescendantNodes().OfType<BaseTypeDeclarationSyntax>()) {
cancellationToken.ThrowIfCancellationRequested();
try {
if (semanticModel.GetDeclaredSymbol(typeDecl, cancellationToken) is INamedTypeSymbol symbol) {
var nsName = symbol.ContainingNamespace?.ToDisplayString() ?? "global";
// Always add to the "all types" collection for analysis
if (!allTypesByNamespace.TryGetValue(nsName, out var allTypeList)) {
allTypeList = new List<INamedTypeSymbol>();
allTypesByNamespace[nsName] = allTypeList;
}
allTypeList.Add(symbol);
// Only add non-nested types to the display collection
if (symbol.ContainingType == null) {
if (!typesByNamespace.TryGetValue(nsName, out var typeList)) {
typeList = new List<INamedTypeSymbol>();
typesByNamespace[nsName] = typeList;
}
typeList.Add(symbol);
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing type declaration in document {DocumentPath}", document.FilePath);
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing document {DocumentPath}", document.FilePath);
}
}
// Populate the display namespace contents
foreach (var nsEntry in typesByNamespace) {
if (!namespaceContents.TryGetValue(nsEntry.Key, out var globalTypeList)) {
globalTypeList = new List<INamedTypeSymbol>();
namespaceContents[nsEntry.Key] = globalTypeList;
}
globalTypeList.AddRange(nsEntry.Value);
}
// Populate the analysis namespace contents
foreach (var nsEntry in allTypesByNamespace) {
if (!allNamespaceContents.TryGetValue(nsEntry.Key, out var globalTypeList)) {
globalTypeList = new List<INamedTypeSymbol>();
allNamespaceContents[nsEntry.Key] = globalTypeList;
}
globalTypeList.AddRange(nsEntry.Value);
}
// Collect derived/implemented type information for the NoCommonDerivedOrImplementedClasses detail level
// Use the all types collection for analysis to include nested types
var derivedTypesByNamespace = await CollectDerivedAndImplementedCounts(allNamespaceContents, codeAnalysisService, logger, cancellationToken);
var commonImplementationInfo = new CommonImplementationInfo(derivedTypesByNamespace);
logger.LogInformation("Found {ImplementationCount} types with derived classes or implementations. Mean count: {MeanCount:F2}, Common base types: {CommonBaseCount}",
commonImplementationInfo.TotalImplementationCounts.Count,
commonImplementationInfo.MedianImplementationCount,
commonImplementationInfo.CommonBaseTypes.Count);
var structureBuilder = new StringBuilder();
DetailLevel currentDetailLevel = DetailLevel.Full;
string output = "";
bool lengthAcceptable = false;
Random random = new Random();
while (!lengthAcceptable && currentDetailLevel <= DetailLevel.NamespacesAndTypesOnly) {
structureBuilder.Clear();
var sortedNamespaces = namespaceContents.Keys.OrderBy(ns => ns).ToList();
var namespaceParts = BuildNamespaceHierarchy(sortedNamespaces, namespaceContents, logger);
var rootNamespaces = namespaceParts.Keys.Where(ns => ns.IndexOf('.') == -1).OrderBy(n => n).ToList();
foreach (var rootNs in rootNamespaces) {
structureBuilder.Append(BuildNamespaceStructureText(rootNs, namespaceParts, namespaceContents, logger, currentDetailLevel, random, commonImplementationInfo));
}
output = structureBuilder.ToString();
if (output.Length <= MaxOutputLength) {
lengthAcceptable = true;
} else {
logger.LogInformation("Output string length ({Length}) exceeds limit ({Limit}). Reducing detail from {OldLevel} to {NewLevel}.", output.Length, MaxOutputLength, currentDetailLevel, currentDetailLevel + 1);
currentDetailLevel++;
if (currentDetailLevel > DetailLevel.NamespacesAndTypesOnly) {
logger.LogWarning("Even at the most compressed level, output length ({Length}) exceeds limit ({Limit}). Returning compressed output.", output.Length, MaxOutputLength);
}
}
}
return $"<typeTree note=\"Use {ToolHelpers.SharpToolPrefix}{nameof(AnalysisTools.GetMembers)} for more detailed information about specific types.\">" +
output +
"\n</typeTree>";
} catch (OperationCanceledException) {
logger.LogInformation("Operation was cancelled while analyzing project {ProjectName}", project.Name);
throw;
} catch (Exception ex) when (!(ex is McpException)) {
logger.LogError(ex, "Error analyzing project structure for {ProjectName}", project.Name);
throw new McpException($"Error analyzing project structure: {ex.Message}");
}
}, logger, nameof(LoadProject), cancellationToken);
}
private static Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>> BuildNamespaceHierarchy(
List<string> sortedNamespaces,
Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
ILogger<SolutionToolsLogCategory> logger) {
// Process namespaces to build the hierarchy
var namespaceParts = new Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>>();
foreach (var fullNamespace in sortedNamespaces) {
try {
// Skip empty global namespace
if (string.IsNullOrEmpty(fullNamespace) || fullNamespace == "global") {
continue;
}
// Split namespace into parts
var parts = fullNamespace.Split('.');
// Create entries for each namespace part
var currentNs = "";
for (int i = 0; i < parts.Length; i++) {
var part = parts[i];
if (!string.IsNullOrEmpty(currentNs)) {
currentNs += ".";
}
currentNs += part;
if (!namespaceParts.TryGetValue(currentNs, out var children)) {
children = new Dictionary<string, List<INamedTypeSymbol>>();
namespaceParts[currentNs] = children;
}
// If not the last part, add the next part as child namespace
if (i < parts.Length - 1) {
var nextPart = parts[i + 1];
if (!children.ContainsKey(nextPart)) {
children[nextPart] = new List<INamedTypeSymbol>();
}
}
}
// Add types to the leaf namespace
if (namespaceContents.TryGetValue(fullNamespace, out var types) && types.Any()) {
var leafNsParts = namespaceParts[fullNamespace];
foreach (var type in types) {
var typeName = type.Name;
if (!leafNsParts.TryGetValue(typeName, out var typeList)) {
typeList = new List<INamedTypeSymbol>();
leafNsParts[typeName] = typeList;
}
typeList.Add(type);
}
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error processing namespace {Namespace} in hierarchy", fullNamespace);
}
}
return namespaceParts;
}
private static string BuildNamespaceStructureText(
string namespaceName,
Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>> namespaceParts,
Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
ILogger<SolutionToolsLogCategory> logger,
DetailLevel detailLevel,
Random random,
CommonImplementationInfo? commonImplementationInfo = null) {
var sb = new StringBuilder();
try {
var simpleName = namespaceName.Contains('.')
? namespaceName.Substring(namespaceName.LastIndexOf('.') + 1)
: namespaceName;
sb.Append('\n').Append(simpleName).Append('{');
// If we're at NoCommonDerivedOrImplementedClasses level or above
// show derived class counts for common base types in this namespace
if (commonImplementationInfo != null &&
detailLevel >= DetailLevel.NoCommonDerivedOrImplementedClasses) {
// Build a dictionary of base types to their derived classes in this namespace
var derivedCountsInNamespace = new Dictionary<INamedTypeSymbol, int>(SymbolEqualityComparer.Default);
foreach (var baseType in commonImplementationInfo.CommonBaseTypes) {
if (commonImplementationInfo.DerivedTypesByNamespace.TryGetValue(baseType, out var derivedByNs) &&
derivedByNs.TryGetValue(namespaceName, out var derivedTypes) &&
derivedTypes.Count > 0) {
derivedCountsInNamespace[baseType] = derivedTypes.Count;
}
}
// If there are any derived classes from common base types in this namespace, show their counts
if (derivedCountsInNamespace.Count > 0) {
foreach (var entry in derivedCountsInNamespace) {
var baseType = entry.Key;
var count = entry.Value;
string typeKindStr = baseType.TypeKind == TypeKind.Interface ? "implementation" : "derived class";
string baseTypeName = CommonImplementationInfo.GetTypeDisplayName(baseType);
sb.Append($"\n {count} {typeKindStr}{(count == 1 ? "" : "es")} of {baseTypeName};");
}
}
}
var typesInNamespace = namespaceContents.GetValueOrDefault(namespaceName);
var typeContent = new StringBuilder();
if (typesInNamespace != null) {
foreach (var type in typesInNamespace.OrderBy(t => t.Name)) {
try {
var typeStructure = BuildTypeStructure(type, logger, detailLevel, random, 1, commonImplementationInfo);
if (!string.IsNullOrEmpty(typeStructure)) { // Skip empty results (filtered derived types)
typeContent.Append(typeStructure);
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error building structure for type {TypeName} in namespace {Namespace}", type.Name, namespaceName);
typeContent.Append($"\n{new string(' ', 2 * 1)}{type.Name}{{/* Error: {ex.Message} */}}");
}
}
}
var childNamespaceContent = new StringBuilder();
if (namespaceParts.TryGetValue(namespaceName, out var children)) {
foreach (var child in children.OrderBy(c => c.Key)) {
if (child.Value?.Count == 0) { // This indicates a child namespace rather than a type within the current namespace
var childNamespace = namespaceName + "." + child.Key;
try {
childNamespaceContent.Append(BuildNamespaceStructureText(childNamespace, namespaceParts, namespaceContents, logger, detailLevel, random, commonImplementationInfo));
} catch (Exception ex) {
logger.LogWarning(ex, "Error building structure for child namespace {Namespace}", childNamespace);
childNamespaceContent.Append($"\n{child.Key}{{/* Error: {ex.Message} */}}");
}
}
}
}
sb.Append(typeContent);
sb.Append(childNamespaceContent);
sb.Append("\n}");
} catch (Exception ex) {
logger.LogError(ex, "Error building namespace structure text for {Namespace}", namespaceName);
return $"\n{namespaceName}{{/* Error: {ex.Message} */}}";
}
return sb.ToString();
}
private static string BuildTypeStructure(
INamedTypeSymbol type,
ILogger<SolutionToolsLogCategory> logger,
DetailLevel detailLevel,
Random random,
int indentLevel,
CommonImplementationInfo? commonImplementationInfo = null) {
var sb = new StringBuilder();
var indent = string.Empty; // new string(' ', 2 * indentLevel);
try {
// Skip derived classes that are part of a common base type at NoCommonDerivedOrImplementedClasses level or above
if (commonImplementationInfo != null &&
detailLevel >= DetailLevel.NoCommonDerivedOrImplementedClasses) {
// Check if this type inherits from or implements a common base type
bool shouldSkip = false;
foreach (var commonBaseType in commonImplementationInfo.CommonBaseTypes) {
// Check if this type directly inherits from a common base type
if (SymbolEqualityComparer.Default.Equals(type.BaseType, commonBaseType)) {
shouldSkip = true;
break;
}
// Check if this type implements a common interface
foreach (var iface in type.AllInterfaces) {
if (SymbolEqualityComparer.Default.Equals(iface, commonBaseType)) {
shouldSkip = true;
break;
}
}
if (shouldSkip) {
break;
}
}
if (shouldSkip) {
return string.Empty; // Skip this type
}
}
sb.Append('\n').Append(indent).Append(type.Name);
if (type.TypeParameters.Length > 0 && detailLevel < DetailLevel.NamespacesAndTypesOnly) {
sb.Append('<').Append(type.TypeParameters.Length).Append('>');
}
sb.Append("{");
if (detailLevel == DetailLevel.NamespacesAndTypesOnly) {
foreach (var nestedType in type.GetTypeMembers().OrderBy(t => t.Name)) {
try {
sb.Append(BuildTypeStructure(nestedType, logger, detailLevel, random, indentLevel + 1, commonImplementationInfo));
} catch (Exception ex) {
logger.LogWarning(ex, "Error building structure for nested type {TypeName} in {ParentType}", nestedType.Name, type.Name);
sb.Append($"\n{new string(' ', 2 * (indentLevel + 1))}{nestedType.Name}{{/* Error: {ex.Message} */}}");
}
}
sb.Append('\n').Append(indent).Append("}");
return sb.ToString();
}
// Regular member info for non-common base types
var membersContent = AppendMemberInfo(sb, type, logger, detailLevel, random, indent);
// Nested Types
foreach (var nestedType in type.GetTypeMembers().OrderBy(t => t.Name)) {
try {
sb.Append(BuildTypeStructure(nestedType, logger, detailLevel, random, indentLevel + 1, commonImplementationInfo));
} catch (Exception ex) {
logger.LogWarning(ex, "Error building structure for nested type {TypeName} in {ParentType}", nestedType.Name, type.Name);
sb.Append($"\n{new string(' ', 2 * (indentLevel + 1))}{nestedType.Name}{{/* Error: {ex.Message} */}}");
}
}
if (membersContent || type.GetTypeMembers().Any()) {
sb.Append('\n').Append(indent).Append("}");
} else {
sb.Append("}"); // No newline if type is empty and no members shown
}
} catch (Exception ex) {
logger.LogError(ex, "Error building structure for type {TypeName}", type.Name);
return $"\n{indent}{type.Name}{{/* Error: {ex.Message} */}}";
}
return sb.ToString();
}
private static string GetTypeShortName(ITypeSymbol type) {
try {
if (type == null) return "?";
if (type.SpecialType != SpecialType.None) {
return type.SpecialType switch {
SpecialType.System_Boolean => "bool",
SpecialType.System_Byte => "byte",
SpecialType.System_SByte => "sbyte",
SpecialType.System_Char => "char",
SpecialType.System_Int16 => "short",
SpecialType.System_UInt16 => "ushort",
SpecialType.System_Int32 => "int",
SpecialType.System_UInt32 => "uint",
SpecialType.System_Int64 => "long",
SpecialType.System_UInt64 => "ulong",
SpecialType.System_Single => "float",
SpecialType.System_Double => "double",
SpecialType.System_Decimal => "decimal",
SpecialType.System_String => "string",
SpecialType.System_Object => "object",
SpecialType.System_Void => "void",
_ => type.Name
};
}
if (type is IArrayTypeSymbol arrayType) {
return $"{GetTypeShortName(arrayType.ElementType)}[]";
}
if (type is INamedTypeSymbol namedType) {
if (namedType.IsTupleType && namedType.TupleElements.Any()) {
return $"({string.Join(", ", namedType.TupleElements.Select(te => $"{GetTypeShortName(te.Type)} {te.Name}"))})";
}
if (namedType.TypeArguments.Length > 0) {
var typeArgs = string.Join(", ", namedType.TypeArguments.Select(GetTypeShortName));
var baseName = namedType.Name;
// Handle common nullable syntax
if (namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) {
return $"{GetTypeShortName(namedType.TypeArguments[0])}?";
}
return $"{baseName}<{typeArgs}>";
}
}
return type.Name;
} catch (Exception) {
return type?.Name ?? "?";
}
}
private static async Task<Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>>> CollectDerivedAndImplementedCounts(
Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
ICodeAnalysisService codeAnalysisService,
ILogger<SolutionToolsLogCategory> logger,
CancellationToken cancellationToken) {
// Dictionary of base types to their derived types, organized by namespace
var baseTypeImplementations = new Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>>(SymbolEqualityComparer.Default);
var processedSymbols = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
try {
// Process each namespace and its types
foreach (var typesList in namespaceContents.Values) {
foreach (var typeSymbol in typesList) {
cancellationToken.ThrowIfCancellationRequested();
// Skip if we've already processed this type
if (processedSymbols.Contains(typeSymbol)) {
continue;
}
processedSymbols.Add(typeSymbol);
// Skip types that can't have derived classes (static, sealed, etc.) or implementations (non-interfaces)
if ((typeSymbol.IsStatic || typeSymbol.IsSealed) && typeSymbol.TypeKind != TypeKind.Interface) {
continue;
}
try {
var derivedTypes = new List<INamedTypeSymbol>();
// Find classes derived from this type
if (typeSymbol.TypeKind == TypeKind.Class) {
derivedTypes.AddRange(await codeAnalysisService.FindDerivedClassesAsync(typeSymbol, cancellationToken));
}
// Find implementations of this interface
if (typeSymbol.TypeKind == TypeKind.Interface) {
var implementations = await codeAnalysisService.FindImplementationsAsync(typeSymbol, cancellationToken);
foreach (var impl in implementations) {
if (impl is INamedTypeSymbol namedTypeImpl) {
derivedTypes.Add(namedTypeImpl);
}
}
}
// Skip if there are no derived types or implementations
if (derivedTypes.Count == 0) {
continue;
}
// Group derived types by namespace
var byNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
foreach (var derivedType in derivedTypes) {
var namespaceName = derivedType.ContainingNamespace?.ToDisplayString() ?? "global";
if (!byNamespace.TryGetValue(namespaceName, out var nsTypes)) {
nsTypes = new List<INamedTypeSymbol>();
byNamespace[namespaceName] = nsTypes;
}
nsTypes.Add(derivedType);
}
// Store the grouped derived types
baseTypeImplementations[typeSymbol] = byNamespace;
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error analyzing derived/implemented types for {TypeName}", typeSymbol.Name);
}
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogError(ex, "Error collecting derived/implemented type counts");
}
return baseTypeImplementations;
}
private class CommonImplementationInfo {
// Maps base types to their derived/implemented types grouped by namespace
public Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>> DerivedTypesByNamespace { get; }
// Maps base types to their total derived/implemented type count
public Dictionary<INamedTypeSymbol, int> TotalImplementationCounts { get; }
// The mean number of derived/implemented types across all base types
public double MedianImplementationCount { get; }
// Base types with above-average number of derived/implemented types
public HashSet<INamedTypeSymbol> CommonBaseTypes { get; }
public CommonImplementationInfo(Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>> derivedTypesByNamespace) {
DerivedTypesByNamespace = derivedTypesByNamespace;
// Calculate total counts for each base type
TotalImplementationCounts = new Dictionary<INamedTypeSymbol, int>(SymbolEqualityComparer.Default);
foreach (var baseType in derivedTypesByNamespace.Keys) {
int totalCount = 0;
foreach (var nsTypes in derivedTypesByNamespace[baseType].Values) {
totalCount += nsTypes.Count;
}
TotalImplementationCounts[baseType] = totalCount;
}
// Calculate the mean implementation count
if (TotalImplementationCounts.Count > 0) {
var counts = TotalImplementationCounts.Values.OrderBy(c => c).ToList();
MedianImplementationCount = counts.Count % 2 == 0
? (counts[counts.Count / 2 - 1] + counts[counts.Count / 2]) / 2.0
: counts[counts.Count / 2];
// Identify base types with above-average number of implementations
CommonBaseTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var pair in TotalImplementationCounts) {
if (pair.Value > MedianImplementationCount) {
CommonBaseTypes.Add(pair.Key);
}
}
} else {
MedianImplementationCount = 0;
CommonBaseTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
}
}
// Get the full qualified display name of a type for display purposes
public static string GetTypeDisplayName(INamedTypeSymbol type) {
return FuzzyFqnLookupService.GetSearchableString(type);
}
}
// Helper method to append member information and return whether any members were added
private static bool AppendMemberInfo(
StringBuilder sb,
INamedTypeSymbol type,
ILogger<SolutionToolsLogCategory> logger,
DetailLevel detailLevel,
Random random,
string indent) {
var membersContent = new StringBuilder();
var publicOrInternalMembers = type.GetMembers()
.Where(m => !m.IsImplicitlyDeclared &&
!(m is INamedTypeSymbol) &&
(m.DeclaredAccessibility == Accessibility.Public ||
m.DeclaredAccessibility == Accessibility.Internal ||
m.DeclaredAccessibility == Accessibility.ProtectedOrInternal))
.ToList();
var fields = publicOrInternalMembers.OfType<IFieldSymbol>()
.Where(f => !f.IsImplicitlyDeclared && !f.Name.Contains("k__BackingField") && !f.IsConst && type.TypeKind != TypeKind.Enum)
.ToList();
var constants = publicOrInternalMembers.OfType<IFieldSymbol>().Where(f => f.IsConst).ToList();
var enumValues = type.TypeKind == TypeKind.Enum ? publicOrInternalMembers.OfType<IFieldSymbol>().ToList() : new List<IFieldSymbol>();
var events = publicOrInternalMembers.OfType<IEventSymbol>().ToList();
var properties = publicOrInternalMembers.OfType<IPropertySymbol>().ToList();
var methods = publicOrInternalMembers.OfType<IMethodSymbol>()
.Where(m => m.MethodKind != MethodKind.PropertyGet &&
m.MethodKind != MethodKind.PropertySet &&
m.MethodKind != MethodKind.EventAdd &&
m.MethodKind != MethodKind.EventRemove &&
!m.Name.StartsWith("<"))
.ToList();
// Fields
if (fields.Any()) {
if (detailLevel <= DetailLevel.NoConstantFieldNames) {
foreach (var field in fields.OrderBy(f => f.Name)) {
membersContent.Append($"\n{indent} {field.Name}:{GetTypeShortName(field.Type)};");
}
} else {
membersContent.Append($"\n{indent} {fields.Count} field{(fields.Count == 1 ? "" : "s")};");
}
}
// Constants
if (constants.Any()) {
if (detailLevel < DetailLevel.NoConstantFieldNames) { // Show names if detail is Full
foreach (var cnst in constants.OrderBy(c => c.Name)) {
membersContent.Append($"\n{indent} const {cnst.Name}:{GetTypeShortName(cnst.Type)};");
}
} else {
membersContent.Append($"\n{indent} {constants.Count} constant{(constants.Count == 1 ? "" : "s")};");
}
}
// Enum Members
if (enumValues.Any()) {
if (detailLevel < DetailLevel.NoEventEnumNames) {
foreach (var enumVal in enumValues.OrderBy(e => e.Name)) {
membersContent.Append($"\n{indent} {enumVal.Name};");
}
} else {
membersContent.Append($"\n{indent} {enumValues.Count} enum value{(enumValues.Count == 1 ? "" : "s")};");
}
}
// Events
if (events.Any()) {
if (detailLevel < DetailLevel.NoEventEnumNames) {
foreach (var evt in events.OrderBy(e => e.Name)) {
membersContent.Append($"\n{indent} event {evt.Name}:{GetTypeShortName(evt.Type)};");
}
} else {
membersContent.Append($"\n{indent} {events.Count} event{(events.Count == 1 ? "" : "s")};");
}
}
// Properties
if (properties.Any()) {
if (detailLevel < DetailLevel.NoPropertyTypes) { // Full, NoConstantFieldNames, NoEventEnumNames, NoMethodParamTypes
foreach (var prop in properties.OrderBy(p => p.Name)) {
membersContent.Append($"\n{indent} {prop.Name}:{GetTypeShortName(prop.Type)};");
}
} else if (detailLevel == DetailLevel.NoPropertyTypes || detailLevel == DetailLevel.NoMethodParamNames) { // Retain property names without types
foreach (var prop in properties.OrderBy(p => p.Name)) {
membersContent.Append($"\n{indent} {prop.Name};");
}
} else if (detailLevel == DetailLevel.FiftyPercentPropertyNames) {
var shuffledProps = properties.OrderBy(_ => random.Next()).ToList();
var propsToShow = shuffledProps.Take(Math.Max(1, properties.Count / 2)).ToList();
foreach (var prop in propsToShow.OrderBy(p => p.Name)) {
membersContent.Append($"\n{indent} {prop.Name};"); // Type omitted
}
if (propsToShow.Count < properties.Count) {
membersContent.Append($"\n{indent} and {properties.Count - propsToShow.Count} more propert{(properties.Count - propsToShow.Count == 1 ? "y" : "ies")};");
}
} else if (detailLevel == DetailLevel.NoPropertyNames || detailLevel == DetailLevel.FiftyPercentMethodNames) { // Only count for NoPropertyNames or if method names are also being reduced
membersContent.Append($"\n{indent} {properties.Count} propert{(properties.Count == 1 ? "y" : "ies")};");
} else if (detailLevel < DetailLevel.NamespacesAndTypesOnly) { // Default for levels more compressed than NoPropertyNames but not NamespacesAndTypesOnly (e.g. NoMethodNames)
membersContent.Append($"\n{indent} {properties.Count} propert{(properties.Count == 1 ? "y" : "ies")};");
}
// If detailLevel is NamespacesAndTypesOnly, properties are skipped entirely by the initial check.
}
// Methods (including constructors)
if (methods.Any()) {
if (detailLevel <= DetailLevel.FiftyPercentMethodNames) {
var methodsToShow = methods;
if (detailLevel == DetailLevel.FiftyPercentMethodNames) {
var shuffledMethods = methods.OrderBy(_ => random.Next()).ToList();
methodsToShow = shuffledMethods.Take(Math.Max(1, methods.Count / 2)).ToList();
}
foreach (var method in methodsToShow.OrderBy(m => m.Name)) {
membersContent.Append($"\n{indent} {method.Name}");
if (detailLevel < DetailLevel.NoMethodParamNames) {
membersContent.Append("(");
if (method.Parameters.Length > 0) {
var paramStrings = method.Parameters.Select(p =>
detailLevel < DetailLevel.NoMethodParamTypes ? $"{p.Name}:{GetTypeShortName(p.Type)}" : p.Name
);
membersContent.Append(string.Join(", ", paramStrings));
}
membersContent.Append(")");
} else if (method.Parameters.Length > 0) {
membersContent.Append($"({method.Parameters.Length} param{(method.Parameters.Length == 1 ? "" : "s")})");
} else {
membersContent.Append("()");
}
if (method.MethodKind != MethodKind.Constructor && !method.ReturnsVoid) {
membersContent.Append($":{GetTypeShortName(method.ReturnType)}");
}
membersContent.Append(";");
}
if (detailLevel == DetailLevel.FiftyPercentMethodNames && methodsToShow.Count < methods.Count) {
membersContent.Append($"\n{indent} and {methods.Count - methodsToShow.Count} more method{(methods.Count - methodsToShow.Count == 1 ? "" : "s")};");
}
} else { // NoMethodNames or higher compression
membersContent.Append($"\n{indent} {methods.Count} method{(methods.Count == 1 ? "" : "s")};");
}
}
// Append the members content to the main StringBuilder
if (membersContent.Length > 0) {
sb.Append(membersContent);
return true;
}
return false;
}
}
```