Forge
csharp551a3611
1using System.Text;
2using Microsoft.CodeAnalysis;
3using Microsoft.CodeAnalysis.CSharp;
4using Microsoft.CodeAnalysis.CSharp.Syntax;
5using Microsoft.CodeAnalysis.MSBuild;
6
7namespace RoslynMcp.ServiceLayer;
8
9/// <summary>Перенос выбранных членов типа в новый файл с объявлением <c>partial</c> того же типа; в исходном файле добавляется <c>partial</c> при необходимости.</summary>
10public static class MoveMembersToPartialFile
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 /// <summary>
21 /// Перенос членов по именам в новый .cs файл (тот же тип, partial). Позиция — на имени типа или внутри тела типа.
22 /// Конструктор: укажи имя типа или <c>.ctor</c>. Индексатор: <c>this</c>. Перегрузки методов с одним именем переносятся все.
23 /// </summary>
24 public static async Task<string> MoveAsync(
25 string solutionOrProjectPath,
26 string filePath,
27 int line,
28 int column,
29 IReadOnlyList<string> memberNames,
30 string outputFilePath,
31 bool apply,
32 bool addDependentUpon = true,
33 CancellationToken cancellationToken = default)
34 {
35 if (!File.Exists(solutionOrProjectPath))
36 return $"Error: solution/project not found: {solutionOrProjectPath}";
37 if (!File.Exists(filePath))
38 return $"Error: file not found: {filePath}";
39 if (memberNames is null || memberNames.Count == 0)
40 return "Error: member_names must be a non-empty array.";
41 var trimmedNames = memberNames.Select(static n => n.Trim()).Where(static n => n.Length > 0).ToArray();
42 if (trimmedNames.Length == 0)
43 return "Error: member_names must contain at least one non-empty name.";
44
45 var outPath = Path.GetFullPath(outputFilePath.Trim());
46 if (apply && File.Exists(outPath))
47 return $"Error: output file already exists (delete or choose another path): {outPath}";
48
49 var targetPath = NormalizePath(filePath);
50 MSBuildWorkspace? workspace = null;
51 try
52 {
53 workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild);
54 var solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false);
55 if (solution is null)
56 return "Error: failed to open solution.";
57
58 var document = solution.Projects
59 .SelectMany(p => p.Documents)
60 .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase));
61 if (document is null)
62 return $"Error: file not found in solution: {filePath}";
63
64 var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
65 var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
66 if (root is null || semanticModel is null)
67 return "Error: could not get syntax/semantic model.";
68
69 var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
70 var lines = sourceText.Lines;
71 if (line < 1 || line > lines.Count)
72 return $"Error: line {line} out of range (1..{lines.Count}).";
73 var lineInfo = lines[line - 1];
74 var columnIndex = column - 1;
75 if (columnIndex < 0)
76 return "Error: column must be >= 1.";
77 var position = lineInfo.Span.Length == 0
78 ? lineInfo.Start
79 : lineInfo.Start + Math.Min(columnIndex, lineInfo.Span.Length);
80
81 var node = root.FindToken(position, findInsideTrivia: true).Parent;
82 TypeDeclarationSyntax? typeDecl = null;
83 INamedTypeSymbol? typeSymbol = null;
84 while (node is not null)
85 {
86 cancellationToken.ThrowIfCancellationRequested();
87 if (node is TypeDeclarationSyntax tds)
88 {
89 var sym = semanticModel.GetDeclaredSymbol(tds, cancellationToken);
90 if (sym is INamedTypeSymbol named && named.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface)
91 {
92 typeDecl = tds;
93 typeSymbol = named;
94 break;
95 }
96 }
97 node = node.Parent;
98 }
99
100 if (typeDecl is null || typeSymbol is null)
101 return $"Error: no class, struct, or interface at {filePath}:{line}:{column}. Put the cursor on the type name or inside its body.";
102
103 if (typeSymbol.TypeKind == TypeKind.Interface)
104 return "Error: moving members out of an interface is not supported.";
105
106 var nameSet = new HashSet<string>(trimmedNames, StringComparer.OrdinalIgnoreCase);
107 var collectError = TryCollectMembersToMove(
108 typeDecl, semanticModel, typeSymbol, nameSet, cancellationToken, out var toMove);
109 if (collectError is not null)
110 return collectError;
111
112 if (toMove.Count == 0)
113 {
114 var available = string.Join(", ", ListMemberNamesForHint(typeDecl, semanticModel, cancellationToken).Take(40));
115 var hint = string.IsNullOrEmpty(available) ? "" : $"\n# Hint (first members in type): {available}";
116 return $"Error: no members matched member_names for type {typeSymbol.Name}. Check spelling (constructors: type name or .ctor; indexer: this).{hint}";
117 }
118
119 var compilationUnit = root is CompilationUnitSyntax cu ? cu : null;
120 if (compilationUnit is null)
121 return "Error: unexpected root (expected compilation unit).";
122
123 var fileScopedNs = IsDeclaredInFileScopedNamespace(typeDecl);
124 var newTypePart = EnsurePartialKeyword(CloneTypeShellWithMembers(typeDecl, toMove));
125 var remainingAfterRemove = typeDecl.RemoveNodes(toMove, SyntaxRemoveOptions.KeepEndOfLine) ?? typeDecl;
126 var remainingType = EnsurePartialKeyword(remainingAfterRemove);
127
128 var newFileText = BuildPartialPartFileText(compilationUnit, newTypePart, typeSymbol, fileScopedNs);
129 var modifiedRoot = root.ReplaceNode(typeDecl, remainingType);
130 var modifiedText = modifiedRoot.GetText();
131
132 var sb = new StringBuilder();
133 sb.AppendLineInvariant($"# Type: {typeSymbol.ToDisplayString()}");
134 sb.AppendLineInvariant($"# Members moved: {toMove.Count} ({string.Join(", ", toMove.Select(GetMemberLabel))})");
135 sb.AppendLineInvariant($"# Output: {outPath}");
136 sb.AppendLineInvariant($"# Source updated: {filePath}");
137 sb.AppendLine(apply ? "# apply: true — writing files." : "# apply: false — preview only. Pass apply: true to write.");
138 sb.AppendLine();
139
140 if (!apply)
141 {
142 sb.AppendLine("## New file content");
143 sb.AppendLine();
144 sb.AppendLine(newFileText);
145 sb.AppendLine();
146 sb.AppendLine("## Modified source (excerpt, first 80 lines)");
147 sb.AppendLine();
148 var fullModified = modifiedText.ToString();
149 var lineArr = fullModified.Split(['\n', '\r'], StringSplitOptions.None);
150 var take = Math.Min(80, lineArr.Length);
151 for (var i = 0; i < take; i++)
152 sb.AppendLine(lineArr[i]);
153 if (lineArr.Length > 80)
154 sb.AppendLine("...");
155 return sb.ToString();
156 }
157
158 var project = document.Project;
159 if (project.FilePath is not null)
160 {
161 var projDir = Path.GetDirectoryName(Path.GetFullPath(project.FilePath));
162 var outDir = Path.GetDirectoryName(outPath);
163 if (projDir is not null && outDir is not null)
164 {
165 var fullProj = Path.GetFullPath(projDir);
166 var fullOut = Path.GetFullPath(outDir);
167 if (!fullOut.StartsWith(fullProj, Environment.OSVersion.Platform == PlatformID.Win32NT ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
168 sb.AppendLineInvariant($"# Warning: output path is outside project directory ({fullProj}). SDK glob may not include the file; prefer a path under the project.");
169 }
170 }
171
172 var solutionWithOriginal = solution.WithDocumentText(document.Id, modifiedText);
173 var projectAfter = solutionWithOriginal.GetDocument(document.Id)?.Project
174 ?? throw new InvalidOperationException("Document missing after update.");
175 var folders = GetRelativeFolderSegments(projectAfter.FilePath, outPath);
176 var fileName = Path.GetFileName(outPath);
177 var withNewDoc = projectAfter.AddDocument(fileName, newFileText, folders, outPath);
178 var finalSolution = withNewDoc.Project.Solution;
179
180 if (!workspace.TryApplyChanges(finalSolution))
181 return "Error: TryApplyChanges returned false (files may be read-only or path invalid).";
182
183 sb.AppendLine("Applied: source file updated and new partial file created.");
184
185 var csprojPathApply = withNewDoc.Project.FilePath;
186 if (!string.IsNullOrEmpty(csprojPathApply))
187 {
188 var projDirApply = Path.GetDirectoryName(Path.GetFullPath(csprojPathApply));
189 if (projDirApply is not null)
190 {
191 var childRelApply = Path.GetRelativePath(projDirApply, outPath).Replace('/', Path.DirectorySeparatorChar);
192 var stripMsg = DependentUponCsproj.TryRemoveRedundantSdkCompileInclude(csprojPathApply, childRelApply);
193 sb.AppendLineInvariant($"# Sdk Compile: {stripMsg}");
194
195 if (addDependentUpon)
196 {
197 var depVal = DependentUponCsproj.ComputeDependentUponValue(projDirApply, outPath, targetPath);
198 var depMsg = DependentUponCsproj.AddOrUpdateDependentUpon(csprojPathApply, childRelApply, depVal, dryRun: false);
199 sb.AppendLineInvariant($"# DependentUpon: {depMsg}");
200 }
201 }
202 }
203
204 return sb.ToString();
205 }
206 catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx"))
207 {
208 return "Error: .slnx format is not supported. Use .sln or .csproj.";
209 }
210 finally
211 {
212 workspace?.Dispose();
213 }
214 }
215
216 private static string GetMemberLabel(MemberDeclarationSyntax m) => m switch
217 {
218 MethodDeclarationSyntax md => md.Identifier.ValueText,
219 ConstructorDeclarationSyntax cd => cd.Identifier.ValueText.Length > 0 ? cd.Identifier.ValueText : ".ctor",
220 DestructorDeclarationSyntax => "Finalize",
221 PropertyDeclarationSyntax pd => pd.Identifier.ValueText,
222 IndexerDeclarationSyntax => "this",
223 FieldDeclarationSyntax fd => string.Join(", ", fd.Declaration.Variables.Select(v => v.Identifier.ValueText)),
224 EventDeclarationSyntax ed => ed.Identifier.ValueText,
225 EventFieldDeclarationSyntax efd => string.Join(", ", efd.Declaration.Variables.Select(v => v.Identifier.ValueText)),
226 TypeDeclarationSyntax td => td.Identifier.ValueText,
227 EnumDeclarationSyntax en => en.Identifier.ValueText,
228 _ => m.Kind().ToString()
229 };
230
231 private static string[] GetRelativeFolderSegments(string? projectFilePath, string documentFullPath)
232 {
233 if (string.IsNullOrEmpty(projectFilePath))
234 return [];
235 var projDir = Path.GetDirectoryName(Path.GetFullPath(projectFilePath));
236 var docDir = Path.GetDirectoryName(Path.GetFullPath(documentFullPath));
237 if (projDir is null || docDir is null)
238 return [];
239 var rel = Path.GetRelativePath(projDir, docDir);
240 if (rel is "." or { Length: 0 })
241 return [];
242 return rel.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
243 }
244
245 private static IEnumerable<string> ListMemberNamesForHint(TypeDeclarationSyntax typeDecl, SemanticModel model, CancellationToken ct)
246 {
247 foreach (var m in typeDecl.Members)
248 {
249 ct.ThrowIfCancellationRequested();
250 switch (m)
251 {
252 case FieldDeclarationSyntax fd:
253 foreach (var v in fd.Declaration.Variables)
254 {
255 ct.ThrowIfCancellationRequested();
256 if (model.GetDeclaredSymbol(v, ct) is IFieldSymbol fs)
257 yield return fs.Name;
258 }
259 break;
260 case EventFieldDeclarationSyntax efd:
261 foreach (var v in efd.Declaration.Variables)
262 {
263 ct.ThrowIfCancellationRequested();
264 if (model.GetDeclaredSymbol(v, ct) is IEventSymbol es)
265 yield return es.Name;
266 }
267 break;
268 default:
269 var sym = model.GetDeclaredSymbol(m, ct);
270 if (sym is null)
271 continue;
272 yield return sym.Name switch
273 {
274 ".ctor" => typeDecl.Identifier.ValueText,
275 _ when sym is IPropertySymbol { IsIndexer: true } => "this",
276 _ => sym.Name
277 };
278 break;
279 }
280 }
281 }
282
283 /// <summary>
284 /// Поля и event-поля: символ у каждого <see cref="VariableDeclaratorSyntax"/>; для узла <see cref="FieldDeclarationSyntax"/> целиком
285 /// <see cref="SemanticModel.GetDeclaredSymbol(Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax, CancellationToken)"/> часто даёт null (const и др.).
286 /// </summary>
287 private static string? TryCollectMembersToMove(
288 TypeDeclarationSyntax typeDecl,
289 SemanticModel semanticModel,
290 INamedTypeSymbol typeSymbol,
291 HashSet<string> nameSet,
292 CancellationToken ct,
293 out List<MemberDeclarationSyntax> toMove)
294 {
295 toMove = new List<MemberDeclarationSyntax>();
296 foreach (var member in typeDecl.Members)
297 {
298 ct.ThrowIfCancellationRequested();
299 switch (member)
300 {
301 case FieldDeclarationSyntax fd:
302 {
303 var err = TryAddFieldOrEventFieldDeclaration(
304 fd, fd.Declaration.Variables, semanticModel, nameSet, typeSymbol.Name, toMove, ct);
305 if (err is not null)
306 return err;
307 break;
308 }
309 case EventFieldDeclarationSyntax efd:
310 {
311 var err = TryAddFieldOrEventFieldDeclaration(
312 efd, efd.Declaration.Variables, semanticModel, nameSet, typeSymbol.Name, toMove, ct);
313 if (err is not null)
314 return err;
315 break;
316 }
317 default:
318 {
319 var sym = semanticModel.GetDeclaredSymbol(member, ct);
320 if (sym is null)
321 continue;
322 if (ShouldMoveMember(sym, nameSet, typeSymbol.Name) && !toMove.Contains(member))
323 toMove.Add(member);
324 break;
325 }
326 }
327 }
328
329 return null;
330 }
331
332 /// <summary>
333 /// Если в одном объявлении несколько переменных и в <paramref name="nameSet"/> попадает только часть имён — перенос невозможен без разбиения объявления.
334 /// </summary>
335 private static string? TryAddFieldOrEventFieldDeclaration(
336 MemberDeclarationSyntax declarationNode,
337 SeparatedSyntaxList<VariableDeclaratorSyntax> variables,
338 SemanticModel semanticModel,
339 HashSet<string> nameSet,
340 string typeName,
341 List<MemberDeclarationSyntax> toMove,
342 CancellationToken ct)
343 {
344 if (variables.Count == 0)
345 return null;
346
347 var anyMatch = false;
348 var anyResolvedNonMatch = false;
349 foreach (var variable in variables)
350 {
351 ct.ThrowIfCancellationRequested();
352 var sym = semanticModel.GetDeclaredSymbol(variable, ct);
353 if (sym is null)
354 continue;
355 if (ShouldMoveMember(sym, nameSet, typeName))
356 anyMatch = true;
357 else
358 anyResolvedNonMatch = true;
359 }
360
361 if (!anyMatch)
362 return null;
363
364 if (anyResolvedNonMatch)
365 {
366 var line = declarationNode.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
367 return $"Error: field/event declaration at line {line} declares multiple variables; cannot move a subset in one step. Split into separate declarations or list all names in member_names.";
368 }
369
370 if (!toMove.Contains(declarationNode))
371 toMove.Add(declarationNode);
372 return null;
373 }
374
375 private static bool ShouldMoveMember(ISymbol sym, HashSet<string> names, string typeName)
376 {
377 return sym switch
378 {
379 IMethodSymbol m when m.MethodKind == MethodKind.Constructor =>
380 names.Contains(typeName) || names.Contains(".ctor"),
381 IMethodSymbol m when m.MethodKind == MethodKind.Destructor =>
382 names.Contains("Finalize") || names.Contains("~" + typeName),
383 IPropertySymbol { IsIndexer: true } =>
384 names.Contains("this"),
385 IMethodSymbol m =>
386 names.Contains(m.Name),
387 IPropertySymbol p =>
388 names.Contains(p.Name),
389 IFieldSymbol f =>
390 names.Contains(f.Name),
391 IEventSymbol e =>
392 names.Contains(e.Name),
393 INamedTypeSymbol nt when nt.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Enum or TypeKind.Interface =>
394 names.Contains(nt.Name),
395 _ => false
396 };
397 }
398
399 /// <summary>
400 /// Добавляет <c>partial</c> если его ещё нет. Порядок: <c>partial</c> — последний модификатор перед keyword типа
401 /// (<c>class</c>/<c>struct</c>/<c>record</c>/<c>interface</c>), иначе CS0267 (<c>public partial sealed class</c> неверно).
402 /// </summary>
403 private static TypeDeclarationSyntax EnsurePartialKeyword(TypeDeclarationSyntax typeDecl)
404 {
405 if (typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
406 return typeDecl;
407 var partialToken = SyntaxFactory.Token(SyntaxKind.PartialKeyword).WithTrailingTrivia(SyntaxFactory.Space);
408 return typeDecl.WithModifiers(typeDecl.Modifiers.Add(partialToken));
409 }
410
411 private static TypeDeclarationSyntax CloneTypeShellWithMembers(TypeDeclarationSyntax original, IReadOnlyList<MemberDeclarationSyntax> members) =>
412 original
413 .WithMembers(SyntaxFactory.List(members))
414 .WithAttributeLists(SyntaxFactory.List<AttributeListSyntax>())
415 .WithLeadingTrivia(SyntaxFactory.EndOfLine(Environment.NewLine));
416
417 private static bool IsDeclaredInFileScopedNamespace(TypeDeclarationSyntax typeDecl)
418 {
419 for (var p = typeDecl.Parent; p != null; p = p.Parent)
420 {
421 if (p is FileScopedNamespaceDeclarationSyntax)
422 return true;
423 if (p is NamespaceDeclarationSyntax)
424 return false;
425 }
426 return false;
427 }
428
429 private static string BuildPartialPartFileText(
430 CompilationUnitSyntax originalCu,
431 TypeDeclarationSyntax typePart,
432 INamedTypeSymbol typeSymbol,
433 bool fileScopedNamespace)
434 {
435 var globalNs = typeSymbol.ContainingNamespace is null or { IsGlobalNamespace: true };
436 var nsDisplay = globalNs ? null : typeSymbol.ContainingNamespace!.ToDisplayString();
437 var fileScoped = fileScopedNamespace && !globalNs;
438
439 MemberDeclarationSyntax typeAsMember = typePart;
440 MemberDeclarationSyntax wrapped;
441
442 if (globalNs)
443 wrapped = typeAsMember;
444 else if (string.IsNullOrEmpty(nsDisplay))
445 wrapped = typeAsMember;
446 else if (fileScoped)
447 {
448 wrapped = SyntaxFactory.FileScopedNamespaceDeclaration(
449 SyntaxFactory.ParseName(nsDisplay))
450 .WithMembers(SyntaxFactory.SingletonList(typeAsMember));
451 }
452 else
453 {
454 wrapped = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName(nsDisplay!))
455 .WithMembers(SyntaxFactory.SingletonList(typeAsMember))
456 .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken).WithLeadingTrivia(SyntaxFactory.CarriageReturnLineFeed))
457 .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken).WithLeadingTrivia(SyntaxFactory.CarriageReturnLineFeed));
458 }
459
460 var cu = SyntaxFactory.CompilationUnit()
461 .WithExterns(originalCu.Externs)
462 .WithUsings(originalCu.Usings);
463
464 cu = globalNs || string.IsNullOrEmpty(nsDisplay)
465 ? cu.AddMembers(typeAsMember)
466 : cu.AddMembers(wrapped);
467
468 return cu.NormalizeWhitespace(elasticTrivia: true).ToFullString();
469 }
470}
471
View only · write via MCP/CIDE