Forge
csharp551a3611
1using System.Linq;
2using System.Text;
3using Microsoft.CodeAnalysis;
4using Microsoft.CodeAnalysis.MSBuild;
5using Microsoft.CodeAnalysis.Rename;
6using Microsoft.CodeAnalysis.Text;
7
8namespace RoslynMcp.ServiceLayer;
9
10/// <summary>Переименование символа по solution. preview (apply=false) — только список изменений; apply=true — запись в файлы.</summary>
11public static class RenameSymbol
12{
13 private static string NormalizePath(string path)
14 {
15 var p = Path.GetFullPath(path.Trim());
16 if (p.EndsWith(Path.DirectorySeparatorChar))
17 p = p.TrimEnd(Path.DirectorySeparatorChar);
18 return p;
19 }
20
21 /// <summary>Топ-уровневый class/struct/interface: можно согласовать имена partial-файлов TypeName.cs и TypeName.*.cs.</summary>
22 private static bool IsTopLevelNamedTypeForPartialFileRename(ISymbol symbol) =>
23 symbol is INamedTypeSymbol { ContainingType: null } nt
24 && nt.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface;
25
26 /// <summary>
27 /// Переименовать путь к файлу с префиксом имени типа: <c>TypeName.cs</c>, <c>TypeName.Part.cs</c> → <c>NewName...</c>.
28 /// </summary>
29 private static bool TryComputeRenamedTypeFilePath(string fullPath, string oldTypeName, string newTypeName, out string newFullPath)
30 {
31 newFullPath = "";
32 var fileName = Path.GetFileName(fullPath);
33 if (!fileName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
34 return false;
35
36 var baseName = Path.GetFileNameWithoutExtension(fileName);
37 var dir = Path.GetDirectoryName(fullPath) ?? "";
38
39 if (string.Equals(baseName, oldTypeName, StringComparison.OrdinalIgnoreCase))
40 {
41 newFullPath = Path.Combine(dir, newTypeName + ".cs");
42 return !PathsEqualNormalized(fullPath, newFullPath);
43 }
44
45 var prefix = oldTypeName + ".";
46 if (baseName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
47 {
48 var rest = baseName.Substring(prefix.Length);
49 newFullPath = Path.Combine(dir, newTypeName + "." + rest + ".cs");
50 return !PathsEqualNormalized(fullPath, newFullPath);
51 }
52
53 return false;
54 }
55
56 private static bool PathsEqualNormalized(string a, string b)
57 {
58 try
59 {
60 return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
61 }
62 catch
63 {
64 return string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase);
65 }
66 }
67
68 public static async Task<string> RenameAsync(
69 string solutionOrProjectPath,
70 string filePath,
71 int line,
72 int column,
73 string newName,
74 bool apply,
75 bool renameInComments = false,
76 bool renameInStrings = false,
77 bool renameOverloads = false,
78 bool renameFile = false,
79 bool renamePartialTypeFiles = false,
80 CancellationToken cancellationToken = default)
81 {
82 if (!File.Exists(solutionOrProjectPath))
83 return $"Error: solution/project not found: {solutionOrProjectPath}";
84 if (!File.Exists(filePath))
85 return $"Error: file not found: {filePath}";
86 if (string.IsNullOrWhiteSpace(newName))
87 return "Error: new_name is required.";
88
89 var targetPath = NormalizePath(filePath);
90 Solution? solution = null;
91 try
92 {
93 var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild);
94 solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false);
95
96 if (solution is null)
97 return "Error: failed to open solution.";
98
99 var document = solution.Projects
100 .SelectMany(p => p.Documents)
101 .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase));
102 if (document is null)
103 return $"Error: file not found in solution: {filePath}";
104
105 var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
106 var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
107 if (root is null || semanticModel is null)
108 return "Error: could not get syntax/semantic model.";
109
110 var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
111 var lines = sourceText.Lines;
112 if (line < 1 || line > lines.Count)
113 return $"Error: line {line} out of range (1..{lines.Count}).";
114 var lineInfo = lines[line - 1];
115 var columnIndex = column - 1;
116 if (columnIndex < 0)
117 return $"Error: column {column} must be >= 1.";
118 var lineLen = lineInfo.Span.Length;
119 var position = lineLen == 0 ? lineInfo.Start : lineInfo.Start + Math.Min(columnIndex, lineLen);
120
121 var node = root.FindToken(position, findInsideTrivia: true).Parent;
122 ISymbol? symbol = null;
123 while (node is not null)
124 {
125 cancellationToken.ThrowIfCancellationRequested();
126 symbol = semanticModel.GetDeclaredSymbol(node, cancellationToken) ?? semanticModel.GetSymbolInfo(node, cancellationToken).Symbol;
127 if (symbol is not null)
128 break;
129 node = node.Parent;
130 }
131 if (symbol is null)
132 return $"No symbol at {filePath}:{line}:{column}.";
133
134 var oldTypeName = symbol.Name;
135 var options = new SymbolRenameOptions(renameOverloads, renameInStrings, renameInComments, renameFile);
136 var newSolution = await Renamer.RenameSymbolAsync(solution, symbol, options, newName, cancellationToken).ConfigureAwait(false);
137
138 var sb = new StringBuilder();
139 var docPath = document.FilePath ?? filePath;
140 sb.AppendLineInvariant($"# Document: {docPath} (total_lines={lines.Count}, line_{line}_length={lineLen})");
141 sb.AppendLineInvariant($"# Rename {symbol.Kind} {oldTypeName} → {newName}");
142 sb.AppendLine();
143
144 var changed = new List<Document>();
145 foreach (var project in newSolution.Projects)
146 {
147 foreach (var doc in project.Documents)
148 {
149 if (doc.FilePath is null)
150 continue;
151 var oldDoc = solution.GetDocument(doc.Id);
152 if (oldDoc is null)
153 continue;
154 var oldText = await oldDoc.GetTextAsync(cancellationToken).ConfigureAwait(false);
155 var newText = await doc.GetTextAsync(cancellationToken).ConfigureAwait(false);
156 if (!oldText.ContentEquals(newText))
157 changed.Add(doc);
158 }
159 }
160
161 List<(string OldPath, string NewPath)>? partialRenames = null;
162 if (renamePartialTypeFiles)
163 {
164 if (!IsTopLevelNamedTypeForPartialFileRename(symbol))
165 {
166 sb.AppendLine("# rename_partial_type_files: skipped (not a top-level class/struct/interface).");
167 sb.AppendLine();
168 }
169 else
170 {
171 var proj = newSolution.GetProject(document.Project.Id);
172 partialRenames = [];
173 if (proj is null)
174 {
175 sb.AppendLine("# rename_partial_type_files: skipped (project not found in new solution).");
176 sb.AppendLine();
177 }
178 else
179 {
180 foreach (var doc in proj.Documents)
181 {
182 var p = doc.FilePath;
183 if (p is null || !p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
184 continue;
185 if (TryComputeRenamedTypeFilePath(p, oldTypeName, newName, out var newP))
186 partialRenames.Add((p, newP));
187 }
188
189 if (partialRenames.Count == 0)
190 {
191 sb.AppendLine("# rename_partial_type_files: no matching TypeName.cs / TypeName.*.cs files in project.");
192 sb.AppendLine();
193 }
194 else
195 {
196 sb.AppendLine("# rename_partial_type_files (disk path renames after symbol rename):");
197 foreach (var (o, n) in partialRenames)
198 sb.AppendLineInvariant($" {o} → {n}");
199 sb.AppendLine();
200 }
201 }
202 }
203 }
204
205 if (changed.Count == 0)
206 {
207 sb.AppendLine("(no text changes)");
208 return sb.ToString();
209 }
210
211 var applied = new List<string>();
212 foreach (var doc in changed)
213 {
214 if (apply)
215 {
216 var newText = await doc.GetTextAsync(cancellationToken).ConfigureAwait(false);
217 await File.WriteAllTextAsync(doc.FilePath!, newText.ToString(), cancellationToken).ConfigureAwait(false);
218 applied.Add(doc.FilePath!);
219 }
220 else
221 {
222 sb.AppendLine(doc.FilePath);
223 }
224 }
225
226 if (apply)
227 {
228 sb.AppendLine("Applied text to:");
229 foreach (var p in applied)
230 sb.AppendLine(" " + p);
231
232 if (renamePartialTypeFiles && partialRenames is { Count: > 0 })
233 {
234 sb.AppendLine("Partial file renames:");
235 foreach (var (oldPath, newPath) in partialRenames.OrderByDescending(x => x.OldPath.Length))
236 {
237 try
238 {
239 if (!File.Exists(oldPath) && File.Exists(newPath))
240 {
241 sb.AppendLineInvariant($" (already at target, skip) {newPath}");
242 continue;
243 }
244
245 if (!File.Exists(oldPath))
246 {
247 sb.AppendLineInvariant($" Warning: source missing, skip: {oldPath}");
248 continue;
249 }
250
251 if (File.Exists(newPath))
252 {
253 sb.AppendLineInvariant($" Error: target exists: {newPath}");
254 continue;
255 }
256
257 File.Move(oldPath, newPath);
258 sb.AppendLineInvariant($" {oldPath} → {newPath}");
259 }
260 catch (Exception ex)
261 {
262 sb.AppendLineInvariant($" Error ({oldPath}): {ex.Message}");
263 }
264 }
265 }
266 }
267 else
268 {
269 sb.AppendLine().AppendLineInvariant($"Total: {changed.Count} file(s). Call with apply: true to write.");
270 }
271
272 return sb.ToString();
273 }
274 catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx"))
275 {
276 return "Error: .slnx format is not supported. Use .sln or open by .csproj.";
277 }
278 finally
279 {
280 solution?.Workspace.Dispose();
281 }
282 }
283}
284
View only · write via MCP/CIDE