This is page 3 of 4. Use http://codebase.md/kooshi/sharptoolsmcp?lines=true&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/SemanticSimilarityService.cs:
--------------------------------------------------------------------------------
```csharp
1 | using Microsoft.CodeAnalysis;
2 | using Microsoft.CodeAnalysis.CSharp.Syntax;
3 | using Microsoft.CodeAnalysis.FlowAnalysis;
4 | using Microsoft.CodeAnalysis.Operations;
5 | using Microsoft.Extensions.Logging;
6 | using SharpTools.Tools.Extensions;
7 | using SharpTools.Tools.Mcp;
8 | using System;
9 | using System.Collections.Concurrent; // Added
10 | using System.Collections.Generic;
11 | using System.Linq;
12 | using System.Threading;
13 | using System.Threading.Tasks;
14 |
15 | namespace SharpTools.Tools.Services {
16 | public class SemanticSimilarityService : ISemanticSimilarityService {
17 | private static class Tuning {
18 |
19 | public static readonly int MaxDegreesOfParallelism = Math.Max(1, Environment.ProcessorCount / 2);
20 |
21 | public const int MethodLineCountFilter = 10;
22 | public const double DefaultSimilarityThreshold = 0.7;
23 |
24 | public static class Normalization {
25 | public const int MaxBasicBlockCount = 60;
26 | public const int MaxConditionalBranchCount = 25;
27 | public const int MaxLoopCount = 8;
28 | public const int MaxCyclomaticComplexity = 30;
29 | }
30 |
31 | public static class Weights {
32 | public enum Feature {
33 | ReturnType,
34 | ParamCount,
35 | ParamTypes,
36 | InvokedMethods,
37 | BasicBlocks,
38 | ConditionalBranches,
39 | Loops,
40 | CyclomaticComplexity,
41 | OperationCounts,
42 | AccessedMemberTypes
43 | }
44 |
45 | public static readonly Dictionary<Feature, double> FeatureWeights = new() {
46 | { Feature.InvokedMethods, 0.25 },
47 | { Feature.OperationCounts, 0.20 },
48 | { Feature.AccessedMemberTypes, 0.15 },
49 | { Feature.ParamTypes, 0.10 },
50 | { Feature.CyclomaticComplexity, 0.075 },
51 | { Feature.ReturnType, 0.05 },
52 | { Feature.BasicBlocks, 0.05 },
53 | { Feature.ConditionalBranches, 0.05 },
54 | { Feature.Loops, 0.05 },
55 | { Feature.ParamCount, 0.025 },
56 | };
57 |
58 | public static double ReturnType => FeatureWeights[Feature.ReturnType];
59 | public static double ParamCount => FeatureWeights[Feature.ParamCount];
60 | public static double ParamTypes => FeatureWeights[Feature.ParamTypes];
61 | public static double InvokedMethods => FeatureWeights[Feature.InvokedMethods];
62 | public static double BasicBlocks => FeatureWeights[Feature.BasicBlocks];
63 | public static double ConditionalBranches => FeatureWeights[Feature.ConditionalBranches];
64 | public static double Loops => FeatureWeights[Feature.Loops];
65 | public static double CyclomaticComplexity => FeatureWeights[Feature.CyclomaticComplexity];
66 | public static double OperationCounts => FeatureWeights[Feature.OperationCounts];
67 | public static double AccessedMemberTypes => FeatureWeights[Feature.AccessedMemberTypes];
68 | public static double TotalWeight => FeatureWeights.Values.Sum();
69 | }
70 |
71 | public const int ClassLineCountFilter = 20;
72 |
73 | public static class ClassNormalization {
74 | public const int MaxPropertyCount = 30;
75 | public const int MaxFieldCount = 50;
76 | public const int MaxMethodCount = 50;
77 | public const int MaxImplementedInterfaces = 10;
78 | public const int MaxReferencedExternalTypes = 75;
79 | public const int MaxUsedNamespaces = 20;
80 | public const double MaxAverageMethodComplexity = 15.0;
81 | }
82 |
83 | public static class ClassWeights {
84 | public enum Feature {
85 | BaseClassName,
86 | ImplementedInterfaceNames,
87 | PublicMethodCount,
88 | ProtectedMethodCount,
89 | PrivateMethodCount,
90 | StaticMethodCount,
91 | AbstractMethodCount,
92 | VirtualMethodCount,
93 | PropertyCount,
94 | ReadOnlyPropertyCount,
95 | StaticPropertyCount,
96 | FieldCount,
97 | StaticFieldCount,
98 | ReadonlyFieldCount,
99 | ConstFieldCount,
100 | EventCount,
101 | NestedClassCount,
102 | NestedStructCount,
103 | NestedEnumCount,
104 | NestedInterfaceCount,
105 | AverageMethodComplexity,
106 | DistinctReferencedExternalTypeFqns,
107 | DistinctUsedNamespaceFqns,
108 | TotalLinesOfCode,
109 | MethodMatchingSimilarity // Added
110 | }
111 |
112 | public static readonly Dictionary<Feature, double> FeatureWeights = new()
113 | {
114 | { Feature.MethodMatchingSimilarity, 0.20 }, // New and significant
115 | { Feature.ImplementedInterfaceNames, 0.15 }, // Was 0.20
116 | { Feature.DistinctReferencedExternalTypeFqns, 0.15 }, // Was 0.20
117 | { Feature.BaseClassName, 0.07 }, // Was 0.10
118 | { Feature.AverageMethodComplexity, 0.05 }, // Was 0.05
119 | { Feature.PublicMethodCount, 0.03 }, // Was 0.05
120 | { Feature.PropertyCount, 0.03 }, // Was 0.05
121 | { Feature.FieldCount, 0.03 }, // Was 0.05
122 | { Feature.DistinctUsedNamespaceFqns, 0.03 }, // Was 0.05
123 | { Feature.TotalLinesOfCode, 0.02 }, // Was 0.025
124 | { Feature.ProtectedMethodCount, 0.02 }, // Was 0.025
125 | { Feature.PrivateMethodCount, 0.02 }, // Was 0.025
126 | { Feature.StaticMethodCount, 0.02 }, // Was 0.025
127 | { Feature.AbstractMethodCount, 0.02 }, // Was 0.025
128 | { Feature.VirtualMethodCount, 0.02 }, // Was 0.025
129 | { Feature.ReadOnlyPropertyCount, 0.01 },
130 | { Feature.StaticPropertyCount, 0.01 },
131 | { Feature.StaticFieldCount, 0.01 },
132 | { Feature.ReadonlyFieldCount, 0.01 },
133 | { Feature.ConstFieldCount, 0.01 },
134 | { Feature.EventCount, 0.01 },
135 | { Feature.NestedClassCount, 0.01 },
136 | { Feature.NestedStructCount, 0.01 },
137 | { Feature.NestedEnumCount, 0.01 },
138 | { Feature.NestedInterfaceCount, 0.01 }
139 | };
140 |
141 | public static double BaseClassName => FeatureWeights[Feature.BaseClassName];
142 | public static double ImplementedInterfaceNames => FeatureWeights[Feature.ImplementedInterfaceNames];
143 | public static double PublicMethodCount => FeatureWeights[Feature.PublicMethodCount];
144 | public static double ProtectedMethodCount => FeatureWeights[Feature.ProtectedMethodCount];
145 | public static double PrivateMethodCount => FeatureWeights[Feature.PrivateMethodCount];
146 | public static double StaticMethodCount => FeatureWeights[Feature.StaticMethodCount];
147 | public static double AbstractMethodCount => FeatureWeights[Feature.AbstractMethodCount];
148 | public static double VirtualMethodCount => FeatureWeights[Feature.VirtualMethodCount];
149 | public static double PropertyCount => FeatureWeights[Feature.PropertyCount];
150 | public static double ReadOnlyPropertyCount => FeatureWeights[Feature.ReadOnlyPropertyCount];
151 | public static double StaticPropertyCount => FeatureWeights[Feature.StaticPropertyCount];
152 | public static double FieldCount => FeatureWeights[Feature.FieldCount];
153 | public static double StaticFieldCount => FeatureWeights[Feature.StaticFieldCount];
154 | public static double ReadonlyFieldCount => FeatureWeights[Feature.ReadonlyFieldCount];
155 | public static double ConstFieldCount => FeatureWeights[Feature.ConstFieldCount];
156 | public static double EventCount => FeatureWeights[Feature.EventCount];
157 | public static double NestedClassCount => FeatureWeights[Feature.NestedClassCount];
158 | public static double NestedStructCount => FeatureWeights[Feature.NestedStructCount];
159 | public static double NestedEnumCount => FeatureWeights[Feature.NestedEnumCount];
160 | public static double NestedInterfaceCount => FeatureWeights[Feature.NestedInterfaceCount];
161 | public static double AverageMethodComplexity => FeatureWeights[Feature.AverageMethodComplexity];
162 | public static double DistinctReferencedExternalTypeFqns => FeatureWeights[Feature.DistinctReferencedExternalTypeFqns];
163 | public static double DistinctUsedNamespaceFqns => FeatureWeights[Feature.DistinctUsedNamespaceFqns];
164 | public static double TotalLinesOfCode => FeatureWeights[Feature.TotalLinesOfCode];
165 | public static double MethodMatchingSimilarity => FeatureWeights[Feature.MethodMatchingSimilarity]; // Added
166 | public static double TotalWeight => FeatureWeights.Values.Sum();
167 | }
168 | }
169 |
170 | private readonly ISolutionManager _solutionManager;
171 | private readonly ICodeAnalysisService _codeAnalysisService;
172 | private readonly ILogger<SemanticSimilarityService> _logger;
173 | private readonly IComplexityAnalysisService _complexityAnalysisService;
174 |
175 | public SemanticSimilarityService(
176 | ISolutionManager solutionManager,
177 | ICodeAnalysisService codeAnalysisService,
178 | ILogger<SemanticSimilarityService> logger,
179 | IComplexityAnalysisService complexityAnalysisService) {
180 | _solutionManager = solutionManager ?? throw new ArgumentNullException(nameof(solutionManager));
181 | _codeAnalysisService = codeAnalysisService ?? throw new ArgumentNullException(nameof(codeAnalysisService));
182 | _logger = logger ?? throw new ArgumentNullException(nameof(logger));
183 | _complexityAnalysisService = complexityAnalysisService ?? throw new ArgumentNullException(nameof(complexityAnalysisService));
184 | }
185 |
186 | public async Task<List<MethodSimilarityResult>> FindSimilarMethodsAsync(
187 | double similarityThreshold,
188 | CancellationToken cancellationToken) {
189 | ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarMethodsAsync));
190 | _logger.LogInformation("Starting semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism);
191 |
192 | var allMethodFeatures = new System.Collections.Concurrent.ConcurrentBag<MethodSemanticFeatures>();
193 |
194 | var parallelOptions = new ParallelOptions {
195 | MaxDegreeOfParallelism = Tuning.MaxDegreesOfParallelism,
196 | CancellationToken = cancellationToken
197 | };
198 |
199 | var projects = _solutionManager.GetProjects().ToList(); // Materialize to avoid issues with concurrent modification if GetProjects() is lazy
200 |
201 | await Parallel.ForEachAsync(projects, parallelOptions, async (project, ct) => {
202 | if (ct.IsCancellationRequested) {
203 | _logger.LogInformation("Semantic similarity analysis cancelled during project iteration for {ProjectName}.", project.Name);
204 | return;
205 | }
206 |
207 | _logger.LogDebug("Analyzing project: {ProjectName}", project.Name);
208 | var compilation = await project.GetCompilationAsync(ct);
209 | if (compilation == null) {
210 | _logger.LogWarning("Could not get compilation for project {ProjectName}", project.Name);
211 | return;
212 | }
213 | var documents = project.Documents.ToList(); // Materialize documents for the current project
214 |
215 | await Parallel.ForEachAsync(documents, parallelOptions, async (document, docCt) => {
216 | if (docCt.IsCancellationRequested) return;
217 | if (!document.SupportsSyntaxTree || !document.SupportsSemanticModel) return;
218 |
219 | _logger.LogTrace("Analyzing document: {DocumentFilePath}", document.FilePath);
220 | var syntaxTree = await document.GetSyntaxTreeAsync(docCt);
221 | var semanticModel = await document.GetSemanticModelAsync(docCt);
222 | if (syntaxTree == null || semanticModel == null) return;
223 |
224 | var methodDeclarations = syntaxTree.GetRoot(docCt).DescendantNodes().OfType<MethodDeclarationSyntax>();
225 |
226 | foreach (var methodDecl in methodDeclarations) {
227 | if (docCt.IsCancellationRequested) break;
228 |
229 | var methodSymbol = semanticModel.GetDeclaredSymbol(methodDecl, docCt) as IMethodSymbol;
230 | if (methodSymbol == null || methodSymbol.IsAbstract || methodSymbol.IsExtern || ToolHelpers.IsPropertyAccessor(methodSymbol)) {
231 | continue;
232 | }
233 |
234 | try {
235 | var features = await ExtractFeaturesAsync(methodSymbol, methodDecl, document, semanticModel, docCt);
236 | if (features != null) {
237 | allMethodFeatures.Add(features);
238 | }
239 | } catch (OperationCanceledException) {
240 | _logger.LogInformation("Feature extraction cancelled for method {MethodName} in {FilePath}", methodSymbol?.Name ?? "Unknown", document.FilePath);
241 | } catch (Exception ex) {
242 | _logger.LogWarning(ex, "Failed to extract features for method {MethodName} in {FilePath}", methodSymbol?.Name ?? "Unknown", document.FilePath);
243 | }
244 | }
245 | });
246 | });
247 |
248 | if (cancellationToken.IsCancellationRequested) {
249 | _logger.LogInformation("Semantic similarity analysis was cancelled before comparison.");
250 | throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
251 | }
252 |
253 | _logger.LogInformation("Extracted features for {MethodCount} methods. Starting similarity comparison.", allMethodFeatures.Count);
254 | return CompareFeatures(allMethodFeatures.ToList(), similarityThreshold, cancellationToken);
255 | }
256 |
257 | private async Task<MethodSemanticFeatures?> ExtractFeaturesAsync(
258 | IMethodSymbol methodSymbol,
259 | MethodDeclarationSyntax methodDecl,
260 | Document document,
261 | SemanticModel semanticModel,
262 | CancellationToken cancellationToken) {
263 | var filePath = document.FilePath ?? "unknown";
264 | var startLine = methodDecl.GetLocation().GetLineSpan().StartLinePosition.Line;
265 | var endLine = methodDecl.GetLocation().GetLineSpan().EndLinePosition.Line;
266 | var lineCount = endLine - startLine + 1;
267 |
268 | if (lineCount < Tuning.MethodLineCountFilter) {
269 | _logger.LogDebug("Method {MethodName} in {FilePath} has {LineCount} lines, which is less than the filter of {FilterCount}. Skipping.", methodSymbol.Name, filePath, lineCount, Tuning.MethodLineCountFilter);
270 | return null;
271 | }
272 |
273 | var methodName = methodSymbol.Name;
274 | var fullyQualifiedMethodName = methodSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
275 | var returnTypeName = methodSymbol.ReturnType.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
276 | var parameterTypeNames = methodSymbol.Parameters.Select(p => p.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)).ToList();
277 |
278 | var invokedMethodSignatures = new HashSet<string>();
279 | var operationCounts = new Dictionary<string, int>();
280 | var distinctAccessedMemberTypes = new HashSet<string>();
281 |
282 | int basicBlockCount = 0;
283 | int conditionalBranchCount = 0;
284 | int loopCount = 0;
285 |
286 | var methodMetrics = new Dictionary<string, object>();
287 | var recommendations = new List<string>();
288 | await _complexityAnalysisService.AnalyzeMethodAsync(methodSymbol, methodMetrics, recommendations, cancellationToken);
289 | int cyclomaticComplexity = methodMetrics.TryGetValue("cyclomaticComplexity", out var cc) && cc is int ccVal ? ccVal : 1;
290 |
291 | SyntaxNode? bodyOrExpressionBody = methodDecl.Body ?? (SyntaxNode?)methodDecl.ExpressionBody?.Expression;
292 |
293 | if (bodyOrExpressionBody != null) {
294 | try {
295 | // Use methodDecl directly for CFG creation
296 | var controlFlowGraph = ControlFlowGraph.Create(methodDecl, semanticModel, cancellationToken);
297 | if (controlFlowGraph != null && controlFlowGraph.Blocks.Any()) {
298 | basicBlockCount = controlFlowGraph.Blocks.Length;
299 | }
300 | _logger.LogDebug("ControlFlowGraph created for method {MethodName} in {FilePath}. BasicBlockCount: {BasicBlockCount}", methodName, filePath, basicBlockCount);
301 | } catch (Exception ex) {
302 | _logger.LogWarning(ex, "Failed to create ControlFlowGraph for method {MethodName} in {FilePath}. CFG-based features will be zero.", methodName, filePath);
303 | }
304 |
305 | var operation = semanticModel.GetOperation(bodyOrExpressionBody, cancellationToken);
306 | if (operation != null) {
307 | foreach (var opNode in operation.DescendantsAndSelf()) {
308 | if (cancellationToken.IsCancellationRequested) {
309 | return null;
310 | }
311 |
312 | var opKindName = opNode.Kind.ToString();
313 | operationCounts[opKindName] = operationCounts.GetValueOrDefault(opKindName, 0) + 1;
314 |
315 | if (opNode is IInvocationOperation invocation) {
316 | invokedMethodSignatures.Add(invocation.TargetMethod.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat));
317 | } else if (opNode is IFieldReferenceOperation fieldRef) {
318 | distinctAccessedMemberTypes.Add(fieldRef.Field.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
319 | } else if (opNode is IPropertyReferenceOperation propRef) {
320 | distinctAccessedMemberTypes.Add(propRef.Property.Type.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
321 | }
322 |
323 | if (opNode is ILoopOperation) {
324 | loopCount++;
325 | } else if (opNode is IConditionalOperation) {
326 | conditionalBranchCount++;
327 | }
328 | }
329 | }
330 | }
331 |
332 | return new MethodSemanticFeatures(
333 | fullyQualifiedMethodName,
334 | filePath,
335 | startLine,
336 | methodName,
337 | returnTypeName,
338 | parameterTypeNames,
339 | invokedMethodSignatures,
340 | basicBlockCount,
341 | conditionalBranchCount,
342 | loopCount,
343 | cyclomaticComplexity,
344 | operationCounts,
345 | distinctAccessedMemberTypes);
346 | }
347 |
348 | private List<MethodSimilarityResult> CompareFeatures(
349 | List<MethodSemanticFeatures> allMethodFeatures,
350 | double similarityThreshold,
351 | CancellationToken cancellationToken) {
352 | var results = new List<MethodSimilarityResult>();
353 | var processedIndices = new HashSet<int>();
354 | _logger.LogInformation("Starting similarity comparison for {MethodCount} methods.", allMethodFeatures.Count);
355 | for (int i = 0; i < allMethodFeatures.Count; i++) {
356 | if (cancellationToken.IsCancellationRequested) {
357 | throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
358 | }
359 | if (processedIndices.Contains(i)) {
360 | continue;
361 | }
362 |
363 | var currentMethod = allMethodFeatures[i];
364 | var similarGroup = new List<MethodSemanticFeatures> { currentMethod };
365 | processedIndices.Add(i);
366 | double groupTotalScore = 0;
367 | int comparisonsMade = 0;
368 | _logger.LogDebug("Comparing method {MethodName} ({FQN}) with other methods.", currentMethod.MethodName, currentMethod.FullyQualifiedMethodName);
369 |
370 | for (int j = i + 1; j < allMethodFeatures.Count; j++) {
371 | if (cancellationToken.IsCancellationRequested) {
372 | throw new OperationCanceledException("Semantic similarity analysis was cancelled.");
373 | }
374 | if (processedIndices.Contains(j)) {
375 | continue;
376 | }
377 |
378 | var otherMethod = allMethodFeatures[j];
379 |
380 | // Skip comparison if methods are overloads of each other
381 | if (currentMethod.FullyQualifiedMethodName == otherMethod.FullyQualifiedMethodName &&
382 | !currentMethod.ParameterTypeNames.SequenceEqual(otherMethod.ParameterTypeNames)) {
383 | _logger.LogDebug("Skipping comparison between overloads: {Method1FQN} ({Params1}) and {Method2FQN} ({Params2})",
384 | currentMethod.FullyQualifiedMethodName, string.Join(", ", currentMethod.ParameterTypeNames),
385 | otherMethod.FullyQualifiedMethodName, string.Join(", ", otherMethod.ParameterTypeNames));
386 | continue;
387 | }
388 |
389 | double similarity = CalculateSimilarity(currentMethod, otherMethod);
390 |
391 | if (similarity >= similarityThreshold) {
392 | similarGroup.Add(otherMethod);
393 | processedIndices.Add(j);
394 | groupTotalScore += similarity;
395 | comparisonsMade++;
396 | _logger.LogDebug("Method {OtherMethodName} ({OtherFQN}) is similar to {CurrentMethodName} ({CurrentFQN}) with score {SimilarityScore}",
397 | otherMethod.MethodName, otherMethod.FullyQualifiedMethodName,
398 | currentMethod.MethodName, currentMethod.FullyQualifiedMethodName,
399 | similarity);
400 | }
401 | }
402 |
403 | if (similarGroup.Count > 1) {
404 | double averageScore = comparisonsMade > 0 ? groupTotalScore / comparisonsMade : 1.0; // Avoid division by zero if only self-comparison
405 | results.Add(new MethodSimilarityResult(similarGroup, averageScore));
406 | _logger.LogInformation("Found similarity group of {GroupSize} methods, starting with {MethodName} ({FQN}), Avg Score: {Score:F2}",
407 | similarGroup.Count,
408 | currentMethod.MethodName,
409 | currentMethod.FullyQualifiedMethodName,
410 | averageScore);
411 | }
412 | }
413 | _logger.LogInformation("Semantic similarity analysis complete. Found {GroupCount} groups.", results.Count);
414 | return results.OrderByDescending(r => r.AverageSimilarityScore).ToList();
415 | }
416 |
417 | private double CalculateSimilarity(MethodSemanticFeatures method1, MethodSemanticFeatures method2) {
418 | double returnTypeSimilarity = (method1.ReturnTypeName == method2.ReturnTypeName) ? 1.0 : 0.0;
419 | double paramCountSimilarity = (method1.ParameterTypeNames.Count == method2.ParameterTypeNames.Count) ? 1.0 : 0.0;
420 | double paramTypeSimilarity = 0.0;
421 | if (method1.ParameterTypeNames.Count == method2.ParameterTypeNames.Count && method1.ParameterTypeNames.Any()) {
422 | int matchingParams = 0;
423 | for (int k = 0; k < method1.ParameterTypeNames.Count; k++) {
424 | if (method1.ParameterTypeNames[k] == method2.ParameterTypeNames[k]) {
425 | matchingParams++;
426 | }
427 | }
428 | paramTypeSimilarity = (double)matchingParams / method1.ParameterTypeNames.Count;
429 | } else if (method1.ParameterTypeNames.Count == 0 && method2.ParameterTypeNames.Count == 0) {
430 | paramTypeSimilarity = 1.0;
431 | }
432 |
433 | double invokedSimilarity = 0.0;
434 | if (method1.InvokedMethodSignatures.Any() || method2.InvokedMethodSignatures.Any()) {
435 | var intersection = method1.InvokedMethodSignatures.Intersect(method2.InvokedMethodSignatures).Count();
436 | var union = method1.InvokedMethodSignatures.Union(method2.InvokedMethodSignatures).Count();
437 | invokedSimilarity = union > 0 ? (double)intersection / union : 1.0;
438 | } else {
439 | invokedSimilarity = 1.0;
440 | }
441 |
442 | double basicBlockSimilarity = 1.0 - CalculateNormalizedDifference(method1.BasicBlockCount, method2.BasicBlockCount, Tuning.Normalization.MaxBasicBlockCount);
443 | double conditionalBranchSimilarity = 1.0 - CalculateNormalizedDifference(method1.ConditionalBranchCount, method2.ConditionalBranchCount, Tuning.Normalization.MaxConditionalBranchCount);
444 | double loopSimilarity = 1.0 - CalculateNormalizedDifference(method1.LoopCount, method2.LoopCount, Tuning.Normalization.MaxLoopCount);
445 | double cyclomaticComplexitySimilarity = 1.0 - CalculateNormalizedDifference(method1.CyclomaticComplexity, method2.CyclomaticComplexity, Tuning.Normalization.MaxCyclomaticComplexity);
446 | double operationCountsSimilarity = CalculateCosineSimilarity(method1.OperationCounts, method2.OperationCounts);
447 | double accessedTypesSimilarity = 0.0;
448 |
449 | if (method1.DistinctAccessedMemberTypes.Any() || method2.DistinctAccessedMemberTypes.Any()) {
450 | var intersectionTypes = method1.DistinctAccessedMemberTypes.Intersect(method2.DistinctAccessedMemberTypes).Count();
451 | var unionTypes = method1.DistinctAccessedMemberTypes.Union(method2.DistinctAccessedMemberTypes).Count();
452 | accessedTypesSimilarity = unionTypes > 0 ? (double)intersectionTypes / unionTypes : 1.0;
453 | } else {
454 | accessedTypesSimilarity = 1.0;
455 | }
456 |
457 | double totalWeightedScore =
458 | returnTypeSimilarity * Tuning.Weights.ReturnType +
459 | paramCountSimilarity * Tuning.Weights.ParamCount +
460 | paramTypeSimilarity * Tuning.Weights.ParamTypes +
461 | invokedSimilarity * Tuning.Weights.InvokedMethods +
462 | basicBlockSimilarity * Tuning.Weights.BasicBlocks +
463 | conditionalBranchSimilarity * Tuning.Weights.ConditionalBranches +
464 | loopSimilarity * Tuning.Weights.Loops +
465 | cyclomaticComplexitySimilarity * Tuning.Weights.CyclomaticComplexity +
466 | operationCountsSimilarity * Tuning.Weights.OperationCounts +
467 | accessedTypesSimilarity * Tuning.Weights.AccessedMemberTypes;
468 |
469 | return totalWeightedScore / Tuning.Weights.TotalWeight;
470 | }
471 |
472 | private double CalculateNormalizedDifference(int val1, int val2, int maxValue) {
473 | if (maxValue == 0) return (val1 == val2) ? 0.0 : 1.0;
474 | double diff = Math.Abs(val1 - val2);
475 | return diff / maxValue;
476 | }
477 |
478 | private double CalculateCosineSimilarity(Dictionary<string, int> vec1, Dictionary<string, int> vec2) {
479 | if (!vec1.Any() && !vec2.Any()) return 1.0;
480 | if (!vec1.Any() || !vec2.Any()) return 0.0;
481 |
482 | var allKeys = vec1.Keys.Union(vec2.Keys).ToList();
483 | double dotProduct = 0.0;
484 | double magnitude1 = 0.0;
485 | double magnitude2 = 0.0;
486 |
487 | foreach (var key in allKeys) {
488 | int val1 = vec1.GetValueOrDefault(key, 0);
489 | int val2 = vec2.GetValueOrDefault(key, 0);
490 |
491 | dotProduct += val1 * val2;
492 | magnitude1 += val1 * val1;
493 | magnitude2 += val2 * val2;
494 | }
495 |
496 | magnitude1 = Math.Sqrt(magnitude1);
497 | magnitude2 = Math.Sqrt(magnitude2);
498 |
499 | if (magnitude1 == 0 || magnitude2 == 0) return 0.0;
500 |
501 | return dotProduct / (magnitude1 * magnitude2);
502 | }
503 |
504 | public async Task<List<ClassSimilarityResult>> FindSimilarClassesAsync(
505 | double similarityThreshold,
506 | CancellationToken cancellationToken) {
507 | ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarClassesAsync));
508 | _logger.LogInformation("Starting class semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism);
509 |
510 | var allClassFeatures = new System.Collections.Concurrent.ConcurrentBag<ClassSemanticFeatures>();
511 |
512 | var parallelOptions = new ParallelOptions {
513 | MaxDegreeOfParallelism = Tuning.MaxDegreesOfParallelism,
514 | CancellationToken = cancellationToken
515 | };
516 |
517 | var projects = _solutionManager.GetProjects().ToList(); // Materialize
518 |
519 | await Parallel.ForEachAsync(projects, parallelOptions, async (project, ct) => {
520 | if (ct.IsCancellationRequested) {
521 | _logger.LogInformation("Class semantic similarity analysis cancelled during project iteration for {ProjectName}.", project.Name);
522 | return;
523 | }
524 |
525 | _logger.LogDebug("Analyzing project for classes: {ProjectName}", project.Name);
526 | var compilation = await project.GetCompilationAsync(ct);
527 | if (compilation == null) {
528 | _logger.LogWarning("Could not get compilation for project {ProjectName}", project.Name);
529 | return;
530 | }
531 | var documents = project.Documents.ToList(); // Materialize
532 |
533 | await Parallel.ForEachAsync(documents, parallelOptions, async (document, docCt) => {
534 | if (docCt.IsCancellationRequested) return;
535 | if (!document.SupportsSyntaxTree || !document.SupportsSemanticModel) return;
536 |
537 | _logger.LogTrace("Analyzing document for classes: {DocumentFilePath}", document.FilePath);
538 | var syntaxTree = await document.GetSyntaxTreeAsync(docCt);
539 | var semanticModel = await document.GetSemanticModelAsync(docCt);
540 | if (syntaxTree == null || semanticModel == null) return;
541 |
542 | var classDeclarations = syntaxTree.GetRoot(docCt).DescendantNodes()
543 | .OfType<TypeDeclarationSyntax>()
544 | .Where(tds => tds.Kind() == Microsoft.CodeAnalysis.CSharp.SyntaxKind.ClassDeclaration ||
545 | tds.Kind() == Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordDeclaration);
546 |
547 | foreach (var classDecl in classDeclarations) {
548 | if (docCt.IsCancellationRequested) break;
549 |
550 | var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, docCt) as INamedTypeSymbol;
551 | if (classSymbol == null || classSymbol.IsAbstract || classSymbol.IsStatic) {
552 | continue;
553 | }
554 |
555 | try {
556 | var features = await ExtractClassFeaturesAsync(classSymbol, classDecl, document, semanticModel, docCt);
557 | if (features != null) {
558 | allClassFeatures.Add(features);
559 | }
560 | } catch (OperationCanceledException) {
561 | _logger.LogInformation("Feature extraction cancelled for class {ClassName} in {FilePath}", classSymbol?.Name ?? "Unknown", document.FilePath);
562 | } catch (Exception ex) {
563 | _logger.LogWarning(ex, "Failed to extract features for class {ClassName} in {FilePath}", classSymbol?.Name ?? "Unknown", document.FilePath);
564 | }
565 | }
566 | });
567 | });
568 |
569 | if (cancellationToken.IsCancellationRequested) {
570 | _logger.LogInformation("Class semantic similarity analysis was cancelled before comparison.");
571 | throw new OperationCanceledException("Class semantic similarity analysis was cancelled.");
572 | }
573 |
574 | _logger.LogInformation("Extracted features for {ClassCount} classes. Starting similarity comparison.", allClassFeatures.Count);
575 | return CompareClassFeatures(allClassFeatures.ToList(), similarityThreshold, cancellationToken);
576 | }
577 |
578 | private async Task<ClassSemanticFeatures?> ExtractClassFeaturesAsync(
579 | INamedTypeSymbol classSymbol,
580 | TypeDeclarationSyntax classDecl,
581 | Document document,
582 | SemanticModel semanticModel,
583 | CancellationToken cancellationToken) {
584 | var filePath = document.FilePath ?? "unknown";
585 | var startLine = classDecl.GetLocation().GetLineSpan().StartLinePosition.Line;
586 | var endLine = classDecl.GetLocation().GetLineSpan().EndLinePosition.Line;
587 | var totalLinesOfCode = endLine - startLine + 1;
588 |
589 | if (totalLinesOfCode < Tuning.ClassLineCountFilter) {
590 | _logger.LogDebug("Class {ClassName} in {FilePath} has {LineCount} lines, less than filter {FilterCount}. Skipping.",
591 | classSymbol.Name, filePath, totalLinesOfCode, Tuning.ClassLineCountFilter);
592 | return null;
593 | }
594 |
595 | var className = classSymbol.Name;
596 | var fullyQualifiedClassName = classSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
597 |
598 | var distinctReferencedExternalTypeFqns = new HashSet<string>();
599 | var distinctUsedNamespaceFqns = new HashSet<string>();
600 |
601 | AddTypeAndNamespaceIfExternal(classSymbol.BaseType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
602 | var baseClassName = classSymbol.BaseType?.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal);
603 |
604 | var implementedInterfaceNames = new List<string>();
605 | foreach (var iface in classSymbol.AllInterfaces) {
606 | AddTypeAndNamespaceIfExternal(iface, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
607 | implementedInterfaceNames.Add(iface.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
608 | }
609 |
610 | int publicMethodCount = 0, protectedMethodCount = 0, privateMethodCount = 0, staticMethodCount = 0, abstractMethodCount = 0, virtualMethodCount = 0;
611 | int propertyCount = 0, readOnlyPropertyCount = 0, staticPropertyCount = 0;
612 | int fieldCount = 0, staticFieldCount = 0, readonlyFieldCount = 0, constFieldCount = 0;
613 | int eventCount = 0;
614 | int nestedClassCount = 0, nestedStructCount = 0, nestedEnumCount = 0, nestedInterfaceCount = 0;
615 | double totalMethodComplexity = 0;
616 | int analyzedMethodCount = 0;
617 | var classMethodFeatures = new List<MethodSemanticFeatures>();
618 |
619 | // Collect used namespaces from using directives in the current file
620 | if (classDecl.SyntaxTree.GetRoot(cancellationToken) is CompilationUnitSyntax compilationUnit) {
621 | foreach (var usingDirective in compilationUnit.Usings) {
622 | if (usingDirective.Name != null) {
623 | var namespaceSymbol = semanticModel.GetSymbolInfo(usingDirective.Name, cancellationToken).Symbol as INamespaceSymbol;
624 | if (namespaceSymbol != null && !namespaceSymbol.IsGlobalNamespace) {
625 | distinctUsedNamespaceFqns.Add(namespaceSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
626 | }
627 | }
628 | }
629 | }
630 |
631 | foreach (var memberSymbol in classSymbol.GetMembers()) {
632 | if (cancellationToken.IsCancellationRequested) return null;
633 |
634 | if (memberSymbol is IMethodSymbol methodMember) {
635 | if (ToolHelpers.IsPropertyAccessor(methodMember) || methodMember.IsImplicitlyDeclared) continue;
636 |
637 | if (methodMember.DeclaredAccessibility == Accessibility.Public) publicMethodCount++;
638 | else if (methodMember.DeclaredAccessibility == Accessibility.Protected) protectedMethodCount++;
639 | else if (methodMember.DeclaredAccessibility == Accessibility.Private) privateMethodCount++;
640 | if (methodMember.IsStatic) staticMethodCount++;
641 | if (methodMember.IsAbstract) abstractMethodCount++;
642 | if (methodMember.IsVirtual) virtualMethodCount++;
643 |
644 | AddTypeAndNamespaceIfExternal(methodMember.ReturnType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
645 | foreach (var param in methodMember.Parameters) {
646 | AddTypeAndNamespaceIfExternal(param.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
647 | }
648 |
649 | if (memberSymbol.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(cancellationToken) is MethodDeclarationSyntax methodDeclSyntax) {
650 | var features = await ExtractFeaturesAsync(methodMember, methodDeclSyntax, document, semanticModel, cancellationToken);
651 | if (features != null) {
652 | classMethodFeatures.Add(features);
653 | totalMethodComplexity += features.CyclomaticComplexity;
654 | analyzedMethodCount++;
655 |
656 | // Add types and namespaces from method body analysis
657 | foreach (var invokedSig in features.InvokedMethodSignatures) {
658 | // This is tricky as InvokedMethodSignatures are strings. A more robust way would be to get IMethodSymbol during ExtractFeaturesAsync
659 | // For now, we'll skip adding these to avoid parsing strings back to symbols.
660 | }
661 | foreach (var accessedType in features.DistinctAccessedMemberTypes) {
662 | // Similar to above, these are strings.
663 | }
664 | }
665 | }
666 | } else if (memberSymbol is IPropertySymbol propertyMember) {
667 | propertyCount++;
668 | if (propertyMember.IsReadOnly) readOnlyPropertyCount++;
669 | if (propertyMember.IsStatic) staticPropertyCount++;
670 | AddTypeAndNamespaceIfExternal(propertyMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
671 | } else if (memberSymbol is IFieldSymbol fieldMember) {
672 | fieldCount++;
673 | if (fieldMember.IsStatic) staticFieldCount++;
674 | if (fieldMember.IsReadOnly) readonlyFieldCount++;
675 | if (fieldMember.IsConst) constFieldCount++;
676 | AddTypeAndNamespaceIfExternal(fieldMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
677 | } else if (memberSymbol is IEventSymbol eventMember) {
678 | eventCount++;
679 | AddTypeAndNamespaceIfExternal(eventMember.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
680 | } else if (memberSymbol is INamedTypeSymbol nestedTypeMember) {
681 | if (nestedTypeMember.TypeKind == TypeKind.Class) nestedClassCount++;
682 | else if (nestedTypeMember.TypeKind == TypeKind.Struct) nestedStructCount++;
683 | else if (nestedTypeMember.TypeKind == TypeKind.Enum) nestedEnumCount++;
684 | else if (nestedTypeMember.TypeKind == TypeKind.Interface) nestedInterfaceCount++;
685 | // We don't add nested types to external types/namespaces as they are part of the class itself.
686 | }
687 | }
688 |
689 | // Deeper scan for referenced types and namespaces within method bodies and other syntax elements
690 | // This is more robust than just looking at IdentifierNameSyntax.
691 | foreach (var node in classDecl.DescendantNodes(descendIntoChildren: n => n is not TypeDeclarationSyntax || n == classDecl)) { // Avoid descending into nested types again
692 | if (cancellationToken.IsCancellationRequested) return null;
693 |
694 | ISymbol? referencedSymbol = null;
695 | if (node is IdentifierNameSyntax identifierName) {
696 | referencedSymbol = semanticModel.GetSymbolInfo(identifierName, cancellationToken).Symbol;
697 | } else if (node is MemberAccessExpressionSyntax memberAccess) {
698 | referencedSymbol = semanticModel.GetSymbolInfo(memberAccess.Name, cancellationToken).Symbol;
699 | } else if (node is ObjectCreationExpressionSyntax objectCreation) {
700 | referencedSymbol = semanticModel.GetSymbolInfo(objectCreation.Type, cancellationToken).Symbol;
701 | } else if (node is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax maes) {
702 | referencedSymbol = semanticModel.GetSymbolInfo(maes.Name, cancellationToken).Symbol;
703 | }
704 |
705 |
706 | if (referencedSymbol is ITypeSymbol typeSym) {
707 | AddTypeAndNamespaceIfExternal(typeSym, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
708 | } else if (referencedSymbol is IMethodSymbol methodSym) {
709 | AddTypeAndNamespaceIfExternal(methodSym.ReturnType, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
710 | foreach (var param in methodSym.Parameters) {
711 | AddTypeAndNamespaceIfExternal(param.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
712 | }
713 | } else if (referencedSymbol is IPropertySymbol propSym) {
714 | AddTypeAndNamespaceIfExternal(propSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
715 | } else if (referencedSymbol is IFieldSymbol fieldSym) {
716 | AddTypeAndNamespaceIfExternal(fieldSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
717 | } else if (referencedSymbol is IEventSymbol eventSym) {
718 | AddTypeAndNamespaceIfExternal(eventSym.Type, classSymbol, distinctReferencedExternalTypeFqns, distinctUsedNamespaceFqns);
719 | } else if (referencedSymbol is INamespaceSymbol nsSym && !nsSym.IsGlobalNamespace) {
720 | // Check if the namespace itself is from an external assembly (less common for direct usage like this, but possible)
721 | if (nsSym.ContainingAssembly != null && classSymbol.ContainingAssembly != null &&
722 | !SymbolEqualityComparer.Default.Equals(nsSym.ContainingAssembly, classSymbol.ContainingAssembly)) {
723 | distinctUsedNamespaceFqns.Add(nsSym.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
724 | } else if (nsSym.ContainingAssembly == null && classSymbol.ContainingAssembly != null) {
725 | // Namespace is likely global or part of the current compilation but not tied to a specific assembly in the same way types are.
726 | // We primarily add namespaces based on types they contain.
727 | }
728 | }
729 | }
730 |
731 |
732 | double averageMethodComplexity = analyzedMethodCount > 0 ? totalMethodComplexity / analyzedMethodCount : 0;
733 |
734 | return new ClassSemanticFeatures(
735 | fullyQualifiedClassName,
736 | filePath,
737 | startLine,
738 | className,
739 | baseClassName,
740 | implementedInterfaceNames.Distinct().ToList(), // Ensure distinct
741 | publicMethodCount,
742 | protectedMethodCount,
743 | privateMethodCount,
744 | staticMethodCount,
745 | abstractMethodCount,
746 | virtualMethodCount,
747 | propertyCount,
748 | readOnlyPropertyCount,
749 | staticPropertyCount,
750 | fieldCount,
751 | staticFieldCount,
752 | readonlyFieldCount,
753 | constFieldCount,
754 | eventCount,
755 | nestedClassCount,
756 | nestedStructCount,
757 | nestedEnumCount,
758 | nestedInterfaceCount,
759 | averageMethodComplexity,
760 | distinctReferencedExternalTypeFqns, // Already a HashSet
761 | distinctUsedNamespaceFqns, // Already a HashSet
762 | totalLinesOfCode,
763 | classMethodFeatures
764 | );
765 | }
766 |
767 | private List<ClassSimilarityResult> CompareClassFeatures(
768 | List<ClassSemanticFeatures> allClassFeatures,
769 | double similarityThreshold,
770 | CancellationToken cancellationToken) {
771 | var results = new List<ClassSimilarityResult>();
772 | var processedIndices = new HashSet<int>();
773 | _logger.LogInformation("Starting class similarity comparison for {ClassCount} classes.", allClassFeatures.Count);
774 |
775 | for (int i = 0; i < allClassFeatures.Count; i++) {
776 | if (cancellationToken.IsCancellationRequested) {
777 | throw new OperationCanceledException("Class similarity analysis was cancelled.");
778 | }
779 | if (processedIndices.Contains(i)) continue;
780 |
781 | var currentClass = allClassFeatures[i];
782 | var similarGroup = new List<ClassSemanticFeatures> { currentClass };
783 | processedIndices.Add(i);
784 | double groupTotalScore = 0;
785 | int comparisonsMade = 0;
786 |
787 | for (int j = i + 1; j < allClassFeatures.Count; j++) {
788 | if (cancellationToken.IsCancellationRequested) {
789 | throw new OperationCanceledException("Class similarity analysis was cancelled.");
790 | }
791 | if (processedIndices.Contains(j)) continue;
792 |
793 | var otherClass = allClassFeatures[j];
794 | double similarity = CalculateClassSimilarity(currentClass, otherClass);
795 |
796 | if (similarity >= similarityThreshold) {
797 | similarGroup.Add(otherClass);
798 | processedIndices.Add(j);
799 | groupTotalScore += similarity;
800 | comparisonsMade++;
801 | }
802 | }
803 |
804 | if (similarGroup.Count > 1) {
805 | double averageScore = comparisonsMade > 0 ? groupTotalScore / comparisonsMade : 1.0;
806 | results.Add(new ClassSimilarityResult(similarGroup, averageScore));
807 | _logger.LogInformation("Found class similarity group of {GroupSize}, starting with {ClassName}, Avg Score: {Score:F2}",
808 | similarGroup.Count, currentClass.ClassName, averageScore);
809 | }
810 | }
811 | _logger.LogInformation("Class semantic similarity analysis complete. Found {GroupCount} groups.", results.Count);
812 | return results.OrderByDescending(r => r.AverageSimilarityScore).ToList();
813 | }
814 |
815 | private double CalculateClassSimilarity(ClassSemanticFeatures class1, ClassSemanticFeatures class2) {
816 | double baseClassSimilarity = (class1.BaseClassName == class2.BaseClassName) ? 1.0 :
817 | (string.IsNullOrEmpty(class1.BaseClassName) && string.IsNullOrEmpty(class2.BaseClassName) ? 1.0 : 0.0);
818 |
819 | double interfaceSimilarity = CalculateJaccardSimilarity(class1.ImplementedInterfaceNames, class2.ImplementedInterfaceNames);
820 | double referencedTypesSimilarity = CalculateJaccardSimilarity(class1.DistinctReferencedExternalTypeFqns, class2.DistinctReferencedExternalTypeFqns);
821 | double usedNamespacesSimilarity = CalculateJaccardSimilarity(class1.DistinctUsedNamespaceFqns, class2.DistinctUsedNamespaceFqns);
822 |
823 | double methodMatchingSimilarity = 0.0;
824 | if (class1.MethodFeatures.Any() && class2.MethodFeatures.Any()) {
825 | var smallerList = class1.MethodFeatures.Count < class2.MethodFeatures.Count ? class1.MethodFeatures : class2.MethodFeatures;
826 | var largerList = class1.MethodFeatures.Count < class2.MethodFeatures.Count ? class2.MethodFeatures : class1.MethodFeatures;
827 | double totalMaxSimilarity = 0.0;
828 | HashSet<int> usedLargerListIndices = new HashSet<int>();
829 |
830 | foreach (var method1Feat in smallerList) {
831 | double maxSimForMethod1 = 0.0;
832 | int bestMatchIndex = -1;
833 | for (int k = 0; k < largerList.Count; k++) {
834 | if (usedLargerListIndices.Contains(k)) {
835 | continue;
836 | }
837 | double sim = CalculateSimilarity(method1Feat, largerList[k]); // Uses existing method similarity
838 | if (sim > maxSimForMethod1) {
839 | maxSimForMethod1 = sim;
840 | bestMatchIndex = k;
841 | }
842 | }
843 | if (bestMatchIndex != -1) {
844 | totalMaxSimilarity += maxSimForMethod1;
845 | usedLargerListIndices.Add(bestMatchIndex);
846 | }
847 | }
848 | methodMatchingSimilarity = smallerList.Any() ? totalMaxSimilarity / smallerList.Count : 1.0;
849 | } else if (!class1.MethodFeatures.Any() && !class2.MethodFeatures.Any()) {
850 | methodMatchingSimilarity = 1.0; // Both have no methods, considered perfectly similar in this aspect
851 | }
852 |
853 | double totalWeightedScore =
854 | baseClassSimilarity * Tuning.ClassWeights.BaseClassName +
855 | interfaceSimilarity * Tuning.ClassWeights.ImplementedInterfaceNames +
856 | referencedTypesSimilarity * Tuning.ClassWeights.DistinctReferencedExternalTypeFqns +
857 | usedNamespacesSimilarity * Tuning.ClassWeights.DistinctUsedNamespaceFqns +
858 | methodMatchingSimilarity * Tuning.ClassWeights.MethodMatchingSimilarity + // Added
859 | (1.0 - CalculateNormalizedDifference(class1.PublicMethodCount, class2.PublicMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.PublicMethodCount +
860 | (1.0 - CalculateNormalizedDifference(class1.ProtectedMethodCount, class2.ProtectedMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.ProtectedMethodCount +
861 | (1.0 - CalculateNormalizedDifference(class1.PrivateMethodCount, class2.PrivateMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.PrivateMethodCount +
862 | (1.0 - CalculateNormalizedDifference(class1.StaticMethodCount, class2.StaticMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.StaticMethodCount +
863 | (1.0 - CalculateNormalizedDifference(class1.AbstractMethodCount, class2.AbstractMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.AbstractMethodCount +
864 | (1.0 - CalculateNormalizedDifference(class1.VirtualMethodCount, class2.VirtualMethodCount, Tuning.ClassNormalization.MaxMethodCount)) * Tuning.ClassWeights.VirtualMethodCount +
865 | (1.0 - CalculateNormalizedDifference(class1.PropertyCount, class2.PropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.PropertyCount +
866 | (1.0 - CalculateNormalizedDifference(class1.ReadOnlyPropertyCount, class2.ReadOnlyPropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.ReadOnlyPropertyCount +
867 | (1.0 - CalculateNormalizedDifference(class1.StaticPropertyCount, class2.StaticPropertyCount, Tuning.ClassNormalization.MaxPropertyCount)) * Tuning.ClassWeights.StaticPropertyCount +
868 | (1.0 - CalculateNormalizedDifference(class1.FieldCount, class2.FieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.FieldCount +
869 | (1.0 - CalculateNormalizedDifference(class1.StaticFieldCount, class2.StaticFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.StaticFieldCount +
870 | (1.0 - CalculateNormalizedDifference(class1.ReadonlyFieldCount, class2.ReadonlyFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.ReadonlyFieldCount +
871 | (1.0 - CalculateNormalizedDifference(class1.ConstFieldCount, class2.ConstFieldCount, Tuning.ClassNormalization.MaxFieldCount)) * Tuning.ClassWeights.ConstFieldCount +
872 | (1.0 - CalculateNormalizedDifference(class1.EventCount, class2.EventCount, 20)) * Tuning.ClassWeights.EventCount + // Max 20 events (consider making this a ClassNormalization constant)
873 | (1.0 - CalculateNormalizedDifference(class1.NestedClassCount, class2.NestedClassCount, 10)) * Tuning.ClassWeights.NestedClassCount + // Max 10 (consider ClassNormalization)
874 | (1.0 - CalculateNormalizedDifference(class1.NestedStructCount, class2.NestedStructCount, 10)) * Tuning.ClassWeights.NestedStructCount + // Max 10 (consider ClassNormalization)
875 | (1.0 - CalculateNormalizedDifference(class1.NestedEnumCount, class2.NestedEnumCount, 10)) * Tuning.ClassWeights.NestedEnumCount + // Max 10 (consider ClassNormalization)
876 | (1.0 - CalculateNormalizedDifference(class1.NestedInterfaceCount, class2.NestedInterfaceCount, 10)) * Tuning.ClassWeights.NestedInterfaceCount + // Max 10 (consider ClassNormalization)
877 | (1.0 - CalculateNormalizedDifference(class1.AverageMethodComplexity, class2.AverageMethodComplexity, Tuning.ClassNormalization.MaxAverageMethodComplexity)) * Tuning.ClassWeights.AverageMethodComplexity +
878 | (1.0 - CalculateNormalizedDifference(class1.TotalLinesOfCode, class2.TotalLinesOfCode, 2000)) * Tuning.ClassWeights.TotalLinesOfCode; // Assuming max 2000 LOC (consider ClassNormalization)
879 |
880 | // Ensure total weight is not zero to prevent division by zero if all weights are somehow zero.
881 | double totalWeight = Tuning.ClassWeights.TotalWeight;
882 | return totalWeight > 0 ? totalWeightedScore / totalWeight : 0.0;
883 | }
884 |
885 | private double CalculateJaccardSimilarity<T>(ICollection<T> set1, ICollection<T> set2) {
886 | if (!set1.Any() && !set2.Any()) return 1.0;
887 | if (!set1.Any() || !set2.Any()) return 0.0;
888 |
889 | var intersection = set1.Intersect(set2).Count();
890 | var union = set1.Union(set2).Count();
891 | return union > 0 ? (double)intersection / union : 0.0;
892 | }
893 |
894 | private double CalculateNormalizedDifference(double val1, double val2, double maxValue) {
895 | if (maxValue == 0.0) {
896 | return (val1 == val2) ? 0.0 : 1.0;
897 | }
898 | double diff = Math.Abs(val1 - val2);
899 | return diff / maxValue;
900 | }
901 |
902 | private void AddTypeAndNamespaceIfExternal(
903 | ITypeSymbol? typeSymbol,
904 | INamedTypeSymbol containingClassSymbol,
905 | HashSet<string> externalTypeFqns,
906 | HashSet<string> usedNamespaceFqns) {
907 | if (typeSymbol == null || typeSymbol.TypeKind == TypeKind.Error || typeSymbol.SpecialType == SpecialType.System_Void) {
908 | return;
909 | }
910 |
911 | // Add namespace
912 | if (typeSymbol.ContainingNamespace != null && !typeSymbol.ContainingNamespace.IsGlobalNamespace) {
913 | usedNamespaceFqns.Add(typeSymbol.ContainingNamespace.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
914 | }
915 |
916 | // Add type if external
917 | if (typeSymbol.ContainingAssembly != null && containingClassSymbol.ContainingAssembly != null &&
918 | !SymbolEqualityComparer.Default.Equals(typeSymbol.ContainingAssembly, containingClassSymbol.ContainingAssembly)) {
919 | externalTypeFqns.Add(typeSymbol.OriginalDefinition.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
920 | }
921 |
922 | // Handle generic type arguments
923 | if (typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.IsGenericType) {
924 | foreach (var typeArg in namedTypeSymbol.TypeArguments) {
925 | AddTypeAndNamespaceIfExternal(typeArg, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
926 | }
927 | }
928 | // Handle array element type
929 | if (typeSymbol is IArrayTypeSymbol arrayTypeSymbol) {
930 | AddTypeAndNamespaceIfExternal(arrayTypeSymbol.ElementType, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
931 | }
932 | // Handle pointer element type
933 | if (typeSymbol is IPointerTypeSymbol pointerTypeSymbol) {
934 | AddTypeAndNamespaceIfExternal(pointerTypeSymbol.PointedAtType, containingClassSymbol, externalTypeFqns, usedNamespaceFqns);
935 | }
936 | }
937 | }
938 | }
939 |
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/SolutionTools.cs:
--------------------------------------------------------------------------------
```csharp
1 | using ModelContextProtocol;
2 | using SharpTools.Tools.Services;
3 |
4 | namespace SharpTools.Tools.Mcp.Tools;
5 |
6 | using System.Xml;
7 | using System.Xml.Linq;
8 | using Microsoft.CodeAnalysis.CSharp.Syntax;
9 |
10 | // Marker class for ILogger<T> category specific to SolutionTools
11 | public class SolutionToolsLogCategory { }
12 |
13 | [McpServerToolType]
14 | public static class SolutionTools {
15 |
16 | private const int MaxOutputLength = 50000;
17 | private enum DetailLevel {
18 | Full,
19 | NoConstantFieldNames,
20 | NoCommonDerivedOrImplementedClasses,
21 | NoEventEnumNames,
22 | NoMethodParamTypes,
23 | NoPropertyTypes,
24 | NoMethodParamNames,
25 | FiftyPercentPropertyNames,
26 | NoPropertyNames,
27 | FiftyPercentMethodNames,
28 | NoMethodNames,
29 | NamespacesAndTypesOnly
30 | }
31 | private const string LoadSolutionDescriptionText =
32 | "The the `SharpTool` suite provides you with focused, high quality, and high information density dotnet analysis and editing tools. " +
33 | "When using `SharpTool`s, you focus on individual components, and navigate with type hierarchies and call graphs instead of raw code. " +
34 | "Because of this, you create more modular, coherent, composable, type-safe, and thus inherently correct code. " +
35 | $"`{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.";
36 |
37 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(LoadSolution), Idempotent = true, Destructive = false, OpenWorld = false, ReadOnly = true)]
38 | [Description(LoadSolutionDescriptionText)]
39 | public static async Task<object> LoadSolution(
40 | ISolutionManager solutionManager,
41 | IEditorConfigProvider editorConfigProvider,
42 | ILogger<SolutionToolsLogCategory> logger,
43 | [Description("The absolute file path to the .sln or .slnx solution file.")] string solutionPath,
44 | CancellationToken cancellationToken) {
45 |
46 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
47 | ErrorHandlingHelpers.ValidateStringParameter(solutionPath, "solutionPath", logger);
48 | logger.LogInformation("Executing '{LoadSolution}' tool for path: {SolutionPath}", nameof(LoadSolution), solutionPath);
49 |
50 | // Validate solution file exists and has correct extension
51 | if (!File.Exists(solutionPath)) {
52 | logger.LogError("Solution file not found at path: {SolutionPath}", solutionPath);
53 | throw new McpException($"Solution file does not exist at path: {solutionPath}");
54 | }
55 |
56 | var ext = Path.GetExtension(solutionPath);
57 | if (!ext.Equals(".sln", StringComparison.OrdinalIgnoreCase) &&
58 | !ext.Equals(".slnx", StringComparison.OrdinalIgnoreCase)) {
59 | logger.LogError("File is not a valid solution file: {SolutionPath}", solutionPath);
60 | throw new McpException($"File at path '{solutionPath}' is not a .sln or .slnx file.");
61 | }
62 |
63 | try {
64 | await solutionManager.LoadSolutionAsync(solutionPath, cancellationToken);
65 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
66 | logger.LogError(ex, "Failed to load solution at {SolutionPath}", solutionPath);
67 | throw new McpException($"Failed to load solution: {ex.Message}");
68 | }
69 |
70 | // Get solution directory and initialize editor config
71 | var solutionDir = Path.GetDirectoryName(solutionPath);
72 | if (string.IsNullOrEmpty(solutionDir)) {
73 | logger.LogWarning(".editorconfig provider could not determine solution directory from path: {SolutionPath}", solutionPath);
74 | throw new McpException($"Could not determine directory for solution path: {solutionPath}");
75 | }
76 |
77 | try {
78 | await editorConfigProvider.InitializeAsync(solutionDir, cancellationToken);
79 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
80 | // Log but don't fail - editor config is helpful but not critical
81 | logger.LogWarning(ex, "Failed to initialize .editorconfig from {SolutionDir}", solutionDir);
82 | // Continue execution, don't throw
83 | }
84 |
85 | var projectCount = solutionManager.GetProjects().Count();
86 | var successMessage = $"Solution '{Path.GetFileName(solutionPath)}' loaded successfully with {projectCount} project(s). Caches and .editorconfig initialized.";
87 | logger.LogInformation(successMessage);
88 |
89 | try {
90 | return await GetProjectStructure(solutionManager, logger, cancellationToken);
91 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
92 | logger.LogWarning(ex, "Successfully loaded solution but failed to retrieve project structure");
93 | // Return basic info instead of detailed structure
94 | return ToolHelpers.ToJson(new {
95 | solutionName = Path.GetFileName(solutionPath),
96 | projectCount,
97 | status = "Solution loaded successfully, but project structure retrieval failed."
98 | });
99 | }
100 | }, logger, nameof(LoadSolution), cancellationToken);
101 | }
102 | private static async Task<object> GetProjectStructure(
103 | ISolutionManager solutionManager,
104 | ILogger<SolutionToolsLogCategory> logger,
105 | CancellationToken cancellationToken) {
106 |
107 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
108 | ToolHelpers.EnsureSolutionLoaded(solutionManager);
109 |
110 | var projectsData = new List<object>();
111 |
112 | try {
113 | foreach (var project in solutionManager.GetProjects()) {
114 | cancellationToken.ThrowIfCancellationRequested();
115 |
116 | try {
117 | var compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
118 | var targetFramework = "Unknown";
119 |
120 | // Get the actual target framework from the project file
121 | if (!string.IsNullOrEmpty(project.FilePath) && File.Exists(project.FilePath)) {
122 | targetFramework = ExtractTargetFrameworkFromProjectFile(project.FilePath);
123 | }
124 |
125 | // Get top level namespaces
126 | var topLevelNamespaces = new HashSet<string>();
127 |
128 | try {
129 | foreach (var document in project.Documents) {
130 | if (document.SourceCodeKind != SourceCodeKind.Regular || !document.SupportsSyntaxTree) {
131 | continue;
132 | }
133 |
134 | try {
135 | var syntaxRoot = await document.GetSyntaxRootAsync(cancellationToken);
136 | if (syntaxRoot == null) {
137 | continue;
138 | }
139 | foreach (var nsNode in syntaxRoot.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>()) {
140 | topLevelNamespaces.Add(nsNode.Name.ToString());
141 | }
142 | } catch (Exception ex) {
143 | logger.LogWarning(ex, "Error getting namespaces from document {DocumentPath}", document.FilePath);
144 | // Continue with other documents
145 | }
146 | }
147 | } catch (Exception ex) {
148 | logger.LogWarning(ex, "Error getting namespaces for project {ProjectName}", project.Name);
149 | // Continue with basic project info
150 | }
151 |
152 | // Get project references safely
153 | var projectRefs = new List<string>();
154 | try {
155 | if (solutionManager.CurrentSolution != null) {
156 | projectRefs = project.ProjectReferences
157 | .Select(pr => solutionManager.CurrentSolution.GetProject(pr.ProjectId)?.Name)
158 | .Where(name => name != null)
159 | .OrderBy(name => name)
160 | .ToList()!;
161 | }
162 | } catch (Exception ex) {
163 | logger.LogWarning(ex, "Error getting project references for {ProjectName}", project.Name);
164 | // Continue with empty project references
165 | }
166 |
167 | // Get NuGet package references from project file (with enhanced format detection)
168 | var packageRefs = new List<string>();
169 | try {
170 | if (!string.IsNullOrEmpty(project.FilePath) && File.Exists(project.FilePath)) {
171 | // Get all packages
172 | var packages = Services.LegacyNuGetPackageReader.GetAllPackages(project.FilePath);
173 | foreach (var package in packages) {
174 | packageRefs.Add($"{package.PackageId} ({package.Version})");
175 | }
176 | }
177 | } catch (Exception ex) {
178 | logger.LogWarning(ex, "Error getting NuGet package references for {ProjectName}", project.Name);
179 | // Continue with empty package references
180 | }
181 |
182 | // Build namespace hierarchy as a nested tree representation
183 | var namespaceTree = new Dictionary<string, HashSet<string>>();
184 |
185 | foreach (var ns in topLevelNamespaces) {
186 | var parts = ns.Split('.');
187 | var current = "";
188 |
189 | for (int i = 0; i < parts.Length; i++) {
190 | var part = parts[i];
191 | var nextNamespace = string.IsNullOrEmpty(current) ? part : $"{current}.{part}";
192 |
193 | if (!namespaceTree.TryGetValue(current, out var children)) {
194 | children = new HashSet<string>();
195 | namespaceTree[current] = children;
196 | }
197 |
198 | children.Add(part);
199 | current = nextNamespace;
200 | }
201 | }
202 |
203 | // Format the namespace tree as a string representation
204 | var namespaceTreeBuilder = new StringBuilder();
205 | BuildNamespaceTreeString("", namespaceTree, namespaceTreeBuilder);
206 | var namespaceStructure = namespaceTreeBuilder.ToString();
207 |
208 | // Local function to recursively build the tree string
209 | void BuildNamespaceTreeString(string current, Dictionary<string, HashSet<string>> tree, StringBuilder builder) {
210 | if (!tree.TryGetValue(current, out var children) || children.Count == 0) {
211 | return;
212 | }
213 |
214 | bool first = true;
215 | foreach (var child in children.OrderBy(c => c)) {
216 | if (!first) {
217 | builder.Append(',');
218 | }
219 | first = false;
220 |
221 | builder.Append(child);
222 |
223 | string nextNamespace = string.IsNullOrEmpty(current) ? child : $"{current}.{child}";
224 | if (tree.ContainsKey(nextNamespace)) {
225 | builder.Append('{');
226 | BuildNamespaceTreeString(nextNamespace, tree, builder);
227 | builder.Append('}');
228 | }
229 | }
230 | }
231 |
232 | // Build the project data
233 | projectsData.Add(new {
234 | name = project.Name + (project.AssemblyName.Equals(project.Name, StringComparison.OrdinalIgnoreCase) ? "" : $" ({project.AssemblyName})"),
235 | version = project.Version.ToString(),
236 | targetFramework,
237 | namespaces = namespaceStructure,
238 | documentCount = project.DocumentIds.Count,
239 | projectReferences = projectRefs,
240 | packageReferences = packageRefs
241 | });
242 | } catch (Exception ex) when (!(ex is OperationCanceledException)) {
243 | logger.LogWarning(ex, "Error processing project {ProjectName}, adding basic info only", project.Name);
244 | // Add minimal project info when there's an error
245 | projectsData.Add(new {
246 | name = project.Name,
247 | //filePath = project.FilePath,
248 | language = project.Language,
249 | error = $"Error processing project: {ex.Message}",
250 | documentCount = project.DocumentIds.Count
251 | });
252 | }
253 | }
254 |
255 | // Create the result safely
256 | string? solutionName = null;
257 | try {
258 | solutionName = Path.GetFileName(solutionManager.CurrentSolution?.FilePath ?? "unknown");
259 | } catch {
260 | solutionName = "unknown";
261 | }
262 |
263 | var result = new {
264 | solutionName,
265 | projects = projectsData.OrderBy(p => ((dynamic)p).name).ToList(),
266 | nextStep = $"Use `{ToolHelpers.SharpToolPrefix}{nameof(LoadProject)}` to get a detailed view of a specific project's structure."
267 | };
268 |
269 | logger.LogInformation("Project structure retrieved successfully for {ProjectCount} projects.", projectsData.Count);
270 | return ToolHelpers.ToJson(result);
271 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
272 | logger.LogError(ex, "Error retrieving project structure");
273 | throw new McpException($"Failed to retrieve project structure: {ex.Message}");
274 | }
275 | }, logger, nameof(GetProjectStructure), cancellationToken);
276 | }
277 | public static string ExtractTargetFrameworkFromProjectFile(string projectFilePath) {
278 | try {
279 | if (string.IsNullOrEmpty(projectFilePath)) {
280 | return "Unknown";
281 | }
282 |
283 | if (!File.Exists(projectFilePath)) {
284 | return "Unknown";
285 | }
286 |
287 | var xDoc = XDocument.Load(projectFilePath);
288 |
289 | // New-style .csproj (SDK-style)
290 | var propertyGroupElements = xDoc.Descendants("PropertyGroup");
291 | foreach (var propertyGroup in propertyGroupElements) {
292 | var targetFrameworkElement = propertyGroup.Element("TargetFramework");
293 | if (targetFrameworkElement != null) {
294 | var value = targetFrameworkElement.Value.Trim();
295 | return !string.IsNullOrEmpty(value) ? value : "Unknown";
296 | }
297 |
298 | var targetFrameworksElement = propertyGroup.Element("TargetFrameworks");
299 | if (targetFrameworksElement != null) {
300 | var value = targetFrameworksElement.Value.Trim();
301 | return !string.IsNullOrEmpty(value) ? value : "Unknown";
302 | }
303 | }
304 |
305 | // Old-style .csproj format
306 | var targetFrameworkVersionElement = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault();
307 | if (targetFrameworkVersionElement != null) {
308 | var version = targetFrameworkVersionElement.Value.Trim();
309 |
310 | // Map from old-style version format (v4.x) to new-style (.NETFramework,Version=v4.x)
311 | if (!string.IsNullOrEmpty(version)) {
312 | if (version.StartsWith("v")) {
313 | return $"net{version.Substring(1).Replace(".", "")}";
314 | }
315 | return version;
316 | }
317 | }
318 |
319 | // Additional old-style property check
320 | var targetFrameworkProfile = xDoc.Descendants("TargetFrameworkProfile").FirstOrDefault()?.Value?.Trim();
321 | var targetFrameworkIdentifier = xDoc.Descendants("TargetFrameworkIdentifier").FirstOrDefault()?.Value?.Trim();
322 |
323 | if (!string.IsNullOrEmpty(targetFrameworkIdentifier)) {
324 | // Parse the old-style framework identifier
325 | if (targetFrameworkIdentifier.Contains(".NETFramework")) {
326 | var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
327 | if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
328 | return $"net{version.Substring(1).Replace(".", "")}";
329 | }
330 | } else if (targetFrameworkIdentifier.Contains(".NETCore")) {
331 | var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
332 | if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
333 | return $"netcoreapp{version.Substring(1).Replace(".", "")}";
334 | }
335 | } else if (targetFrameworkIdentifier.Contains(".NETStandard")) {
336 | var version = xDoc.Descendants("TargetFrameworkVersion").FirstOrDefault()?.Value?.Trim();
337 | if (!string.IsNullOrEmpty(version) && version.StartsWith("v")) {
338 | return $"netstandard{version.Substring(1).Replace(".", "")}";
339 | }
340 | }
341 |
342 | // Add profile if present
343 | if (!string.IsNullOrEmpty(targetFrameworkProfile)) {
344 | return $"{targetFrameworkIdentifier},{targetFrameworkProfile}";
345 | }
346 |
347 | return targetFrameworkIdentifier;
348 | }
349 |
350 | return "Unknown";
351 | } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) {
352 | // File access issues
353 | return "Unknown (Access Error)";
354 | } catch (Exception ex) when (ex is XmlException) {
355 | // XML parsing issues
356 | return "Unknown (XML Error)";
357 | } catch (Exception) {
358 | // Any other exceptions
359 | return "Unknown";
360 | }
361 | }
362 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(LoadProject), ReadOnly = true, OpenWorld = false, Destructive = false, Idempotent = false)]
363 | [Description($"Use this immediately after {nameof(LoadSolution)}. This injects a comprehensive understanding of the project structure into your context.")]
364 | public static async Task<object> LoadProject(
365 | ISolutionManager solutionManager,
366 | ILogger<SolutionToolsLogCategory> logger,
367 | ICodeAnalysisService codeAnalysisService,
368 | string projectName,
369 | CancellationToken cancellationToken) {
370 |
371 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
372 | ErrorHandlingHelpers.ValidateStringParameter(projectName, "projectName", logger);
373 | logger.LogInformation("Executing '{LoadProjectToolName}' tool for project: {ProjectName}", nameof(LoadProject), projectName);
374 |
375 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(LoadProject));
376 | int indexOfParen = projectName.IndexOf('(');
377 | string projectNameNormalized = indexOfParen == -1
378 | ? projectName.Trim()
379 | : projectName[..indexOfParen].Trim();
380 |
381 | var project = solutionManager.GetProjects().FirstOrDefault(
382 | p => p.Name == projectName
383 | || p.AssemblyName == projectName
384 | || p.Name == projectNameNormalized);
385 | if (project == null) {
386 | logger.LogError("Project '{ProjectName}' not found in the loaded solution", projectName);
387 | throw new McpException($"Project '{projectName}' not found in the solution.");
388 | }
389 |
390 | cancellationToken.ThrowIfCancellationRequested();
391 | logger.LogDebug("Processing project: {ProjectName}", project.Name);
392 |
393 | // Get the compilation for the project
394 | Compilation? compilation;
395 | try {
396 | compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
397 | if (compilation == null) {
398 | logger.LogError("Failed to get compilation for project: {ProjectName}", project.Name);
399 | throw new McpException($"Failed to get compilation for project: {project.Name}");
400 | }
401 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
402 | logger.LogError(ex, "Error getting compilation for project {ProjectName}", project.Name);
403 | throw new McpException($"Error getting compilation for project '{project.Name}': {ex.Message}");
404 | }
405 |
406 | // For display (excludes nested types)
407 | var namespaceContents = new Dictionary<string, List<INamedTypeSymbol>>();
408 | var typesByNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
409 |
410 | // For analysis (includes all types including nested)
411 | var allNamespaceContents = new Dictionary<string, List<INamedTypeSymbol>>();
412 | var allTypesByNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
413 |
414 | try {
415 | // First pass: Collect all source symbols and organize types by namespace
416 | foreach (var document in project.Documents) {
417 | cancellationToken.ThrowIfCancellationRequested();
418 |
419 | if (document.SourceCodeKind != SourceCodeKind.Regular || !document.SupportsSyntaxTree) {
420 | continue;
421 | }
422 |
423 | try {
424 | var syntaxTree = await document.GetSyntaxTreeAsync(cancellationToken);
425 | if (syntaxTree == null) continue;
426 |
427 | var semanticModel = compilation.GetSemanticModel(syntaxTree);
428 | if (semanticModel == null) continue;
429 |
430 | var root = await syntaxTree.GetRootAsync(cancellationToken);
431 |
432 | // Get all type declarations
433 | foreach (var typeDecl in root.DescendantNodes().OfType<BaseTypeDeclarationSyntax>()) {
434 | cancellationToken.ThrowIfCancellationRequested();
435 |
436 | try {
437 | if (semanticModel.GetDeclaredSymbol(typeDecl, cancellationToken) is INamedTypeSymbol symbol) {
438 | var nsName = symbol.ContainingNamespace?.ToDisplayString() ?? "global";
439 |
440 | // Always add to the "all types" collection for analysis
441 | if (!allTypesByNamespace.TryGetValue(nsName, out var allTypeList)) {
442 | allTypeList = new List<INamedTypeSymbol>();
443 | allTypesByNamespace[nsName] = allTypeList;
444 | }
445 | allTypeList.Add(symbol);
446 |
447 | // Only add non-nested types to the display collection
448 | if (symbol.ContainingType == null) {
449 | if (!typesByNamespace.TryGetValue(nsName, out var typeList)) {
450 | typeList = new List<INamedTypeSymbol>();
451 | typesByNamespace[nsName] = typeList;
452 | }
453 | typeList.Add(symbol);
454 | }
455 | }
456 | } catch (Exception ex) when (!(ex is OperationCanceledException)) {
457 | logger.LogWarning(ex, "Error processing type declaration in document {DocumentPath}", document.FilePath);
458 | }
459 | }
460 | } catch (Exception ex) when (!(ex is OperationCanceledException)) {
461 | logger.LogWarning(ex, "Error processing document {DocumentPath}", document.FilePath);
462 | }
463 | }
464 |
465 | // Populate the display namespace contents
466 | foreach (var nsEntry in typesByNamespace) {
467 | if (!namespaceContents.TryGetValue(nsEntry.Key, out var globalTypeList)) {
468 | globalTypeList = new List<INamedTypeSymbol>();
469 | namespaceContents[nsEntry.Key] = globalTypeList;
470 | }
471 | globalTypeList.AddRange(nsEntry.Value);
472 | }
473 |
474 | // Populate the analysis namespace contents
475 | foreach (var nsEntry in allTypesByNamespace) {
476 | if (!allNamespaceContents.TryGetValue(nsEntry.Key, out var globalTypeList)) {
477 | globalTypeList = new List<INamedTypeSymbol>();
478 | allNamespaceContents[nsEntry.Key] = globalTypeList;
479 | }
480 | globalTypeList.AddRange(nsEntry.Value);
481 | }
482 |
483 | // Collect derived/implemented type information for the NoCommonDerivedOrImplementedClasses detail level
484 | // Use the all types collection for analysis to include nested types
485 | var derivedTypesByNamespace = await CollectDerivedAndImplementedCounts(allNamespaceContents, codeAnalysisService, logger, cancellationToken);
486 | var commonImplementationInfo = new CommonImplementationInfo(derivedTypesByNamespace);
487 |
488 | logger.LogInformation("Found {ImplementationCount} types with derived classes or implementations. Mean count: {MeanCount:F2}, Common base types: {CommonBaseCount}",
489 | commonImplementationInfo.TotalImplementationCounts.Count,
490 | commonImplementationInfo.MedianImplementationCount,
491 | commonImplementationInfo.CommonBaseTypes.Count);
492 |
493 | var structureBuilder = new StringBuilder();
494 | DetailLevel currentDetailLevel = DetailLevel.Full;
495 | string output = "";
496 | bool lengthAcceptable = false;
497 | Random random = new Random();
498 |
499 | while (!lengthAcceptable && currentDetailLevel <= DetailLevel.NamespacesAndTypesOnly) {
500 | structureBuilder.Clear();
501 | var sortedNamespaces = namespaceContents.Keys.OrderBy(ns => ns).ToList();
502 | var namespaceParts = BuildNamespaceHierarchy(sortedNamespaces, namespaceContents, logger);
503 | var rootNamespaces = namespaceParts.Keys.Where(ns => ns.IndexOf('.') == -1).OrderBy(n => n).ToList();
504 |
505 | foreach (var rootNs in rootNamespaces) {
506 | structureBuilder.Append(BuildNamespaceStructureText(rootNs, namespaceParts, namespaceContents, logger, currentDetailLevel, random, commonImplementationInfo));
507 | }
508 |
509 | output = structureBuilder.ToString();
510 |
511 | if (output.Length <= MaxOutputLength) {
512 | lengthAcceptable = true;
513 | } else {
514 | logger.LogInformation("Output string length ({Length}) exceeds limit ({Limit}). Reducing detail from {OldLevel} to {NewLevel}.", output.Length, MaxOutputLength, currentDetailLevel, currentDetailLevel + 1);
515 | currentDetailLevel++;
516 | if (currentDetailLevel > DetailLevel.NamespacesAndTypesOnly) {
517 | logger.LogWarning("Even at the most compressed level, output length ({Length}) exceeds limit ({Limit}). Returning compressed output.", output.Length, MaxOutputLength);
518 | }
519 | }
520 | }
521 |
522 | return $"<typeTree note=\"Use {ToolHelpers.SharpToolPrefix}{nameof(AnalysisTools.GetMembers)} for more detailed information about specific types.\">" +
523 | output +
524 | "\n</typeTree>";
525 |
526 | } catch (OperationCanceledException) {
527 | logger.LogInformation("Operation was cancelled while analyzing project {ProjectName}", project.Name);
528 | throw;
529 | } catch (Exception ex) when (!(ex is McpException)) {
530 | logger.LogError(ex, "Error analyzing project structure for {ProjectName}", project.Name);
531 | throw new McpException($"Error analyzing project structure: {ex.Message}");
532 | }
533 | }, logger, nameof(LoadProject), cancellationToken);
534 | }
535 | private static Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>> BuildNamespaceHierarchy(
536 | List<string> sortedNamespaces,
537 | Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
538 | ILogger<SolutionToolsLogCategory> logger) {
539 |
540 | // Process namespaces to build the hierarchy
541 | var namespaceParts = new Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>>();
542 |
543 | foreach (var fullNamespace in sortedNamespaces) {
544 | try {
545 | // Skip empty global namespace
546 | if (string.IsNullOrEmpty(fullNamespace) || fullNamespace == "global") {
547 | continue;
548 | }
549 |
550 | // Split namespace into parts
551 | var parts = fullNamespace.Split('.');
552 |
553 | // Create entries for each namespace part
554 | var currentNs = "";
555 |
556 | for (int i = 0; i < parts.Length; i++) {
557 | var part = parts[i];
558 |
559 | if (!string.IsNullOrEmpty(currentNs)) {
560 | currentNs += ".";
561 | }
562 | currentNs += part;
563 |
564 | if (!namespaceParts.TryGetValue(currentNs, out var children)) {
565 | children = new Dictionary<string, List<INamedTypeSymbol>>();
566 | namespaceParts[currentNs] = children;
567 | }
568 |
569 | // If not the last part, add the next part as child namespace
570 | if (i < parts.Length - 1) {
571 | var nextPart = parts[i + 1];
572 | if (!children.ContainsKey(nextPart)) {
573 | children[nextPart] = new List<INamedTypeSymbol>();
574 | }
575 | }
576 | }
577 |
578 | // Add types to the leaf namespace
579 | if (namespaceContents.TryGetValue(fullNamespace, out var types) && types.Any()) {
580 | var leafNsParts = namespaceParts[fullNamespace];
581 | foreach (var type in types) {
582 | var typeName = type.Name;
583 | if (!leafNsParts.TryGetValue(typeName, out var typeList)) {
584 | typeList = new List<INamedTypeSymbol>();
585 | leafNsParts[typeName] = typeList;
586 | }
587 | typeList.Add(type);
588 | }
589 | }
590 | } catch (Exception ex) {
591 | logger.LogWarning(ex, "Error processing namespace {Namespace} in hierarchy", fullNamespace);
592 | }
593 | }
594 | return namespaceParts;
595 | }
596 | private static string BuildNamespaceStructureText(
597 | string namespaceName,
598 | Dictionary<string, Dictionary<string, List<INamedTypeSymbol>>> namespaceParts,
599 | Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
600 | ILogger<SolutionToolsLogCategory> logger,
601 | DetailLevel detailLevel,
602 | Random random,
603 | CommonImplementationInfo? commonImplementationInfo = null) {
604 |
605 | var sb = new StringBuilder();
606 | try {
607 | var simpleName = namespaceName.Contains('.')
608 | ? namespaceName.Substring(namespaceName.LastIndexOf('.') + 1)
609 | : namespaceName;
610 |
611 | sb.Append('\n').Append(simpleName).Append('{');
612 |
613 | // If we're at NoCommonDerivedOrImplementedClasses level or above
614 | // show derived class counts for common base types in this namespace
615 | if (commonImplementationInfo != null &&
616 | detailLevel >= DetailLevel.NoCommonDerivedOrImplementedClasses) {
617 |
618 | // Build a dictionary of base types to their derived classes in this namespace
619 | var derivedCountsInNamespace = new Dictionary<INamedTypeSymbol, int>(SymbolEqualityComparer.Default);
620 |
621 | foreach (var baseType in commonImplementationInfo.CommonBaseTypes) {
622 | if (commonImplementationInfo.DerivedTypesByNamespace.TryGetValue(baseType, out var derivedByNs) &&
623 | derivedByNs.TryGetValue(namespaceName, out var derivedTypes) &&
624 | derivedTypes.Count > 0) {
625 |
626 | derivedCountsInNamespace[baseType] = derivedTypes.Count;
627 | }
628 | }
629 |
630 | // If there are any derived classes from common base types in this namespace, show their counts
631 | if (derivedCountsInNamespace.Count > 0) {
632 | foreach (var entry in derivedCountsInNamespace) {
633 | var baseType = entry.Key;
634 | var count = entry.Value;
635 | string typeKindStr = baseType.TypeKind == TypeKind.Interface ? "implementation" : "derived class";
636 | string baseTypeName = CommonImplementationInfo.GetTypeDisplayName(baseType);
637 |
638 | sb.Append($"\n {count} {typeKindStr}{(count == 1 ? "" : "es")} of {baseTypeName};");
639 | }
640 | }
641 | }
642 |
643 | var typesInNamespace = namespaceContents.GetValueOrDefault(namespaceName);
644 | var typeContent = new StringBuilder();
645 |
646 | if (typesInNamespace != null) {
647 | foreach (var type in typesInNamespace.OrderBy(t => t.Name)) {
648 | try {
649 | var typeStructure = BuildTypeStructure(type, logger, detailLevel, random, 1, commonImplementationInfo);
650 | if (!string.IsNullOrEmpty(typeStructure)) { // Skip empty results (filtered derived types)
651 | typeContent.Append(typeStructure);
652 | }
653 | } catch (Exception ex) {
654 | logger.LogWarning(ex, "Error building structure for type {TypeName} in namespace {Namespace}", type.Name, namespaceName);
655 | typeContent.Append($"\n{new string(' ', 2 * 1)}{type.Name}{{/* Error: {ex.Message} */}}");
656 | }
657 | }
658 | }
659 |
660 | var childNamespaceContent = new StringBuilder();
661 | if (namespaceParts.TryGetValue(namespaceName, out var children)) {
662 | foreach (var child in children.OrderBy(c => c.Key)) {
663 | if (child.Value?.Count == 0) { // This indicates a child namespace rather than a type within the current namespace
664 | var childNamespace = namespaceName + "." + child.Key;
665 | try {
666 | childNamespaceContent.Append(BuildNamespaceStructureText(childNamespace, namespaceParts, namespaceContents, logger, detailLevel, random, commonImplementationInfo));
667 | } catch (Exception ex) {
668 | logger.LogWarning(ex, "Error building structure for child namespace {Namespace}", childNamespace);
669 | childNamespaceContent.Append($"\n{child.Key}{{/* Error: {ex.Message} */}}");
670 | }
671 | }
672 | }
673 | }
674 |
675 | sb.Append(typeContent);
676 | sb.Append(childNamespaceContent);
677 | sb.Append("\n}");
678 |
679 | } catch (Exception ex) {
680 | logger.LogError(ex, "Error building namespace structure text for {Namespace}", namespaceName);
681 | return $"\n{namespaceName}{{/* Error: {ex.Message} */}}";
682 | }
683 | return sb.ToString();
684 | }
685 | private static string BuildTypeStructure(
686 | INamedTypeSymbol type,
687 | ILogger<SolutionToolsLogCategory> logger,
688 | DetailLevel detailLevel,
689 | Random random,
690 | int indentLevel,
691 | CommonImplementationInfo? commonImplementationInfo = null) {
692 |
693 | var sb = new StringBuilder();
694 | var indent = string.Empty; // new string(' ', 2 * indentLevel);
695 | try {
696 | // Skip derived classes that are part of a common base type at NoCommonDerivedOrImplementedClasses level or above
697 | if (commonImplementationInfo != null &&
698 | detailLevel >= DetailLevel.NoCommonDerivedOrImplementedClasses) {
699 |
700 | // Check if this type inherits from or implements a common base type
701 | bool shouldSkip = false;
702 | foreach (var commonBaseType in commonImplementationInfo.CommonBaseTypes) {
703 | // Check if this type directly inherits from a common base type
704 | if (SymbolEqualityComparer.Default.Equals(type.BaseType, commonBaseType)) {
705 | shouldSkip = true;
706 | break;
707 | }
708 |
709 | // Check if this type implements a common interface
710 | foreach (var iface in type.AllInterfaces) {
711 | if (SymbolEqualityComparer.Default.Equals(iface, commonBaseType)) {
712 | shouldSkip = true;
713 | break;
714 | }
715 | }
716 |
717 | if (shouldSkip) {
718 | break;
719 | }
720 | }
721 |
722 | if (shouldSkip) {
723 | return string.Empty; // Skip this type
724 | }
725 | }
726 |
727 | sb.Append('\n').Append(indent).Append(type.Name);
728 |
729 | if (type.TypeParameters.Length > 0 && detailLevel < DetailLevel.NamespacesAndTypesOnly) {
730 | sb.Append('<').Append(type.TypeParameters.Length).Append('>');
731 | }
732 | sb.Append("{");
733 |
734 | if (detailLevel == DetailLevel.NamespacesAndTypesOnly) {
735 | foreach (var nestedType in type.GetTypeMembers().OrderBy(t => t.Name)) {
736 | try {
737 | sb.Append(BuildTypeStructure(nestedType, logger, detailLevel, random, indentLevel + 1, commonImplementationInfo));
738 | } catch (Exception ex) {
739 | logger.LogWarning(ex, "Error building structure for nested type {TypeName} in {ParentType}", nestedType.Name, type.Name);
740 | sb.Append($"\n{new string(' ', 2 * (indentLevel + 1))}{nestedType.Name}{{/* Error: {ex.Message} */}}");
741 | }
742 | }
743 | sb.Append('\n').Append(indent).Append("}");
744 | return sb.ToString();
745 | }
746 |
747 | // Regular member info for non-common base types
748 | var membersContent = AppendMemberInfo(sb, type, logger, detailLevel, random, indent);
749 |
750 | // Nested Types
751 | foreach (var nestedType in type.GetTypeMembers().OrderBy(t => t.Name)) {
752 | try {
753 | sb.Append(BuildTypeStructure(nestedType, logger, detailLevel, random, indentLevel + 1, commonImplementationInfo));
754 | } catch (Exception ex) {
755 | logger.LogWarning(ex, "Error building structure for nested type {TypeName} in {ParentType}", nestedType.Name, type.Name);
756 | sb.Append($"\n{new string(' ', 2 * (indentLevel + 1))}{nestedType.Name}{{/* Error: {ex.Message} */}}");
757 | }
758 | }
759 |
760 | if (membersContent || type.GetTypeMembers().Any()) {
761 | sb.Append('\n').Append(indent).Append("}");
762 | } else {
763 | sb.Append("}"); // No newline if type is empty and no members shown
764 | }
765 |
766 | } catch (Exception ex) {
767 | logger.LogError(ex, "Error building structure for type {TypeName}", type.Name);
768 | return $"\n{indent}{type.Name}{{/* Error: {ex.Message} */}}";
769 | }
770 | return sb.ToString();
771 | }
772 | private static string GetTypeShortName(ITypeSymbol type) {
773 | try {
774 | if (type == null) return "?";
775 |
776 | if (type.SpecialType != SpecialType.None) {
777 | return type.SpecialType switch {
778 | SpecialType.System_Boolean => "bool",
779 | SpecialType.System_Byte => "byte",
780 | SpecialType.System_SByte => "sbyte",
781 | SpecialType.System_Char => "char",
782 | SpecialType.System_Int16 => "short",
783 | SpecialType.System_UInt16 => "ushort",
784 | SpecialType.System_Int32 => "int",
785 | SpecialType.System_UInt32 => "uint",
786 | SpecialType.System_Int64 => "long",
787 | SpecialType.System_UInt64 => "ulong",
788 | SpecialType.System_Single => "float",
789 | SpecialType.System_Double => "double",
790 | SpecialType.System_Decimal => "decimal",
791 | SpecialType.System_String => "string",
792 | SpecialType.System_Object => "object",
793 | SpecialType.System_Void => "void",
794 | _ => type.Name
795 | };
796 | }
797 |
798 | if (type is IArrayTypeSymbol arrayType) {
799 | return $"{GetTypeShortName(arrayType.ElementType)}[]";
800 | }
801 |
802 | if (type is INamedTypeSymbol namedType) {
803 | if (namedType.IsTupleType && namedType.TupleElements.Any()) {
804 | return $"({string.Join(", ", namedType.TupleElements.Select(te => $"{GetTypeShortName(te.Type)} {te.Name}"))})";
805 | }
806 | if (namedType.TypeArguments.Length > 0) {
807 | var typeArgs = string.Join(", ", namedType.TypeArguments.Select(GetTypeShortName));
808 | var baseName = namedType.Name;
809 | // Handle common nullable syntax
810 | if (namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) {
811 | return $"{GetTypeShortName(namedType.TypeArguments[0])}?";
812 | }
813 | return $"{baseName}<{typeArgs}>";
814 | }
815 | }
816 |
817 | return type.Name;
818 | } catch (Exception) {
819 | return type?.Name ?? "?";
820 | }
821 | }
822 | private static async Task<Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>>> CollectDerivedAndImplementedCounts(
823 | Dictionary<string, List<INamedTypeSymbol>> namespaceContents,
824 | ICodeAnalysisService codeAnalysisService,
825 | ILogger<SolutionToolsLogCategory> logger,
826 | CancellationToken cancellationToken) {
827 |
828 | // Dictionary of base types to their derived types, organized by namespace
829 | var baseTypeImplementations = new Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>>(SymbolEqualityComparer.Default);
830 | var processedSymbols = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
831 |
832 | try {
833 | // Process each namespace and its types
834 | foreach (var typesList in namespaceContents.Values) {
835 | foreach (var typeSymbol in typesList) {
836 | cancellationToken.ThrowIfCancellationRequested();
837 |
838 | // Skip if we've already processed this type
839 | if (processedSymbols.Contains(typeSymbol)) {
840 | continue;
841 | }
842 |
843 | processedSymbols.Add(typeSymbol);
844 |
845 | // Skip types that can't have derived classes (static, sealed, etc.) or implementations (non-interfaces)
846 | if ((typeSymbol.IsStatic || typeSymbol.IsSealed) && typeSymbol.TypeKind != TypeKind.Interface) {
847 | continue;
848 | }
849 |
850 | try {
851 | var derivedTypes = new List<INamedTypeSymbol>();
852 |
853 | // Find classes derived from this type
854 | if (typeSymbol.TypeKind == TypeKind.Class) {
855 | derivedTypes.AddRange(await codeAnalysisService.FindDerivedClassesAsync(typeSymbol, cancellationToken));
856 | }
857 |
858 | // Find implementations of this interface
859 | if (typeSymbol.TypeKind == TypeKind.Interface) {
860 | var implementations = await codeAnalysisService.FindImplementationsAsync(typeSymbol, cancellationToken);
861 | foreach (var impl in implementations) {
862 | if (impl is INamedTypeSymbol namedTypeImpl) {
863 | derivedTypes.Add(namedTypeImpl);
864 | }
865 | }
866 | }
867 |
868 | // Skip if there are no derived types or implementations
869 | if (derivedTypes.Count == 0) {
870 | continue;
871 | }
872 |
873 | // Group derived types by namespace
874 | var byNamespace = new Dictionary<string, List<INamedTypeSymbol>>();
875 | foreach (var derivedType in derivedTypes) {
876 | var namespaceName = derivedType.ContainingNamespace?.ToDisplayString() ?? "global";
877 | if (!byNamespace.TryGetValue(namespaceName, out var nsTypes)) {
878 | nsTypes = new List<INamedTypeSymbol>();
879 | byNamespace[namespaceName] = nsTypes;
880 | }
881 | nsTypes.Add(derivedType);
882 | }
883 |
884 | // Store the grouped derived types
885 | baseTypeImplementations[typeSymbol] = byNamespace;
886 | } catch (Exception ex) when (!(ex is OperationCanceledException)) {
887 | logger.LogWarning(ex, "Error analyzing derived/implemented types for {TypeName}", typeSymbol.Name);
888 | }
889 | }
890 | }
891 | } catch (Exception ex) when (!(ex is OperationCanceledException)) {
892 | logger.LogError(ex, "Error collecting derived/implemented type counts");
893 | }
894 |
895 | return baseTypeImplementations;
896 | }
897 | private class CommonImplementationInfo {
898 | // Maps base types to their derived/implemented types grouped by namespace
899 | public Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>> DerivedTypesByNamespace { get; }
900 |
901 | // Maps base types to their total derived/implemented type count
902 | public Dictionary<INamedTypeSymbol, int> TotalImplementationCounts { get; }
903 |
904 | // The mean number of derived/implemented types across all base types
905 | public double MedianImplementationCount { get; }
906 |
907 | // Base types with above-average number of derived/implemented types
908 | public HashSet<INamedTypeSymbol> CommonBaseTypes { get; }
909 |
910 | public CommonImplementationInfo(Dictionary<INamedTypeSymbol, Dictionary<string, List<INamedTypeSymbol>>> derivedTypesByNamespace) {
911 | DerivedTypesByNamespace = derivedTypesByNamespace;
912 |
913 | // Calculate total counts for each base type
914 | TotalImplementationCounts = new Dictionary<INamedTypeSymbol, int>(SymbolEqualityComparer.Default);
915 | foreach (var baseType in derivedTypesByNamespace.Keys) {
916 | int totalCount = 0;
917 | foreach (var nsTypes in derivedTypesByNamespace[baseType].Values) {
918 | totalCount += nsTypes.Count;
919 | }
920 | TotalImplementationCounts[baseType] = totalCount;
921 | }
922 |
923 | // Calculate the mean implementation count
924 | if (TotalImplementationCounts.Count > 0) {
925 | var counts = TotalImplementationCounts.Values.OrderBy(c => c).ToList();
926 | MedianImplementationCount = counts.Count % 2 == 0
927 | ? (counts[counts.Count / 2 - 1] + counts[counts.Count / 2]) / 2.0
928 | : counts[counts.Count / 2];
929 |
930 | // Identify base types with above-average number of implementations
931 | CommonBaseTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
932 | foreach (var pair in TotalImplementationCounts) {
933 | if (pair.Value > MedianImplementationCount) {
934 | CommonBaseTypes.Add(pair.Key);
935 | }
936 | }
937 | } else {
938 | MedianImplementationCount = 0;
939 | CommonBaseTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
940 | }
941 | }
942 |
943 | // Get the full qualified display name of a type for display purposes
944 | public static string GetTypeDisplayName(INamedTypeSymbol type) {
945 | return FuzzyFqnLookupService.GetSearchableString(type);
946 | }
947 | }
948 | // Helper method to append member information and return whether any members were added
949 | private static bool AppendMemberInfo(
950 | StringBuilder sb,
951 | INamedTypeSymbol type,
952 | ILogger<SolutionToolsLogCategory> logger,
953 | DetailLevel detailLevel,
954 | Random random,
955 | string indent) {
956 |
957 | var membersContent = new StringBuilder();
958 | var publicOrInternalMembers = type.GetMembers()
959 | .Where(m => !m.IsImplicitlyDeclared &&
960 | !(m is INamedTypeSymbol) &&
961 | (m.DeclaredAccessibility == Accessibility.Public ||
962 | m.DeclaredAccessibility == Accessibility.Internal ||
963 | m.DeclaredAccessibility == Accessibility.ProtectedOrInternal))
964 | .ToList();
965 |
966 | var fields = publicOrInternalMembers.OfType<IFieldSymbol>()
967 | .Where(f => !f.IsImplicitlyDeclared && !f.Name.Contains("k__BackingField") && !f.IsConst && type.TypeKind != TypeKind.Enum)
968 | .ToList();
969 | var constants = publicOrInternalMembers.OfType<IFieldSymbol>().Where(f => f.IsConst).ToList();
970 | var enumValues = type.TypeKind == TypeKind.Enum ? publicOrInternalMembers.OfType<IFieldSymbol>().ToList() : new List<IFieldSymbol>();
971 | var events = publicOrInternalMembers.OfType<IEventSymbol>().ToList();
972 | var properties = publicOrInternalMembers.OfType<IPropertySymbol>().ToList();
973 | var methods = publicOrInternalMembers.OfType<IMethodSymbol>()
974 | .Where(m => m.MethodKind != MethodKind.PropertyGet &&
975 | m.MethodKind != MethodKind.PropertySet &&
976 | m.MethodKind != MethodKind.EventAdd &&
977 | m.MethodKind != MethodKind.EventRemove &&
978 | !m.Name.StartsWith("<"))
979 | .ToList();
980 |
981 | // Fields
982 | if (fields.Any()) {
983 | if (detailLevel <= DetailLevel.NoConstantFieldNames) {
984 | foreach (var field in fields.OrderBy(f => f.Name)) {
985 | membersContent.Append($"\n{indent} {field.Name}:{GetTypeShortName(field.Type)};");
986 | }
987 | } else {
988 | membersContent.Append($"\n{indent} {fields.Count} field{(fields.Count == 1 ? "" : "s")};");
989 | }
990 | }
991 |
992 | // Constants
993 | if (constants.Any()) {
994 | if (detailLevel < DetailLevel.NoConstantFieldNames) { // Show names if detail is Full
995 | foreach (var cnst in constants.OrderBy(c => c.Name)) {
996 | membersContent.Append($"\n{indent} const {cnst.Name}:{GetTypeShortName(cnst.Type)};");
997 | }
998 | } else {
999 | membersContent.Append($"\n{indent} {constants.Count} constant{(constants.Count == 1 ? "" : "s")};");
1000 | }
1001 | }
1002 |
1003 | // Enum Members
1004 | if (enumValues.Any()) {
1005 | if (detailLevel < DetailLevel.NoEventEnumNames) {
1006 | foreach (var enumVal in enumValues.OrderBy(e => e.Name)) {
1007 | membersContent.Append($"\n{indent} {enumVal.Name};");
1008 | }
1009 | } else {
1010 | membersContent.Append($"\n{indent} {enumValues.Count} enum value{(enumValues.Count == 1 ? "" : "s")};");
1011 | }
1012 | }
1013 |
1014 | // Events
1015 | if (events.Any()) {
1016 | if (detailLevel < DetailLevel.NoEventEnumNames) {
1017 | foreach (var evt in events.OrderBy(e => e.Name)) {
1018 | membersContent.Append($"\n{indent} event {evt.Name}:{GetTypeShortName(evt.Type)};");
1019 | }
1020 | } else {
1021 | membersContent.Append($"\n{indent} {events.Count} event{(events.Count == 1 ? "" : "s")};");
1022 | }
1023 | }
1024 |
1025 | // Properties
1026 | if (properties.Any()) {
1027 | if (detailLevel < DetailLevel.NoPropertyTypes) { // Full, NoConstantFieldNames, NoEventEnumNames, NoMethodParamTypes
1028 | foreach (var prop in properties.OrderBy(p => p.Name)) {
1029 | membersContent.Append($"\n{indent} {prop.Name}:{GetTypeShortName(prop.Type)};");
1030 | }
1031 | } else if (detailLevel == DetailLevel.NoPropertyTypes || detailLevel == DetailLevel.NoMethodParamNames) { // Retain property names without types
1032 | foreach (var prop in properties.OrderBy(p => p.Name)) {
1033 | membersContent.Append($"\n{indent} {prop.Name};");
1034 | }
1035 | } else if (detailLevel == DetailLevel.FiftyPercentPropertyNames) {
1036 | var shuffledProps = properties.OrderBy(_ => random.Next()).ToList();
1037 | var propsToShow = shuffledProps.Take(Math.Max(1, properties.Count / 2)).ToList();
1038 | foreach (var prop in propsToShow.OrderBy(p => p.Name)) {
1039 | membersContent.Append($"\n{indent} {prop.Name};"); // Type omitted
1040 | }
1041 | if (propsToShow.Count < properties.Count) {
1042 | membersContent.Append($"\n{indent} and {properties.Count - propsToShow.Count} more propert{(properties.Count - propsToShow.Count == 1 ? "y" : "ies")};");
1043 | }
1044 | } else if (detailLevel == DetailLevel.NoPropertyNames || detailLevel == DetailLevel.FiftyPercentMethodNames) { // Only count for NoPropertyNames or if method names are also being reduced
1045 | membersContent.Append($"\n{indent} {properties.Count} propert{(properties.Count == 1 ? "y" : "ies")};");
1046 | } else if (detailLevel < DetailLevel.NamespacesAndTypesOnly) { // Default for levels more compressed than NoPropertyNames but not NamespacesAndTypesOnly (e.g. NoMethodNames)
1047 | membersContent.Append($"\n{indent} {properties.Count} propert{(properties.Count == 1 ? "y" : "ies")};");
1048 | }
1049 | // If detailLevel is NamespacesAndTypesOnly, properties are skipped entirely by the initial check.
1050 | }
1051 |
1052 | // Methods (including constructors)
1053 | if (methods.Any()) {
1054 | if (detailLevel <= DetailLevel.FiftyPercentMethodNames) {
1055 | var methodsToShow = methods;
1056 | if (detailLevel == DetailLevel.FiftyPercentMethodNames) {
1057 | var shuffledMethods = methods.OrderBy(_ => random.Next()).ToList();
1058 | methodsToShow = shuffledMethods.Take(Math.Max(1, methods.Count / 2)).ToList();
1059 | }
1060 | foreach (var method in methodsToShow.OrderBy(m => m.Name)) {
1061 | membersContent.Append($"\n{indent} {method.Name}");
1062 | if (detailLevel < DetailLevel.NoMethodParamNames) {
1063 | membersContent.Append("(");
1064 | if (method.Parameters.Length > 0) {
1065 | var paramStrings = method.Parameters.Select(p =>
1066 | detailLevel < DetailLevel.NoMethodParamTypes ? $"{p.Name}:{GetTypeShortName(p.Type)}" : p.Name
1067 | );
1068 | membersContent.Append(string.Join(", ", paramStrings));
1069 | }
1070 | membersContent.Append(")");
1071 | } else if (method.Parameters.Length > 0) {
1072 | membersContent.Append($"({method.Parameters.Length} param{(method.Parameters.Length == 1 ? "" : "s")})");
1073 | } else {
1074 | membersContent.Append("()");
1075 | }
1076 | if (method.MethodKind != MethodKind.Constructor && !method.ReturnsVoid) {
1077 | membersContent.Append($":{GetTypeShortName(method.ReturnType)}");
1078 | }
1079 | membersContent.Append(";");
1080 | }
1081 | if (detailLevel == DetailLevel.FiftyPercentMethodNames && methodsToShow.Count < methods.Count) {
1082 | membersContent.Append($"\n{indent} and {methods.Count - methodsToShow.Count} more method{(methods.Count - methodsToShow.Count == 1 ? "" : "s")};");
1083 | }
1084 | } else { // NoMethodNames or higher compression
1085 | membersContent.Append($"\n{indent} {methods.Count} method{(methods.Count == 1 ? "" : "s")};");
1086 | }
1087 | }
1088 |
1089 | // Append the members content to the main StringBuilder
1090 | if (membersContent.Length > 0) {
1091 | sb.Append(membersContent);
1092 | return true;
1093 | }
1094 |
1095 | return false;
1096 | }
1097 | }
1098 |
1099 |
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/ModificationTools.cs:
--------------------------------------------------------------------------------
```csharp
1 | using System;
2 | using System.Collections.Generic;
3 | using System.ComponentModel;
4 | using System.IO;
5 | using System.Linq;
6 | using System.Text;
7 | using System.Text.RegularExpressions;
8 | using System.Threading;
9 | using System.Threading.Tasks;
10 | using DiffPlex.DiffBuilder;
11 | using DiffPlex.DiffBuilder.Model;
12 | using Microsoft.CodeAnalysis;
13 | using Microsoft.CodeAnalysis.CSharp;
14 | using Microsoft.CodeAnalysis.CSharp.Syntax;
15 | using Microsoft.CodeAnalysis.Editing;
16 | using Microsoft.CodeAnalysis.FindSymbols;
17 | using Microsoft.Extensions.FileSystemGlobbing;
18 | using Microsoft.Extensions.Logging;
19 | using ModelContextProtocol;
20 | using SharpTools.Tools.Interfaces;
21 | using SharpTools.Tools.Mcp;
22 | using SharpTools.Tools.Services;
23 |
24 | namespace SharpTools.Tools.Mcp.Tools;
25 |
26 | // Marker class for ILogger<T> category specific to ModificationTools
27 | public class ModificationToolsLogCategory { }
28 |
29 | [McpServerToolType]
30 | public static class ModificationTools {
31 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(AddMember), Idempotent = false, Destructive = false, OpenWorld = false, ReadOnly = false)]
32 | [Description("Adds one or more new member definitions (Property, Field, Method, inner Class, etc.) to a specified type. Code is parsed, inserted, and formatted. Definition can include xml documentation and attributes. Writing small components produces cleaner code, so you can use this to break up large components, in addition to adding new functionality.")]
33 | public static async Task<string> AddMember(
34 | ISolutionManager solutionManager,
35 | ICodeModificationService modificationService,
36 | IComplexityAnalysisService complexityAnalysisService,
37 | ISemanticSimilarityService semanticSimilarityService,
38 | ILogger<ModificationToolsLogCategory> logger,
39 | [Description("FQN of the parent type or method.")] string fullyQualifiedTargetName,
40 | [Description("The C# code to add.")] string codeSnippet,
41 | [Description("If the target is a partial type, specifies which file to add to. Set to 'auto' to determine automatically.")] string fileNameHint,
42 | [Description("Suggest a line number to insert the member near. '-1' to determine automatically.")] int lineNumberHint,
43 | string commitMessage,
44 | CancellationToken cancellationToken = default) {
45 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
46 | // Validate parameters
47 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTargetName, "fullyQualifiedTargetName", logger);
48 | ErrorHandlingHelpers.ValidateStringParameter(codeSnippet, "codeSnippet", logger);
49 | codeSnippet = codeSnippet.TrimBackslash();
50 |
51 | // Ensure solution is loaded
52 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AddMember));
53 | logger.LogInformation("Executing '{AddMember}' for target: {TargetName}", nameof(AddMember), fullyQualifiedTargetName);
54 |
55 | // Get the target symbol
56 | var targetSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedTargetName, cancellationToken);
57 |
58 | SyntaxReference? targetSyntaxRef = null;
59 | if (!string.IsNullOrEmpty(fileNameHint) && fileNameHint != "auto") {
60 | targetSyntaxRef = targetSymbol.DeclaringSyntaxReferences.FirstOrDefault(sr =>
61 | sr.SyntaxTree.FilePath != null && sr.SyntaxTree.FilePath.Contains(fileNameHint));
62 |
63 | if (targetSyntaxRef == null) {
64 | throw new McpException($"File hint '{fileNameHint}' did not match any declaring syntax reference for symbol '{fullyQualifiedTargetName}'.");
65 | }
66 | } else {
67 | targetSyntaxRef = targetSymbol.DeclaringSyntaxReferences.FirstOrDefault();
68 | }
69 | if (targetSyntaxRef == null) {
70 | throw new McpException($"Could not find a suitable syntax reference for symbol '{fullyQualifiedTargetName}'.");
71 | }
72 |
73 | if (solutionManager.CurrentSolution == null) {
74 | throw new McpException("Current solution is unexpectedly null after validation checks.");
75 | }
76 |
77 | var syntaxNode = await targetSyntaxRef.GetSyntaxAsync(cancellationToken);
78 | var document = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(solutionManager.CurrentSolution, syntaxNode);
79 |
80 | if (targetSymbol is not INamedTypeSymbol typeSymbol) {
81 | throw new McpException($"Target '{fullyQualifiedTargetName}' is not a type, cannot add member.");
82 | }
83 |
84 | // Parse the code snippet
85 | MemberDeclarationSyntax? memberSyntax;
86 | try {
87 | memberSyntax = SyntaxFactory.ParseMemberDeclaration(codeSnippet);
88 | if (memberSyntax == null) {
89 | throw new McpException("Failed to parse code snippet as a valid member declaration.");
90 | }
91 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
92 | logger.LogError(ex, "Failed to parse code snippet as member declaration");
93 | throw new McpException($"Invalid C# syntax in code snippet: {ex.Message}");
94 | }
95 |
96 | // Verify the member name doesn't already exist in the type
97 | string memberName = GetMemberName(memberSyntax);
98 | logger.LogInformation("Adding member with name: {MemberName}", memberName);
99 |
100 | // Check for duplicate members
101 | if (!IsMemberAllowed(typeSymbol, memberSyntax, memberName, cancellationToken)) {
102 | throw new McpException($"A member with the name '{memberName}' already exists in '{fullyQualifiedTargetName}'" +
103 | (memberSyntax is MethodDeclarationSyntax ? " with the same parameter signature." : "."));
104 | }
105 |
106 | try {
107 | // Use the lineNumberHint parameter when calling AddMemberAsync
108 | var newSolution = await modificationService.AddMemberAsync(document.Id, typeSymbol, memberSyntax, lineNumberHint, cancellationToken);
109 |
110 | string finalCommitMessage = $"Add {memberName} to {typeSymbol.Name}: " + commitMessage;
111 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
112 |
113 | // Check for compilation errors after adding the code
114 | var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
115 | if (updatedDocument is null) {
116 | logger.LogError("Updated document for {TargetName} is null after applying changes", fullyQualifiedTargetName);
117 | throw new McpException($"Failed to retrieve updated document for {fullyQualifiedTargetName} after applying changes.");
118 | }
119 | var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
120 | solutionManager, updatedDocument, logger, cancellationToken);
121 |
122 | // Perform complexity and similarity analysis on the added member
123 | string analysisResults = string.Empty;
124 |
125 | // Get the updated type symbol to find the newly added member
126 | var updatedSemanticModel = await updatedDocument.GetSemanticModelAsync(cancellationToken);
127 | if (updatedSemanticModel != null) {
128 | // Find the type symbol in the updated document by FQN instead of using old syntax reference
129 | var updatedTypeSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedTargetName, cancellationToken) as INamedTypeSymbol;
130 |
131 | if (updatedTypeSymbol != null) {
132 | // Find the added member by name
133 | var addedSymbol = updatedTypeSymbol.GetMembers(memberName).FirstOrDefault();
134 | if (addedSymbol != null) {
135 | analysisResults = await MemberAnalysisHelper.AnalyzeAddedMemberAsync(
136 | addedSymbol, complexityAnalysisService, semanticSimilarityService, logger, cancellationToken);
137 | }
138 | }
139 | }
140 |
141 | string baseMessage = $"Successfully added member to {fullyQualifiedTargetName} in {document.FilePath ?? "unknown file"}.\n\n" +
142 | ((!hasErrors) ? "<errorCheck>No compilation issues detected.</errorCheck>" :
143 | ($"{errorMessages}\n" + //Code added is not necessary in Copilot, as it can see the invocation $"\nCode added:\n{codeSnippet}\n\n" +
144 | $"If you choose to fix these issues, you must use {ToolHelpers.SharpToolPrefix + nameof(OverwriteMember)} to replace the member with a new definition."));
145 |
146 | return string.IsNullOrWhiteSpace(analysisResults) ? baseMessage : $"{baseMessage}\n\n{analysisResults}";
147 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
148 | logger.LogError(ex, "Failed to add member to {TypeName}", fullyQualifiedTargetName);
149 | throw new McpException($"Failed to add member to {fullyQualifiedTargetName}: {ex.Message}");
150 | }
151 | }, logger, nameof(AddMember), cancellationToken);
152 | }
153 | private static string GetMemberName(MemberDeclarationSyntax memberSyntax) {
154 | return memberSyntax switch {
155 | MethodDeclarationSyntax method => method.Identifier.Text,
156 | ConstructorDeclarationSyntax ctor => ctor.Identifier.Text,
157 | DestructorDeclarationSyntax dtor => dtor.Identifier.Text,
158 | OperatorDeclarationSyntax op => op.OperatorToken.Text,
159 | ConversionOperatorDeclarationSyntax conv => conv.Type.ToString(),
160 | PropertyDeclarationSyntax property => property.Identifier.Text,
161 | FieldDeclarationSyntax field => field.Declaration.Variables.First().Identifier.Text,
162 | EnumDeclarationSyntax enumDecl => enumDecl.Identifier.Text,
163 | TypeDeclarationSyntax type => type.Identifier.Text,
164 | DelegateDeclarationSyntax del => del.Identifier.Text,
165 | EventDeclarationSyntax evt => evt.Identifier.Text,
166 | EventFieldDeclarationSyntax evtField => evtField.Declaration.Variables.First().Identifier.Text,
167 | IndexerDeclarationSyntax indexer => "this[]", // Indexers don't have names but use the 'this' keyword
168 | _ => throw new NotSupportedException($"Unsupported member type: {memberSyntax.GetType().Name}")
169 | };
170 | }
171 | // Helper method to check if the member is allowed to be added
172 | private static bool IsMemberAllowed(INamedTypeSymbol typeSymbol, MemberDeclarationSyntax newMember, string memberName, CancellationToken cancellationToken) {
173 | // Special handling for method overloads
174 | if (newMember is MethodDeclarationSyntax newMethod) {
175 | // Get all existing methods with the same name
176 | var existingMethods = typeSymbol.GetMembers(memberName)
177 | .OfType<IMethodSymbol>()
178 | .Where(m => !m.IsImplicitlyDeclared && m.MethodKind == MethodKind.Ordinary)
179 | .ToList();
180 |
181 | if (!existingMethods.Any()) {
182 | return true; // No method with the same name exists
183 | }
184 |
185 | // Convert parameters of the new method to comparable format
186 | var newMethodParams = newMethod.ParameterList.Parameters
187 | .Select(p => new {
188 | Type = p.Type?.ToString() ?? "unknown",
189 | IsRef = p.Modifiers.Any(m => m.IsKind(SyntaxKind.RefKeyword)),
190 | IsOut = p.Modifiers.Any(m => m.IsKind(SyntaxKind.OutKeyword))
191 | })
192 | .ToList();
193 |
194 | // Check if any existing method has the same parameter signature
195 | foreach (var existingMethod in existingMethods) {
196 | if (existingMethod.Parameters.Length != newMethodParams.Count) {
197 | continue; // Different parameter count, not a duplicate
198 | }
199 |
200 | bool signatureMatches = true;
201 | for (int i = 0; i < existingMethod.Parameters.Length; i++) {
202 | var existingParam = existingMethod.Parameters[i];
203 | var newParam = newMethodParams[i];
204 |
205 | // Compare parameter types and ref/out modifiers
206 | if (existingParam.Type.ToDisplayString() != newParam.Type ||
207 | existingParam.RefKind == RefKind.Ref != newParam.IsRef ||
208 | existingParam.RefKind == RefKind.Out != newParam.IsOut) {
209 | signatureMatches = false;
210 | break;
211 | }
212 | }
213 |
214 | if (signatureMatches) {
215 | return false; // Found a method with the same signature
216 | }
217 | }
218 |
219 | return true; // No matching signature found
220 | } else {
221 | // For non-method members, simply check if a member with the same name exists
222 | return !typeSymbol.GetMembers(memberName).Any(m => !m.IsImplicitlyDeclared);
223 | }
224 | }
225 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(OverwriteMember), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
226 | [Description("Replaces the definition of an existing member or type with new C# code, or deletes it. Code is parsed and formatted. Code can contain multiple new members, update the existing member, and/or replace it with a new one.")]
227 | public static async Task<string> OverwriteMember(
228 | ISolutionManager solutionManager,
229 | ICodeModificationService modificationService,
230 | ILogger<ModificationToolsLogCategory> logger,
231 | [Description("FQN of the member or type to rewrite.")] string fullyQualifiedMemberName,
232 | [Description("The new C# code for the member or type. *If this member has attributes or XML documentation, they MUST be included here.* To Delete the target instead, set this to `// Delete {memberName}`.")] string newMemberCode,
233 | string commitMessage,
234 | CancellationToken cancellationToken = default) {
235 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
236 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger);
237 | ErrorHandlingHelpers.ValidateStringParameter(newMemberCode, nameof(newMemberCode), logger);
238 | newMemberCode = newMemberCode.TrimBackslash();
239 |
240 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteMember));
241 | logger.LogInformation("Executing '{OverwriteMember}' for: {SymbolName}", nameof(OverwriteMember), fullyQualifiedMemberName);
242 |
243 | var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken);
244 |
245 | if (!symbol.DeclaringSyntaxReferences.Any()) {
246 | throw new McpException($"Symbol '{fullyQualifiedMemberName}' has no declaring syntax references.");
247 | }
248 |
249 | var syntaxRef = symbol.DeclaringSyntaxReferences.First();
250 | var oldNode = await syntaxRef.GetSyntaxAsync(cancellationToken);
251 |
252 | if (solutionManager.CurrentSolution is null) {
253 | throw new McpException("Current solution is unexpectedly null after validation checks.");
254 | }
255 |
256 | var document = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(solutionManager.CurrentSolution, oldNode);
257 |
258 | if (oldNode is not MemberDeclarationSyntax && oldNode is not TypeDeclarationSyntax) {
259 | throw new McpException($"Symbol '{fullyQualifiedMemberName}' does not represent a replaceable member or type.");
260 | }
261 |
262 | // Get a simple name for the symbol for the commit message
263 | string symbolName = symbol.Name;
264 |
265 | bool isDelete = newMemberCode.StartsWith("// Delete", StringComparison.OrdinalIgnoreCase);
266 | string finalCommitMessage = (isDelete ? $"Delete {symbolName}" : $"Update {symbolName}") + ": " + commitMessage;
267 | if (isDelete) {
268 | var commentTrivia = SyntaxFactory.Comment(newMemberCode);
269 | var emptyNode = SyntaxFactory.EmptyStatement()
270 | .WithLeadingTrivia(commentTrivia)
271 | .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
272 |
273 | try {
274 | var newSolution = await modificationService.ReplaceNodeAsync(document.Id, oldNode, emptyNode, cancellationToken);
275 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
276 |
277 | var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
278 | if (updatedDocument != null) {
279 | var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
280 | solutionManager, updatedDocument, logger, cancellationToken);
281 | if (!hasErrors)
282 | errorMessages = "<errorCheck>No compilation issues detected.</errorCheck>";
283 |
284 | return $"Successfully deleted symbol {fullyQualifiedMemberName}.\n\n{errorMessages}";
285 | }
286 | return $"Successfully deleted symbol {fullyQualifiedMemberName}";
287 | } catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
288 | logger.LogError(ex, "Failed to delete symbol {SymbolName}", fullyQualifiedMemberName);
289 | throw new McpException($"Failed to delete symbol {fullyQualifiedMemberName}: {ex.Message}");
290 | }
291 | }
292 |
293 | SyntaxNode? newNode;
294 | try {
295 | var parsedCode = SyntaxFactory.ParseCompilationUnit(newMemberCode);
296 | newNode = parsedCode.Members.FirstOrDefault();
297 |
298 | if (newNode is null) {
299 | throw new McpException("Failed to parse new code as a valid member or type declaration. The parsed result was empty.");
300 | }
301 |
302 | // Validate that the parsed node is of an expected type if the original was a TypeDeclaration
303 | if (oldNode is TypeDeclarationSyntax && newNode is not TypeDeclarationSyntax) {
304 | throw new McpException($"The new code for '{fullyQualifiedMemberName}' was parsed as a {newNode.Kind()}, but a TypeDeclaration was expected to replace the existing TypeDeclaration.");
305 | }
306 | // Validate that the parsed node is of an expected type if the original was a MemberDeclaration (but not a TypeDeclaration, which is a subtype)
307 | else if (oldNode is MemberDeclarationSyntax && oldNode is not TypeDeclarationSyntax && newNode is not MemberDeclarationSyntax) {
308 | throw new McpException($"The new code for '{fullyQualifiedMemberName}' was parsed as a {newNode.Kind()}, but a MemberDeclaration was expected to replace the existing MemberDeclaration.");
309 | }
310 |
311 | } catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
312 | logger.LogError(ex, "Failed to parse replacement code for {SymbolName}", fullyQualifiedMemberName);
313 | throw new McpException($"Invalid C# syntax in replacement code: {ex.Message}");
314 | }
315 |
316 | if (newNode is null) { // Should be caught by earlier checks, but as a safeguard.
317 | throw new McpException("Critical error: Failed to parse new code and newNode is null.");
318 | }
319 |
320 | try {
321 | var newSolution = await modificationService.ReplaceNodeAsync(document.Id, oldNode, newNode, cancellationToken);
322 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
323 |
324 | if (solutionManager.CurrentSolution is null) {
325 | throw new McpException("Current solution is unexpectedly null after applying changes.");
326 | }
327 |
328 | // Generate diff using the centralized ContextInjectors
329 | var diffResult = ContextInjectors.CreateCodeDiff(oldNode.ToFullString(), newNode.ToFullString());
330 |
331 | var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
332 | if (updatedDocument is null) {
333 | logger.LogError("Updated document for {SymbolName} is null after applying changes", fullyQualifiedMemberName);
334 | throw new McpException($"Failed to retrieve updated document for {fullyQualifiedMemberName} after applying changes.");
335 | }
336 |
337 | var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
338 | solutionManager, updatedDocument, logger, cancellationToken);
339 | if (!hasErrors)
340 | errorMessages = "<errorCheck>No compilation issues detected.</errorCheck>";
341 |
342 | return $"Successfully replaced symbol {fullyQualifiedMemberName}.\n\n{diffResult}\n\n{errorMessages}";
343 |
344 |
345 | } catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
346 | logger.LogError(ex, "Failed to replace symbol {SymbolName}", fullyQualifiedMemberName);
347 | throw new McpException($"Failed to replace symbol {fullyQualifiedMemberName}: {ex.Message}");
348 | }
349 | }, logger, nameof(OverwriteMember), cancellationToken);
350 | }
351 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(RenameSymbol), Idempotent = true, Destructive = true, OpenWorld = false, ReadOnly = false),
352 | Description("Renames a symbol (variable, method, property, type) and updates all references. Changes are formatted.")]
353 | public static async Task<string> RenameSymbol(
354 | ISolutionManager solutionManager,
355 | ICodeModificationService modificationService,
356 | ILogger<ModificationToolsLogCategory> logger,
357 | [Description("FQN of the symbol to rename.")] string fullyQualifiedSymbolName,
358 | [Description("The new name for the symbol.")] string newName,
359 | string commitMessage,
360 | CancellationToken cancellationToken = default) {
361 |
362 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
363 | // Validate parameters
364 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
365 | ErrorHandlingHelpers.ValidateStringParameter(newName, "newName", logger);
366 |
367 | // Validate that the new name is a valid C# identifier
368 | if (!IsValidCSharpIdentifier(newName)) {
369 | throw new McpException($"'{newName}' is not a valid C# identifier for renaming.");
370 | }
371 |
372 | // Ensure solution is loaded
373 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(RenameSymbol));
374 | logger.LogInformation("Executing '{RenameSymbol}' for {SymbolName} to {NewName}", nameof(RenameSymbol), fullyQualifiedSymbolName, newName);
375 |
376 | // Get the symbol to rename
377 | var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
378 |
379 | // Check if symbol is renamable
380 | if (symbol.IsImplicitlyDeclared) {
381 | throw new McpException($"Cannot rename implicitly declared symbol '{fullyQualifiedSymbolName}'.");
382 | }
383 |
384 | string finalCommitMessage = $"Rename {symbol.Name} to {newName}: " + commitMessage;
385 |
386 | try {
387 | // Perform the rename operation
388 | var newSolution = await modificationService.RenameSymbolAsync(symbol, newName, cancellationToken);
389 |
390 | // Check if the operation actually made changes
391 | var changeset = newSolution.GetChanges(solutionManager.CurrentSolution!);
392 | var changedDocumentCount = changeset.GetProjectChanges().Sum(p => p.GetChangedDocuments().Count());
393 |
394 | if (changedDocumentCount == 0) {
395 | logger.LogWarning("Rename operation for {SymbolName} to {NewName} produced no changes",
396 | fullyQualifiedSymbolName, newName);
397 | }
398 |
399 | // Apply the changes with the commit message
400 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
401 |
402 | // Check for compilation errors after renaming the symbol using centralized ContextInjectors
403 | // Get the first few affected documents to check
404 | var affectedDocumentIds = changeset.GetProjectChanges()
405 | .SelectMany(pc => pc.GetChangedDocuments())
406 | .Take(5) // Limit to first 5 documents to avoid excessive checking
407 | .ToList();
408 |
409 | StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
410 |
411 | // Check each affected document for compilation errors
412 | foreach (var docId in affectedDocumentIds) {
413 | if (solutionManager.CurrentSolution != null) {
414 | var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
415 | if (updatedDoc != null) {
416 | var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
417 | solutionManager, updatedDoc, logger, cancellationToken);
418 |
419 | if (docHasErrors) {
420 | errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
421 | errorBuilder.AppendLine(docErrorMessages);
422 | errorBuilder.AppendLine();
423 | } else {
424 | errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
425 | }
426 | }
427 | }
428 | }
429 | errorBuilder.AppendLine("</errorCheck>");
430 |
431 | return $"Symbol '{symbol.Name}' (originally '{fullyQualifiedSymbolName}') successfully renamed to '{newName}' and references updated in {changedDocumentCount} documents.\n\n{errorBuilder}";
432 |
433 | } catch (InvalidOperationException ex) {
434 | logger.LogError(ex, "Invalid rename operation for {SymbolName} to {NewName}", fullyQualifiedSymbolName, newName);
435 | throw new McpException($"Cannot rename symbol '{fullyQualifiedSymbolName}' to '{newName}': {ex.Message}");
436 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
437 | logger.LogError(ex, "Failed to rename symbol {SymbolName} to {NewName}", fullyQualifiedSymbolName, newName);
438 | throw new McpException($"Failed to rename symbol '{fullyQualifiedSymbolName}' to '{newName}': {ex.Message}");
439 | }
440 | }, logger, nameof(RenameSymbol), cancellationToken);
441 | }
442 | // Helper method to check if a string is a valid C# identifier
443 | private static bool IsValidCSharpIdentifier(string name) {
444 | return SyntaxFacts.IsValidIdentifier(name);
445 | }
446 |
447 | //Disabled for now
448 | //[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ReplaceAllReferences), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
449 | [Description("Surgically replaces all references to a symbol with new C# code across the solution. Perfect for systematic API upgrades - e.g., replacing all Console.WriteLine() calls with Logger.Info(). Use filename filters (*.cs, Controller*.cs) to scope changes to specific files.")]
450 | public static async Task<string> ReplaceAllReferences(
451 | ISolutionManager solutionManager,
452 | ICodeModificationService modificationService,
453 | ILogger<ModificationToolsLogCategory> logger,
454 | [Description("FQN of the symbol whose references should be replaced.")] string fullyQualifiedSymbolName,
455 | [Description("The C# code replace references with.")] string replacementCode,
456 | [Description("Only replace symbols in files with this pattern. Supports globbing (`*`).")] string filenameFilter,
457 | string commitMessage,
458 | CancellationToken cancellationToken = default) {
459 |
460 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
461 | // Validate parameters
462 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
463 | ErrorHandlingHelpers.ValidateStringParameter(replacementCode, "replacementCode", logger);
464 | replacementCode = replacementCode.TrimBackslash();
465 |
466 | // Note: filenameFilter can be empty or null, as this indicates "replace in all files"
467 |
468 | // Ensure solution is loaded
469 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReplaceAllReferences));
470 | logger.LogInformation("Executing '{ReplaceAllReferences}' for {SymbolName} with text '{ReplacementCode}', filter: {Filter}",
471 | nameof(ReplaceAllReferences), fullyQualifiedSymbolName, replacementCode, filenameFilter ?? "none");
472 |
473 | // Get the symbol whose references will be replaced
474 | var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
475 |
476 | // Create a shortened version of the replacement code for the commit message
477 | string shortReplacementCode = replacementCode.Length > 30
478 | ? replacementCode.Substring(0, 30) + "..."
479 | : replacementCode;
480 |
481 | string finalCommitMessage = $"Replace references to {symbol.Name} with {shortReplacementCode}: " + commitMessage;
482 |
483 | // Validate that the replacement code can be parsed as a valid C# expression
484 | try {
485 | var expressionSyntax = SyntaxFactory.ParseExpression(replacementCode);
486 | if (expressionSyntax == null) {
487 | logger.LogWarning("Replacement code '{ReplacementCode}' may not be a valid C# expression", replacementCode);
488 | }
489 | } catch (Exception ex) {
490 | logger.LogWarning(ex, "Replacement code '{ReplacementCode}' could not be parsed as a C# expression", replacementCode);
491 | // We don't throw here, because some valid replacements might not be valid expressions on their own
492 | }
493 |
494 | // Create a predicate filter if a filename filter is provided
495 | Func<SyntaxNode, bool>? predicateFilter = null;
496 | if (!string.IsNullOrEmpty(filenameFilter)) {
497 | Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
498 | string normalizedFilter = filenameFilter.Replace('\\', '/');
499 | matcher.AddInclude(normalizedFilter);
500 |
501 | string root = Path.GetPathRoot(solutionManager.CurrentSolution?.FilePath) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
502 | try {
503 | predicateFilter = node => {
504 | try {
505 | var location = node.GetLocation();
506 | if (location == null || string.IsNullOrWhiteSpace(location.SourceTree?.FilePath)) {
507 | return false;
508 | }
509 | string filePath = location.SourceTree.FilePath;
510 | return matcher.Match(root, filePath).HasMatches;
511 | } catch (Exception ex) {
512 | logger.LogWarning(ex, "Error applying filename filter to node");
513 | return false; // Skip nodes that cause errors in the filter
514 | }
515 | };
516 | } catch (Exception ex) {
517 | logger.LogError(ex, "Failed to create filename filter '{Filter}'", filenameFilter);
518 | throw new McpException($"Failed to create filename filter '{filenameFilter}': {ex.Message}");
519 | }
520 | }
521 |
522 | try {
523 | // Replace all references to the symbol with the new code
524 | var newSolution = await modificationService.ReplaceAllReferencesAsync(
525 | symbol, replacementCode, cancellationToken, predicateFilter);
526 |
527 | // Count changes before applying them
528 | if (solutionManager.CurrentSolution == null) {
529 | throw new McpException("Current solution is null after replacement operation.");
530 | }
531 |
532 | var originalSolution = solutionManager.CurrentSolution;
533 | var solutionChanges = newSolution.GetChanges(originalSolution);
534 | var changedDocumentsCount = solutionChanges.GetProjectChanges()
535 | .SelectMany(pc => pc.GetChangedDocuments())
536 | .Count();
537 |
538 | if (changedDocumentsCount == 0) {
539 | logger.LogWarning("No documents were changed when replacing references to '{SymbolName}'",
540 | fullyQualifiedSymbolName);
541 |
542 | // We can't directly check for references without applying changes
543 | // Just give a general message about no changes being made
544 |
545 | if (!string.IsNullOrEmpty(filenameFilter)) {
546 | // If the filter is limiting results
547 | return $"No references to '{symbol.Name}' found in files matching '{filenameFilter}'. No changes were made.";
548 | } else {
549 | // General message about no changes
550 | return $"References to '{symbol.Name}' were found but no changes were made. The replacement code might be identical to the original.";
551 | }
552 | }
553 |
554 | // Apply the changes
555 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
556 |
557 | // Check for compilation errors in changed documents
558 | var changedDocIds = solutionChanges.GetProjectChanges()
559 | .SelectMany(pc => pc.GetChangedDocuments())
560 | .Take(5) // Limit to first 5 documents to avoid excessive checking
561 | .ToList();
562 |
563 | StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
564 |
565 | // Check each affected document for compilation errors
566 | foreach (var docId in changedDocIds) {
567 | if (solutionManager.CurrentSolution != null) {
568 | var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
569 | if (updatedDoc != null) {
570 | var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
571 | solutionManager, updatedDoc, logger, cancellationToken);
572 |
573 | if (docHasErrors) {
574 | errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
575 | errorBuilder.AppendLine(docErrorMessages);
576 | errorBuilder.AppendLine();
577 | } else {
578 | errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
579 | }
580 | }
581 | }
582 | }
583 | errorBuilder.AppendLine("</errorCheck>");
584 |
585 | var filterMessage = string.IsNullOrEmpty(filenameFilter) ? "" : $" (with filter '{filenameFilter}')";
586 |
587 | return $"Successfully replaced references to '{symbol.Name}'{filterMessage} with '{replacementCode}' in {changedDocumentsCount} document(s).\n\n{errorBuilder}";
588 |
589 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
590 | logger.LogError(ex, "Failed to replace references to symbol '{SymbolName}' with '{ReplacementCode}'",
591 | fullyQualifiedSymbolName, replacementCode);
592 | throw new McpException($"Failed to replace references: {ex.Message}");
593 | }
594 | }, logger, nameof(ReplaceAllReferences), cancellationToken);
595 | }
596 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(Undo), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
597 | [Description($"Reverts the last applied change to the solution. You can undo all consecutive changes you have made. Returns a diff of the change that was undone.")]
598 | public static async Task<string> Undo(
599 | ISolutionManager solutionManager,
600 | ICodeModificationService modificationService,
601 | ILogger<ModificationToolsLogCategory> logger,
602 | CancellationToken cancellationToken) {
603 |
604 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
605 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(Undo));
606 | logger.LogInformation("Executing '{UndoLastChange}'", nameof(Undo));
607 |
608 | var (success, message) = await modificationService.UndoLastChangeAsync(cancellationToken);
609 | if (!success) {
610 | logger.LogWarning("Undo operation failed: {Message}", message);
611 | throw new McpException($"Failed to undo the last change. {message}");
612 | }
613 | logger.LogInformation("Undo operation succeeded");
614 | return message;
615 |
616 | }, logger, nameof(Undo), cancellationToken);
617 | }
618 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(FindAndReplace), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
619 | [Description("Every developer's favorite. Use this for all small edits (code tweaks, usings, namespaces, interface implementations, attributes, etc.) instead of rewriting large members or types.")]
620 | public static async Task<string> FindAndReplace(
621 | ISolutionManager solutionManager,
622 | ICodeModificationService modificationService,
623 | IDocumentOperationsService documentOperations,
624 | ILogger<ModificationToolsLogCategory> logger,
625 | [Description("Regex operating in multiline mode, so `^` and `$` match per line. Always use `\\s*` at the beginnings of lines for unknown indentation. Make sure to escape your escapes for json.")] string regexPattern,
626 | [Description("Replacement text, which can include regex groups ($1, ${name}, etc.)")] string replacementText,
627 | [Description("Target, which can be either a FQN (replaces text within a declaration) or a filepath supporting globbing (`*`) (replaces all instances across files)")] string target,
628 | string commitMessage,
629 | CancellationToken cancellationToken = default) {
630 |
631 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
632 | // Validate parameters
633 | ErrorHandlingHelpers.ValidateStringParameter(regexPattern, "regexPattern", logger);
634 | ErrorHandlingHelpers.ValidateStringParameter(target, "targetString", logger);
635 |
636 | //normalize newlines in pattern
637 | regexPattern = regexPattern
638 | .Replace("\r\n", "\n")
639 | .Replace("\r", "\n")
640 | .Replace("\n", @"\n")
641 | .Replace(@"\r\n", @"\n")
642 | .Replace(@"\r", @"\n");
643 |
644 | // Ensure solution is loaded
645 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindAndReplace));
646 | logger.LogInformation("Executing '{FindAndReplace}' with pattern: '{Pattern}', replacement: {Replacement}, target: {Target}",
647 | nameof(FindAndReplace), regexPattern, replacementText, target);
648 |
649 | // Validate the regex pattern
650 | try {
651 | // Create the regex with multiline option to test it
652 | _ = new Regex(regexPattern, RegexOptions.Multiline);
653 | } catch (ArgumentException ex) {
654 | throw new McpException($"Invalid regular expression pattern: {ex.Message}");
655 | }
656 |
657 | try {
658 | // Get the original solution for later comparison
659 | var originalSolution = solutionManager.CurrentSolution ?? throw new McpException("Current solution is null before find and replace operation.");
660 |
661 | // Track all modified files for both code and non-code files
662 | var modifiedFiles = new List<string>();
663 | var changedDocuments = new List<DocumentId>();
664 | var nonCodeFilesModified = new List<string>();
665 | var nonCodeDiffs = new Dictionary<string, string>();
666 |
667 | // First, check if the target is a file path pattern (contains wildcard or is a direct file path)
668 | if (target.Contains("*") || target.Contains("?") || (File.Exists(target) && !documentOperations.IsCodeFile(target))) {
669 | logger.LogInformation("Target appears to be a file path pattern or non-code file: {Target}", target);
670 |
671 | // Normalize the target path to use forward slashes consistently
672 | string normalizedTarget = target.Replace('\\', '/');
673 |
674 | // Create matcher for the pattern
675 | Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
676 | matcher.AddInclude(normalizedTarget);
677 |
678 | string root = Path.GetPathRoot(originalSolution.FilePath) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
679 |
680 | // Get all files in solution directory matching the pattern
681 | var solutionDirectory = Path.GetDirectoryName(originalSolution.FilePath);
682 | if (string.IsNullOrEmpty(solutionDirectory)) {
683 | throw new McpException("Could not determine solution directory");
684 | }
685 |
686 | // Handle direct file path (no wildcards)
687 | if (!target.Contains("*") && !target.Contains("?") && File.Exists(target)) {
688 | // Direct file path, process just this file
689 | var pathInfo = documentOperations.GetPathInfo(target);
690 |
691 | if (!pathInfo.IsWithinSolutionDirectory) {
692 | throw new McpException($"File {target} exists but is outside the solution directory. Cannot modify for safety reasons.");
693 | }
694 |
695 | // Check if it's a non-code file
696 | if (!documentOperations.IsCodeFile(target)) {
697 | var (changed, diff) = await ProcessNonCodeFile(target, regexPattern, replacementText, documentOperations, nonCodeFilesModified, cancellationToken);
698 | if (changed) {
699 | nonCodeDiffs.Add(target, diff);
700 | }
701 | }
702 | } else {
703 | // Use glob pattern to find matching files
704 | DirectoryInfo dirInfo = new DirectoryInfo(solutionDirectory);
705 | List<FileInfo> allFiles = dirInfo.GetFiles("*.*", SearchOption.AllDirectories).ToList();
706 | string rootDir = Path.GetPathRoot(solutionDirectory) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
707 | foreach (var file in allFiles) {
708 | if (matcher.Match(rootDir, file.FullName).HasMatches) {
709 | var pathInfo = documentOperations.GetPathInfo(file.FullName);
710 |
711 | // Skip files in unsafe directories or outside solution
712 | if (!pathInfo.IsWithinSolutionDirectory || !string.IsNullOrEmpty(pathInfo.WriteRestrictionReason)) {
713 | logger.LogWarning("Skipping file due to restrictions: {FilePath}, Reason: {Reason}",
714 | file.FullName, pathInfo.WriteRestrictionReason ?? "Outside solution directory");
715 | continue;
716 | }
717 |
718 | // Process non-code files directly
719 | if (!documentOperations.IsCodeFile(file.FullName)) {
720 | var (changed, diff) = await ProcessNonCodeFile(file.FullName, regexPattern, replacementText, documentOperations, nonCodeFilesModified, cancellationToken);
721 | if (changed) {
722 | nonCodeDiffs.Add(file.FullName, diff);
723 | }
724 | }
725 | }
726 | }
727 | }
728 | }
729 |
730 | // Now process code files through the Roslyn workspace
731 | var newSolution = await modificationService.FindAndReplaceAsync(
732 | target, regexPattern, replacementText, cancellationToken, RegexOptions.Multiline);
733 |
734 | // Get changed code documents
735 | var solutionChanges = newSolution.GetChanges(originalSolution);
736 | foreach (var projectChange in solutionChanges.GetProjectChanges()) {
737 | changedDocuments.AddRange(projectChange.GetChangedDocuments());
738 | }
739 |
740 | // If no code or non-code files were modified, return early
741 | if (changedDocuments.Count == 0 && nonCodeFilesModified.Count == 0) {
742 | logger.LogWarning("No documents were changed during find and replace operation");
743 | throw new McpException($"No matches found for pattern '{regexPattern}' in target '{target}', or matches were found but replacement produced identical text. No changes were made.");
744 | }
745 |
746 | // Add code document file paths to the modifiedFiles list
747 | foreach (var docId in changedDocuments) {
748 | var document = originalSolution.GetDocument(docId);
749 | if (document?.FilePath != null) {
750 | modifiedFiles.Add(document.FilePath);
751 | }
752 | }
753 |
754 | // Add non-code files to the list
755 | modifiedFiles.AddRange(nonCodeFilesModified);
756 |
757 | string finalCommitMessage = $"Find and replace on {target}: {commitMessage}";
758 |
759 | // Apply the changes to code files
760 | if (changedDocuments.Count > 0) {
761 | await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage, nonCodeFilesModified);
762 | }
763 |
764 | // Commit non-code files (if we only modified non-code files)
765 | if (nonCodeFilesModified.Count > 0 && changedDocuments.Count == 0) {
766 | // Get solution path
767 | var solutionPath = originalSolution.FilePath;
768 | if (string.IsNullOrEmpty(solutionPath)) {
769 | logger.LogDebug("Solution path is not available, skipping Git operations for non-code files");
770 | } else {
771 | await documentOperations.ProcessGitOperationsAsync(nonCodeFilesModified, cancellationToken, finalCommitMessage);
772 | }
773 | }
774 |
775 | // Check for compilation errors in changed code documents
776 | var changedDocIds = changedDocuments.Take(5).ToList(); // Limit to first 5 documents
777 | StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
778 |
779 | // Check each affected document for compilation errors
780 | foreach (var docId in changedDocIds) {
781 | if (solutionManager.CurrentSolution != null) {
782 | var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
783 | if (updatedDoc != null) {
784 | var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
785 | solutionManager, updatedDoc, logger, cancellationToken);
786 |
787 | if (docHasErrors) {
788 | errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
789 | errorBuilder.AppendLine(docErrorMessages);
790 | errorBuilder.AppendLine();
791 | } else {
792 | errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
793 | }
794 | }
795 | }
796 | }
797 | errorBuilder.AppendLine("</errorCheck>");
798 |
799 | // Generate multi-document diff for code files
800 | string diffOutput = changedDocuments.Count > 0
801 | ? await ContextInjectors.CreateMultiDocumentDiff(
802 | originalSolution,
803 | newSolution,
804 | changedDocuments,
805 | 5,
806 | cancellationToken)
807 | : "";
808 |
809 | // For non-code files, build a similar diff output format
810 | StringBuilder nonCodeDiffBuilder = new StringBuilder();
811 | if (nonCodeDiffs.Count > 0) {
812 | nonCodeDiffBuilder.AppendLine();
813 | nonCodeDiffBuilder.AppendLine("Non-code file changes:");
814 |
815 | int nonCodeFileCount = 0;
816 | foreach (var diffEntry in nonCodeDiffs) {
817 | if (nonCodeFileCount >= 5) {
818 | nonCodeDiffBuilder.AppendLine($"...and {nonCodeDiffs.Count - 5} more non-code files");
819 | break;
820 | }
821 | nonCodeDiffBuilder.AppendLine();
822 | nonCodeDiffBuilder.AppendLine($"Document: {diffEntry.Key}");
823 | nonCodeDiffBuilder.AppendLine(diffEntry.Value);
824 | nonCodeFileCount++;
825 | }
826 | }
827 |
828 | return $"Successfully replaced pattern '{regexPattern}' with '{replacementText}' in {modifiedFiles.Count} file(s).\n\n{errorBuilder}\n\n{diffOutput}{nonCodeDiffBuilder}";
829 |
830 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
831 | logger.LogError(ex, "Failed to replace pattern '{Pattern}' with '{Replacement}' in '{Target}'",
832 | regexPattern, replacementText, target);
833 | throw new McpException($"Failed to perform find and replace: {ex.Message}");
834 | }
835 | }, logger, nameof(FindAndReplace), cancellationToken);
836 | }
837 | [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(MoveMember), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
838 | [Description("Moves a member (property, field, method, nested type, etc.) from one type/namespace to another. The member is removed from the source location and added to the destination.")]
839 | public static async Task<string> MoveMember(
840 | ISolutionManager solutionManager,
841 | ICodeModificationService modificationService,
842 | ILogger<ModificationToolsLogCategory> logger,
843 | [Description("FQN of the member to move.")] string fullyQualifiedMemberName,
844 | [Description("FQN of the destination type or namespace where the member should be moved.")] string fullyQualifiedDestinationTypeOrNamespaceName,
845 | string commitMessage,
846 | CancellationToken cancellationToken = default) {
847 | return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
848 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger);
849 | ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedDestinationTypeOrNamespaceName, nameof(fullyQualifiedDestinationTypeOrNamespaceName), logger);
850 |
851 | ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(MoveMember));
852 | logger.LogInformation("Executing '{MoveMember}' moving {MemberName} to {DestinationName}",
853 | nameof(MoveMember), fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName);
854 |
855 | if (solutionManager.CurrentSolution == null) {
856 | throw new McpException("Current solution is unexpectedly null after validation checks.");
857 | }
858 | Solution currentSolution = solutionManager.CurrentSolution;
859 |
860 | var sourceMemberSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken);
861 |
862 | if (sourceMemberSymbol is not (IFieldSymbol or IPropertySymbol or IMethodSymbol or IEventSymbol or INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct or TypeKind.Interface or TypeKind.Enum or TypeKind.Delegate })) {
863 | throw new McpException($"Symbol '{fullyQualifiedMemberName}' is not a movable member type. Only fields, properties, methods, events, and nested types can be moved.");
864 | }
865 |
866 | var destinationSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedDestinationTypeOrNamespaceName, cancellationToken);
867 |
868 | if (destinationSymbol is not (INamedTypeSymbol or INamespaceSymbol)) {
869 | throw new McpException($"Destination '{fullyQualifiedDestinationTypeOrNamespaceName}' must be a type or namespace.");
870 | }
871 |
872 | var sourceSyntaxRef = sourceMemberSymbol.DeclaringSyntaxReferences.FirstOrDefault();
873 | if (sourceSyntaxRef == null) {
874 | throw new McpException($"Could not find syntax reference for member '{fullyQualifiedMemberName}'.");
875 | }
876 |
877 | var sourceMemberNode = await sourceSyntaxRef.GetSyntaxAsync(cancellationToken);
878 | if (sourceMemberNode is not MemberDeclarationSyntax memberDeclaration) {
879 | throw new McpException($"Source member '{fullyQualifiedMemberName}' is not a valid member declaration.");
880 | }
881 |
882 | Document sourceDocument = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(currentSolution, sourceMemberNode);
883 | Document destinationDocument;
884 | INamedTypeSymbol? destinationTypeSymbol = null;
885 | INamespaceSymbol? destinationNamespaceSymbol = null;
886 |
887 | if (destinationSymbol is INamedTypeSymbol typeSym) {
888 | destinationTypeSymbol = typeSym;
889 | var destSyntaxRef = typeSym.DeclaringSyntaxReferences.FirstOrDefault();
890 | if (destSyntaxRef == null) {
891 | throw new McpException($"Could not find syntax reference for destination type '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
892 | }
893 | var destNode = await destSyntaxRef.GetSyntaxAsync(cancellationToken);
894 | destinationDocument = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(currentSolution, destNode);
895 | } else if (destinationSymbol is INamespaceSymbol nsSym) {
896 | destinationNamespaceSymbol = nsSym;
897 | var projectForDestination = currentSolution.GetDocument(sourceDocument.Id)!.Project;
898 | var existingDoc = await FindExistingDocumentWithNamespaceAsync(projectForDestination, nsSym, cancellationToken);
899 | if (existingDoc != null) {
900 | destinationDocument = existingDoc;
901 | } else {
902 | var newDoc = await CreateDocumentForNamespaceAsync(projectForDestination, nsSym, cancellationToken);
903 | destinationDocument = newDoc;
904 | currentSolution = newDoc.Project.Solution; // Update currentSolution after adding a document
905 | }
906 | } else {
907 | throw new McpException("Invalid destination symbol type.");
908 | }
909 |
910 | if (sourceDocument.Id == destinationDocument.Id && sourceMemberSymbol.ContainingSymbol.Equals(destinationSymbol, SymbolEqualityComparer.Default)) {
911 | throw new McpException($"Source and destination are the same. Member '{fullyQualifiedMemberName}' is already in '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
912 | }
913 |
914 | string memberName = GetMemberName(memberDeclaration);
915 | INamedTypeSymbol? updatedDestinationTypeSymbol = null;
916 | if (destinationTypeSymbol != null) {
917 | // Re-resolve destinationTypeSymbol from the potentially updated currentSolution
918 | var destinationDocumentFromCurrentSolution = currentSolution.GetDocument(destinationDocument.Id)
919 | ?? throw new McpException($"Destination document '{destinationDocument.FilePath}' not found in current solution for symbol re-resolution.");
920 | var tempDestSymbol = await SymbolFinder.FindSymbolAtPositionAsync(destinationDocumentFromCurrentSolution, destinationTypeSymbol.Locations.First().SourceSpan.Start, cancellationToken);
921 | updatedDestinationTypeSymbol = tempDestSymbol as INamedTypeSymbol;
922 | if (updatedDestinationTypeSymbol == null) {
923 | throw new McpException($"Could not re-resolve destination type symbol '{destinationTypeSymbol.ToDisplayString()}' in the current solution state at file '{destinationDocumentFromCurrentSolution.FilePath}'. Original location span: {destinationTypeSymbol.Locations.First().SourceSpan}");
924 | }
925 | }
926 |
927 | if (updatedDestinationTypeSymbol != null && !IsMemberAllowed(updatedDestinationTypeSymbol, memberDeclaration, memberName, cancellationToken)) {
928 | throw new McpException($"A member with the name '{memberName}' already exists in destination type '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
929 | }
930 |
931 | try {
932 | var actualDestinationDocument = currentSolution.GetDocument(destinationDocument.Id)
933 | ?? throw new McpException($"Destination document '{destinationDocument.FilePath}' not found in current solution before adding member.");
934 |
935 | if (updatedDestinationTypeSymbol != null) {
936 | currentSolution = await modificationService.AddMemberAsync(actualDestinationDocument.Id, updatedDestinationTypeSymbol, memberDeclaration, -1, cancellationToken);
937 | } else {
938 | if (destinationNamespaceSymbol == null) throw new McpException("Destination namespace symbol is null when expected for namespace move.");
939 | currentSolution = await AddMemberToNamespaceAsync(actualDestinationDocument, destinationNamespaceSymbol, memberDeclaration, modificationService, cancellationToken);
940 | }
941 |
942 | // Re-acquire source document and node from the *new* currentSolution
943 | var sourceDocumentInCurrentSolution = currentSolution.GetDocument(sourceDocument.Id)
944 | ?? throw new McpException("Source document not found in current solution after adding member to destination.");
945 | var syntaxRootOfSourceInCurrentSolution = await sourceDocumentInCurrentSolution.GetSyntaxRootAsync(cancellationToken)
946 | ?? throw new McpException("Could not get syntax root for source document in current solution.");
947 |
948 | // Attempt to find the node again. Its span might have changed if the destination was in the same file.
949 | var sourceMemberNodeInCurrentTree = syntaxRootOfSourceInCurrentSolution.FindNode(sourceMemberNode.Span, findInsideTrivia: true, getInnermostNodeForTie: true);
950 | if (sourceMemberNodeInCurrentTree == null || !(sourceMemberNodeInCurrentTree is MemberDeclarationSyntax)) {
951 | // Fallback: Try to find by kind and name if span-based lookup failed (e.g. due to formatting changes or other modifications)
952 | sourceMemberNodeInCurrentTree = syntaxRootOfSourceInCurrentSolution
953 | .DescendantNodes()
954 | .OfType<MemberDeclarationSyntax>()
955 | .FirstOrDefault(m => m.Kind() == memberDeclaration.Kind() && GetMemberName(m) == memberName);
956 |
957 | if (sourceMemberNodeInCurrentTree == null) {
958 | logger.LogWarning("Could not precisely re-locate source member node by original span or by kind/name after destination add. Original span: {Span}. Member kind: {Kind}, Name: {Name}. File: {File}", sourceMemberNode.Span, memberDeclaration.Kind(), memberName, sourceDocumentInCurrentSolution.FilePath);
959 | // As a last resort, if the original node is still part of the new tree (by reference), use it.
960 | // This is risky if the tree has been significantly changed, but better than failing if it's just minor formatting.
961 | if (syntaxRootOfSourceInCurrentSolution.DescendantNodes().Contains(sourceMemberNode)) {
962 | sourceMemberNodeInCurrentTree = sourceMemberNode;
963 | logger.LogWarning("Fallback: Using original source member node reference for removal. This might be risky if tree changed significantly.");
964 | } else {
965 | throw new McpException($"Critically failed to re-locate source member node '{memberName}' in '{sourceDocumentInCurrentSolution.FilePath}' for removal after modifications. Original span {sourceMemberNode.Span}. This usually indicates significant tree changes that broke span tracking or the member was unexpectedly altered or removed.");
966 | }
967 | } else {
968 | logger.LogInformation("Re-located source member node by kind and name for removal. Original span: {OriginalSpan}, New span: {NewSpan}", sourceMemberNode.Span, sourceMemberNodeInCurrentTree.Span);
969 | }
970 | }
971 |
972 | currentSolution = await RemoveMemberFromParentAsync(sourceDocumentInCurrentSolution, sourceMemberNodeInCurrentTree, modificationService, cancellationToken);
973 |
974 | string finalCommitMessage = $"Move {memberName} to {fullyQualifiedDestinationTypeOrNamespaceName}: {commitMessage}";
975 | await modificationService.ApplyChangesAsync(currentSolution, cancellationToken, finalCommitMessage);
976 |
977 | // After ApplyChangesAsync, the solutionManager.CurrentSolution should be the most up-to-date.
978 | // Re-fetch documents from there for final error checking.
979 | var finalSourceDocumentAfterApply = solutionManager.CurrentSolution?.GetDocument(sourceDocument.Id);
980 | var finalDestinationDocumentAfterApply = solutionManager.CurrentSolution?.GetDocument(destinationDocument.Id);
981 |
982 | StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
983 | if (finalSourceDocumentAfterApply != null) {
984 | var (sourceHasErrors, sourceErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(solutionManager, finalSourceDocumentAfterApply, logger, cancellationToken);
985 | errorBuilder.AppendLine(sourceHasErrors
986 | ? $"Issues in source file {finalSourceDocumentAfterApply.FilePath ?? "unknown"}:\n{sourceErrorMessages}\n"
987 | : $"No compilation errors detected in source file {finalSourceDocumentAfterApply.FilePath ?? "unknown"}.");
988 | }
989 |
990 | if (finalDestinationDocumentAfterApply != null && (!finalSourceDocumentAfterApply?.Id.Equals(finalDestinationDocumentAfterApply.Id) ?? true)) {
991 | var (destHasErrors, destErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(solutionManager, finalDestinationDocumentAfterApply, logger, cancellationToken);
992 | errorBuilder.AppendLine(destHasErrors
993 | ? $"Issues in destination file {finalDestinationDocumentAfterApply.FilePath ?? "unknown"}:\n{destErrorMessages}\n"
994 | : $"No compilation errors detected in destination file {finalDestinationDocumentAfterApply.FilePath ?? "unknown"}.");
995 | }
996 | errorBuilder.AppendLine("</errorCheck>");
997 |
998 | var sourceFilePathDisplay = finalSourceDocumentAfterApply?.FilePath ?? sourceDocument.FilePath ?? "unknown source file";
999 | var destinationFilePathDisplay = finalDestinationDocumentAfterApply?.FilePath ?? destinationDocument.FilePath ?? "unknown destination file";
1000 |
1001 | var locationInfo = sourceFilePathDisplay == destinationFilePathDisplay
1002 | ? $"within {sourceFilePathDisplay}"
1003 | : $"from {sourceFilePathDisplay} to {destinationFilePathDisplay}";
1004 |
1005 | return $"Successfully moved member '{memberName}' to '{fullyQualifiedDestinationTypeOrNamespaceName}' {locationInfo}.\n\n{errorBuilder}";
1006 | } catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
1007 | logger.LogError(ex, "Failed to move member {MemberName} to {DestinationName}", fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName);
1008 | throw new McpException($"Failed to move member '{fullyQualifiedMemberName}' to '{fullyQualifiedDestinationTypeOrNamespaceName}': {ex.Message}", ex);
1009 | }
1010 | }, logger, nameof(MoveMember), cancellationToken);
1011 | }
1012 | /// <summary>
1013 | /// Finds an existing document in the project that contains the specified namespace.
1014 | /// </summary>
1015 | private static async Task<Document?> FindExistingDocumentWithNamespaceAsync(Project project, INamespaceSymbol namespaceSymbol, CancellationToken cancellationToken) {
1016 | var namespaceName = namespaceSymbol.ToDisplayString();
1017 |
1018 | foreach (var document in project.Documents) {
1019 | if (document.FilePath?.EndsWith(".cs") != true) continue;
1020 |
1021 | var root = await document.GetSyntaxRootAsync(cancellationToken);
1022 | if (root is CompilationUnitSyntax compilationUnit) {
1023 | // Check if this document already contains the target namespace
1024 | var hasNamespace = compilationUnit.Members
1025 | .OfType<NamespaceDeclarationSyntax>()
1026 | .Any(n => n.Name.ToString() == namespaceName);
1027 |
1028 | if (hasNamespace || (namespaceSymbol.IsGlobalNamespace && compilationUnit.Members.Any())) {
1029 | return document;
1030 | }
1031 | }
1032 | }
1033 |
1034 | return null;
1035 | }
1036 | /// <summary>
1037 | /// Creates a new document for the specified namespace.
1038 | /// </summary>
1039 | private static Task<Document> CreateDocumentForNamespaceAsync(Project project, INamespaceSymbol namespaceSymbol, CancellationToken cancellationToken) {
1040 | var namespaceName = namespaceSymbol.ToDisplayString();
1041 | var fileName = string.IsNullOrEmpty(namespaceName) || namespaceSymbol.IsGlobalNamespace
1042 | ? "GlobalNamespace.cs"
1043 | : $"{namespaceName.Split('.').Last()}.cs";
1044 |
1045 | // Ensure the file name doesn't conflict with existing files
1046 | var baseName = Path.GetFileNameWithoutExtension(fileName);
1047 | var extension = Path.GetExtension(fileName);
1048 | var counter = 1;
1049 | var projectDirectory = Path.GetDirectoryName(project.FilePath) ?? throw new InvalidOperationException("Project directory not found");
1050 |
1051 | var fullPath = Path.Combine(projectDirectory, fileName);
1052 | while (project.Documents.Any(d => string.Equals(d.FilePath, fullPath, StringComparison.OrdinalIgnoreCase))) {
1053 | fileName = $"{baseName}{counter}{extension}";
1054 | fullPath = Path.Combine(projectDirectory, fileName);
1055 | counter++;
1056 | }
1057 |
1058 | // Create basic content for the new file
1059 | var content = namespaceSymbol.IsGlobalNamespace
1060 | ? "// Global namespace file\n"
1061 | : $"namespace {namespaceName} {{\n // Namespace content\n}}\n";
1062 |
1063 | var newDocument = project.AddDocument(fileName, content, filePath: fullPath);
1064 | return Task.FromResult(newDocument);
1065 | }
1066 | /// <summary>
1067 | /// Adds a member to the specified namespace in the given document.
1068 | /// </summary>
1069 | private static async Task<Solution> AddMemberToNamespaceAsync(Document document, INamespaceSymbol namespaceSymbol, MemberDeclarationSyntax memberDeclaration, ICodeModificationService modificationService, CancellationToken cancellationToken) {
1070 | var root = await document.GetSyntaxRootAsync(cancellationToken);
1071 | if (root is not CompilationUnitSyntax compilationUnit) {
1072 | throw new McpException("Destination document does not have a valid compilation unit.");
1073 | }
1074 |
1075 | var editor = await DocumentEditor.CreateAsync(document, cancellationToken);
1076 |
1077 | if (namespaceSymbol.IsGlobalNamespace) {
1078 | // Add to global namespace (compilation unit)
1079 | editor.AddMember(compilationUnit, memberDeclaration);
1080 | } else {
1081 | // Find or create the target namespace
1082 | var namespaceName = namespaceSymbol.ToDisplayString();
1083 | var targetNamespace = compilationUnit.Members
1084 | .OfType<NamespaceDeclarationSyntax>()
1085 | .FirstOrDefault(n => n.Name.ToString() == namespaceName);
1086 |
1087 | if (targetNamespace == null) {
1088 | // Create the namespace and add the member to it
1089 | targetNamespace = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName(namespaceName))
1090 | .AddMembers(memberDeclaration);
1091 | editor.AddMember(compilationUnit, targetNamespace);
1092 | } else {
1093 | // Add member to existing namespace
1094 | editor.AddMember(targetNamespace, memberDeclaration);
1095 | }
1096 | }
1097 |
1098 | var changedDocument = editor.GetChangedDocument();
1099 | var formattedDocument = await modificationService.FormatDocumentAsync(changedDocument, cancellationToken);
1100 | return formattedDocument.Project.Solution;
1101 | }
1102 |
1103 | /// <summary>
1104 | /// Properly removes a member from its parent by deleting it from the parent's member collection.
1105 | /// </summary>
1106 | private static async Task<Solution> RemoveMemberFromParentAsync(Document document, SyntaxNode memberNode, ICodeModificationService modificationService, CancellationToken cancellationToken) {
1107 | if (memberNode is not MemberDeclarationSyntax memberDeclaration) {
1108 | throw new McpException($"Node is not a member declaration: {memberNode.GetType().Name}");
1109 | }
1110 |
1111 | var root = await document.GetSyntaxRootAsync(cancellationToken);
1112 | if (root == null) {
1113 | throw new McpException("Could not get syntax root from document.");
1114 | }
1115 |
1116 | SyntaxNode newRoot;
1117 |
1118 | if (memberNode.Parent is CompilationUnitSyntax compilationUnit) {
1119 | // Handle top-level members in the compilation unit
1120 | var newMembers = compilationUnit.Members.Remove(memberDeclaration);
1121 | newRoot = compilationUnit.WithMembers(newMembers);
1122 | } else if (memberNode.Parent is NamespaceDeclarationSyntax namespaceDecl) {
1123 | // Handle members in a namespace
1124 | var newMembers = namespaceDecl.Members.Remove(memberDeclaration);
1125 | var newNamespace = namespaceDecl.WithMembers(newMembers);
1126 | newRoot = root.ReplaceNode(namespaceDecl, newNamespace);
1127 | } else if (memberNode.Parent is TypeDeclarationSyntax typeDecl) {
1128 | // Handle members in a type declaration (class, struct, interface, etc.)
1129 | var newMembers = typeDecl.Members.Remove(memberDeclaration);
1130 | var newType = typeDecl.WithMembers(newMembers);
1131 | newRoot = root.ReplaceNode(typeDecl, newType);
1132 | } else {
1133 | throw new McpException($"Cannot remove member from parent of type {memberNode.Parent?.GetType().Name ?? "null"}.");
1134 | }
1135 |
1136 | var newDocument = document.WithSyntaxRoot(newRoot);
1137 | var formattedDocument = await modificationService.FormatDocumentAsync(newDocument, cancellationToken);
1138 | return formattedDocument.Project.Solution;
1139 | }
1140 | private static async Task<(bool changed, string diff)> ProcessNonCodeFile(
1141 | string filePath,
1142 | string regexPattern,
1143 | string replacementText,
1144 | IDocumentOperationsService documentOperations,
1145 | List<string> modifiedFiles,
1146 | CancellationToken cancellationToken) {
1147 | try {
1148 | var (originalContent, _) = await documentOperations.ReadFileAsync(filePath, false, cancellationToken);
1149 | var regex = new Regex(regexPattern, RegexOptions.Multiline);
1150 | string newContent = regex.Replace(originalContent.NormalizeEndOfLines(), replacementText);
1151 |
1152 | // Only write if content changed
1153 | if (newContent != originalContent) {
1154 | // Note: we don't pass commit message here as we'll handle Git at a higher level
1155 | // for all modified non-code files at once
1156 | await documentOperations.WriteFileAsync(filePath, newContent, true, cancellationToken, string.Empty);
1157 | modifiedFiles.Add(filePath);
1158 |
1159 | // Generate diff
1160 | string diff = ContextInjectors.CreateCodeDiff(originalContent, newContent);
1161 | return (true, diff);
1162 | }
1163 |
1164 | return (false, string.Empty);
1165 | } catch (Exception ex) {
1166 | throw new McpException($"Error processing non-code file {filePath}: {ex.Message}");
1167 | }
1168 | }
1169 | }
```