Forge
csharp551a3611
1using System.Text;
2using Microsoft.CodeAnalysis;
3using Microsoft.CodeAnalysis.CSharp.Syntax;
4using Microsoft.CodeAnalysis.MSBuild;
5using Microsoft.CodeAnalysis.Text;
6
7namespace RoslynMcp.ServiceLayer;
8
9/// <summary>Генерация override Equals и GetHashCode по выбранным полям/свойствам без диалога Roslyn.</summary>
10public static class GenerateEqualsGetHashCode
11{
12 private static string NormalizePath(string path)
13 {
14 var p = Path.GetFullPath(path.Trim());
15 if (p.EndsWith(Path.DirectorySeparatorChar))
16 p = p.TrimEnd(Path.DirectorySeparatorChar);
17 return p;
18 }
19
20 private static readonly SymbolDisplayFormat TypeFormat = new(
21 typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes,
22 genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
23 memberOptions: SymbolDisplayMemberOptions.None,
24 parameterOptions: SymbolDisplayParameterOptions.IncludeType | SymbolDisplayParameterOptions.IncludeName);
25
26 /// <summary>По позиции на класс генерирует override Equals(object?) и GetHashCode() по выбранным полям/свойствам. Опционально: member_names, insert_into_file.</summary>
27 public static async Task<string> GenerateEqualsGetHashCodeAsync(
28 string solutionOrProjectPath,
29 string filePath,
30 int line,
31 int column,
32 IReadOnlyList<string>? memberNames = null,
33 bool insertIntoFile = false,
34 CancellationToken cancellationToken = default)
35 {
36 if (!File.Exists(solutionOrProjectPath))
37 return $"Error: solution/project not found: {solutionOrProjectPath}";
38 if (!File.Exists(filePath))
39 return $"Error: file not found: {filePath}";
40
41 var targetPath = NormalizePath(filePath);
42 Solution? solution = null;
43 try
44 {
45 var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild);
46 solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false);
47
48 if (solution is null)
49 return "Error: failed to open solution.";
50
51 var document = solution.Projects
52 .SelectMany(p => p.Documents)
53 .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase));
54 if (document is null)
55 return $"Error: file not found in solution: {filePath}";
56
57 var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
58 var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
59 if (root is null || semanticModel is null)
60 return "Error: could not get syntax/semantic model.";
61
62 var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
63 var lines = sourceText.Lines;
64 if (line < 1 || line > lines.Count)
65 return $"Error: line {line} out of range (1..{lines.Count}).";
66 var lineInfo = lines[line - 1];
67 var columnIndex = column - 1;
68 if (columnIndex < 0)
69 return "Error: column must be >= 1.";
70 var position = lineInfo.Span.Length == 0
71 ? lineInfo.Start
72 : lineInfo.Start + Math.Min(columnIndex, lineInfo.Span.Length);
73
74 var node = root.FindToken(position, findInsideTrivia: true).Parent;
75 INamedTypeSymbol? typeSymbol = null;
76 SyntaxNode? classDeclaration = null;
77 while (node != null)
78 {
79 cancellationToken.ThrowIfCancellationRequested();
80 var sym = semanticModel.GetDeclaredSymbol(node, cancellationToken);
81 if (sym is INamedTypeSymbol named && named.TypeKind == TypeKind.Class)
82 {
83 typeSymbol = named;
84 classDeclaration = node;
85 break;
86 }
87 node = node.Parent;
88 }
89
90 if (typeSymbol is null)
91 return $"Error: no class at {filePath}:{line}:{column}. Position the cursor on the class name or inside the class body.";
92
93 var style = EditorConfigStyle.GetOptionsForDirectory(Path.GetDirectoryName(document.FilePath) ?? "");
94
95 var memberSet = memberNames != null && memberNames.Count > 0
96 ? new HashSet<string>(memberNames.Select(n => n.Trim()), StringComparer.OrdinalIgnoreCase)
97 : null;
98
99 var members = new List<string>();
100 foreach (var member in typeSymbol.GetMembers())
101 {
102 if (member.IsImplicitlyDeclared)
103 continue;
104 if (memberSet != null && !memberSet.Contains(member.Name))
105 continue;
106
107 switch (member)
108 {
109 case IFieldSymbol field when field.IsConst == false && field.IsStatic == false:
110 members.Add(field.Name);
111 break;
112 case IPropertySymbol prop when prop.IsIndexer == false:
113 members.Add(prop.Name);
114 break;
115 }
116 }
117
118 if (members.Count == 0)
119 return "Error: no instance fields or properties to include. Add member_names or ensure the class has instance fields/properties.";
120
121 var indent = "\t\t";
122 var className = typeSymbol.Name;
123 var equalsBody = BuildEqualsBody(className, members);
124 var block = $"public override bool Equals(object? obj) => obj is {className} other && {equalsBody};" + Environment.NewLine + indent +
125 $"public override int GetHashCode() => HashCode.Combine({string.Join(", ", members)});";
126
127 if (insertIntoFile && classDeclaration != null)
128 {
129 var (closeBrace, indentBeforeBrace) = GetClassCloseBraceAndIndent(classDeclaration, root);
130 if (closeBrace != null)
131 {
132 var insertIndent = indentBeforeBrace ?? style.IndentString;
133 var toInsert = Environment.NewLine + insertIndent +
134 $"public override bool Equals(object? obj) => obj is {className} other && {equalsBody};" +
135 Environment.NewLine + insertIndent +
136 $"public override int GetHashCode() => HashCode.Combine({string.Join(", ", members)});" +
137 Environment.NewLine + insertIndent;
138 var text = root.GetText();
139 var change = new TextChange(new TextSpan(closeBrace.Value.Span.Start, 0), toInsert);
140 var newText = text.WithChanges(change);
141 var newSolution = solution.WithDocumentText(document.Id, newText);
142 var applied = newSolution.Workspace.TryApplyChanges(newSolution);
143 if (applied)
144 {
145 newSolution.Workspace.Dispose();
146 return $"# Equals/GetHashCode inserted into {filePath}\n\n{block}";
147 }
148 }
149 }
150
151 solution?.Workspace.Dispose();
152 return $"# Generated Equals and GetHashCode for {className}\n# Paste into the class body. (Use insert_into_file: true to insert automatically.)\n\n{block}";
153 }
154 catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx"))
155 {
156 return "Error: .slnx format is not supported. Use .sln or .csproj.";
157 }
158 finally
159 {
160 solution?.Workspace.Dispose();
161 }
162 }
163
164 private static string BuildEqualsBody(string className, List<string> members)
165 {
166 return string.Join(" && ", members.Select(m => $"{m} == other.{m}"));
167 }
168
169 private static (SyntaxToken? closeBrace, string? indent) GetClassCloseBraceAndIndent(SyntaxNode classDeclaration, SyntaxNode root)
170 {
171 if (classDeclaration is not ClassDeclarationSyntax classSyn)
172 return (null, null);
173 var closeBrace = classSyn.CloseBraceToken;
174 if (closeBrace.IsMissing)
175 return (null, null);
176 var text = root.SyntaxTree?.GetText();
177 if (text is null)
178 return (null, null);
179 var line = text.Lines.GetLineFromPosition(closeBrace.Span.Start);
180 var lineText = line.ToString();
181 var posInLine = closeBrace.Span.Start - line.Start;
182 var indent = posInLine > 0 && posInLine <= lineText.Length ? lineText[..posInLine] : "";
183 if (string.IsNullOrEmpty(indent))
184 indent = "\t";
185 return (closeBrace, indent);
186 }
187}
188
View only · write via MCP/CIDE