Forge
csharp551a3611
1using System.Text;
2using System.Text.RegularExpressions;
3using Microsoft.CodeAnalysis;
4using Microsoft.CodeAnalysis.CSharp;
5using Microsoft.CodeAnalysis.CSharp.Syntax;
6using Microsoft.CodeAnalysis.MSBuild;
7using Microsoft.CodeAnalysis.Text;
8
9namespace RoslynMcp.ServiceLayer;
10
11/// <summary>Приведение объявлений namespace к структуре папок (RootNamespace + путь папки). Опционально dry_run — только отчёт.</summary>
12public static class SyncNamespaces
13{
14 private static string NormalizePath(string path)
15 {
16 var p = Path.GetFullPath(path.Trim());
17 if (p.EndsWith(Path.DirectorySeparatorChar))
18 p = p.TrimEnd(Path.DirectorySeparatorChar);
19 return p;
20 }
21
22 private static string? GetRootNamespaceFromProject(string projectFilePath)
23 {
24 if (!File.Exists(projectFilePath)) return null;
25 try
26 {
27 var text = File.ReadAllText(projectFilePath);
28 var match = Regex.Match(text, @"<RootNamespace>\s*([^<]+)\s*</RootNamespace>");
29 if (match.Success)
30 return match.Groups[1].Value.Trim();
31 }
32 catch { /* ignore */ }
33 return Path.GetFileNameWithoutExtension(projectFilePath);
34 }
35
36 /// <summary>Вычисляет целевой namespace по пути файла относительно корня проекта: RootNamespace + папки.</summary>
37 private static string GetTargetNamespace(string rootNamespace, string projectDir, string filePath)
38 {
39 var fullPath = NormalizePath(filePath);
40 var projectRoot = NormalizePath(projectDir);
41 if (!fullPath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
42 return rootNamespace;
43 var relative = fullPath.Length == projectRoot.Length
44 ? ""
45 : fullPath.Substring(projectRoot.Length).TrimStart(Path.DirectorySeparatorChar, '/');
46 var dir = Path.GetDirectoryName(relative);
47 if (string.IsNullOrEmpty(dir))
48 return rootNamespace;
49 var segments = dir.Replace('\\', '/').Split('/').Where(s => s.Length > 0).ToArray();
50 if (segments.Length == 0)
51 return rootNamespace;
52 return rootNamespace + "." + string.Join(".", segments);
53 }
54
55 public static async Task<string> SyncAsync(
56 string solutionOrProjectPath,
57 bool dryRun = false,
58 string? projectPath = null,
59 CancellationToken cancellationToken = default)
60 {
61 if (!File.Exists(solutionOrProjectPath))
62 return $"Error: solution/project not found: {solutionOrProjectPath}";
63
64 Solution? solution = null;
65 try
66 {
67 var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild);
68 solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false);
69
70 if (solution is null)
71 return "Error: failed to open solution.";
72
73 var sb = new StringBuilder();
74 sb.AppendLine("# Sync namespaces to folder structure");
75 sb.AppendLineInvariant($"# Path: {solutionOrProjectPath}");
76 sb.AppendLineInvariant($"# Mode: {(dryRun ? "dry run (no writes)" : "apply")}");
77 sb.AppendLine();
78
79 var allChanges = new List<(DocumentId docId, string filePath, string oldNs, string newNs)>();
80
81 var projectsToProcess = solution.Projects.AsEnumerable();
82 if (!string.IsNullOrWhiteSpace(projectPath))
83 {
84 var normalizedProjectPath = NormalizePath(projectPath);
85 projectsToProcess = projectsToProcess.Where(p => p.FilePath is not null && NormalizePath(p.FilePath) == normalizedProjectPath);
86 }
87
88 foreach (var project in projectsToProcess)
89 {
90 cancellationToken.ThrowIfCancellationRequested();
91 var projFilePath = project.FilePath;
92 if (string.IsNullOrEmpty(projFilePath))
93 continue;
94 var projectDir = Path.GetDirectoryName(projFilePath) ?? projFilePath;
95 var rootNs = GetRootNamespaceFromProject(projFilePath) ?? project.Name;
96
97 foreach (var doc in project.Documents)
98 {
99 cancellationToken.ThrowIfCancellationRequested();
100 var filePath = doc.FilePath;
101 if (string.IsNullOrEmpty(filePath) || !string.Equals(Path.GetExtension(filePath), ".cs", StringComparison.OrdinalIgnoreCase))
102 continue;
103
104 var targetNs = GetTargetNamespace(rootNs, projectDir, filePath);
105 var tree = await doc.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
106 if (tree?.GetRoot(cancellationToken) is not CompilationUnitSyntax root)
107 continue;
108
109 var nsDecl = root.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>().FirstOrDefault();
110 if (nsDecl is null)
111 continue;
112
113 var currentNs = nsDecl.Name.ToString();
114 if (string.Equals(currentNs, targetNs, StringComparison.Ordinal))
115 continue;
116
117 allChanges.Add((doc.Id, filePath, currentNs, targetNs));
118 }
119 }
120
121 if (allChanges.Count == 0)
122 {
123 sb.AppendLine("(no namespace changes needed)");
124 return sb.ToString();
125 }
126
127 foreach (var (_, filePath, oldNs, newNs) in allChanges)
128 sb.AppendLineInvariant($"{filePath}: \"{oldNs}\" → \"{newNs}\"");
129 sb.AppendLine();
130 sb.AppendLineInvariant($"Total: {allChanges.Count} file(s) to update.");
131
132 if (dryRun)
133 {
134 sb.AppendLine().AppendLine("# Run without dry_run to apply.");
135 return sb.ToString();
136 }
137
138 // Применяем: 1) замена namespace в каждом файле; 2) замена using oldNs на using newNs во всех документах проектов
139 var solutionWithNewNamespaces = solution;
140 var oldToNew = allChanges.Select(c => (c.oldNs, c.newNs)).Distinct().ToList();
141
142 foreach (var (docId, filePath, oldNs, newNs) in allChanges)
143 {
144 cancellationToken.ThrowIfCancellationRequested();
145 var doc = solutionWithNewNamespaces.GetDocument(docId);
146 if (doc is null) continue;
147
148 var tree = await doc.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
149 if (tree?.GetRoot(cancellationToken) is not CompilationUnitSyntax root)
150 continue;
151
152 var nsDecl = root.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>().FirstOrDefault();
153 if (nsDecl is null) continue;
154
155 var newName = SyntaxFactory.ParseName(newNs);
156 var newNsDecl = nsDecl.WithName(newName);
157 var newRoot = root.ReplaceNode(nsDecl, newNsDecl);
158 solutionWithNewNamespaces = solutionWithNewNamespaces.WithDocumentSyntaxRoot(docId, newRoot);
159 }
160
161 // Во всех документах проектов заменяем using oldNs на using newNs
162 foreach (var project in solutionWithNewNamespaces.Projects)
163 {
164 foreach (var doc in project.Documents)
165 {
166 cancellationToken.ThrowIfCancellationRequested();
167 var tree = await doc.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
168 if (tree?.GetRoot(cancellationToken) is not CompilationUnitSyntax root)
169 continue;
170
171 var usings = root.Usings;
172 var changed = false;
173 var newUsings = new List<UsingDirectiveSyntax>();
174 foreach (var u in usings)
175 {
176 if (u.Name is null) { newUsings.Add(u); continue; }
177 var nameStr = u.Name.ToString();
178 var replacement = oldToNew.FirstOrDefault(t => string.Equals(t.oldNs, nameStr, StringComparison.Ordinal));
179 if (replacement.oldNs is not null)
180 {
181 newUsings.Add(u.WithName(SyntaxFactory.ParseName(replacement.newNs)));
182 changed = true;
183 }
184 else
185 newUsings.Add(u);
186 }
187
188 if (!changed) continue;
189
190 var newRoot = root.WithUsings(SyntaxFactory.List(newUsings));
191 solutionWithNewNamespaces = solutionWithNewNamespaces.WithDocumentSyntaxRoot(doc.Id, newRoot);
192 }
193 }
194
195 // Записываем все изменённые файлы на диск
196 var changes = solutionWithNewNamespaces.GetChanges(solution);
197 var changedDocIds = new HashSet<DocumentId>();
198 foreach (var projectChange in changes.GetProjectChanges())
199 foreach (var docId in projectChange.GetChangedDocuments())
200 changedDocIds.Add(docId);
201
202 foreach (var docId in changedDocIds)
203 {
204 var doc = solutionWithNewNamespaces.GetDocument(docId);
205 if (doc?.FilePath is null) continue;
206 var text = await doc.GetTextAsync(cancellationToken).ConfigureAwait(false);
207 await File.WriteAllTextAsync(doc.FilePath, text.ToString(), cancellationToken).ConfigureAwait(false);
208 }
209
210 sb.AppendLine().AppendLine("Applied. Files written to disk.");
211 return sb.ToString();
212 }
213 catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx"))
214 {
215 return "Error: .slnx format is not supported. Use .sln or .csproj.";
216 }
217 finally
218 {
219 solution?.Workspace.Dispose();
220 }
221 }
222}
223
View only · write via MCP/CIDE