| 1 | using System.Text; |
| 2 | using System.Text.RegularExpressions; |
| 3 | using Microsoft.CodeAnalysis; |
| 4 | using Microsoft.CodeAnalysis.CSharp; |
| 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 6 | using Microsoft.CodeAnalysis.MSBuild; |
| 7 | using Microsoft.CodeAnalysis.Text; |
| 8 | |
| 9 | namespace RoslynMcp.ServiceLayer; |
| 10 | |
| 11 | /// <summary>Приведение объявлений namespace к структуре папок (RootNamespace + путь папки). Опционально dry_run — только отчёт.</summary> |
| 12 | public 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 | |