| 1 | using System.Text; |
| 2 | using Microsoft.CodeAnalysis; |
| 3 | using Microsoft.CodeAnalysis.MSBuild; |
| 4 | |
| 5 | namespace RoslynMcp.ServiceLayer; |
| 6 | |
| 7 | /// <summary>Генерация абстрактного базового класса по классу без диалогов Roslyn: выбранные public-члены становятся abstract в новом классе.</summary> |
| 8 | public static class GenerateBaseClass |
| 9 | { |
| 10 | private static string NormalizePath(string path) |
| 11 | { |
| 12 | var p = Path.GetFullPath(path.Trim()); |
| 13 | if (p.EndsWith(Path.DirectorySeparatorChar)) |
| 14 | p = p.TrimEnd(Path.DirectorySeparatorChar); |
| 15 | return p; |
| 16 | } |
| 17 | |
| 18 | private static readonly SymbolDisplayFormat TypeFormat = new( |
| 19 | typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, |
| 20 | genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, |
| 21 | memberOptions: SymbolDisplayMemberOptions.None, |
| 22 | parameterOptions: SymbolDisplayParameterOptions.IncludeType | SymbolDisplayParameterOptions.IncludeName); |
| 23 | |
| 24 | /// <summary>По позиции (класс в файле) генерирует абстрактный базовый класс с выбранными членами как abstract. Опционально: имя базового класса, путь к файлу, фильтр по именам членов.</summary> |
| 25 | public static async Task<string> GenerateBaseClassFromClassAsync( |
| 26 | string solutionOrProjectPath, |
| 27 | string filePath, |
| 28 | int line, |
| 29 | int column, |
| 30 | string? baseClassName = null, |
| 31 | string? outputFilePath = null, |
| 32 | IReadOnlyList<string>? memberNames = null, |
| 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 | |
| 40 | var targetPath = NormalizePath(filePath); |
| 41 | Solution? solution = null; |
| 42 | try |
| 43 | { |
| 44 | var workspace = MSBuildWorkspace.Create(RoslynMcpWorkspaceProperties.MsBuild); |
| 45 | solution = await WorkspaceOpen.OpenSolutionOrProjectAsync(workspace, solutionOrProjectPath, cancellationToken).ConfigureAwait(false); |
| 46 | |
| 47 | if (solution is null) |
| 48 | return "Error: failed to open solution."; |
| 49 | |
| 50 | var document = solution.Projects |
| 51 | .SelectMany(p => p.Documents) |
| 52 | .FirstOrDefault(d => string.Equals(NormalizePath(d.FilePath ?? ""), targetPath, StringComparison.OrdinalIgnoreCase)); |
| 53 | if (document is null) |
| 54 | return $"Error: file not found in solution: {filePath}"; |
| 55 | |
| 56 | var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); |
| 57 | var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); |
| 58 | if (root is null || semanticModel is null) |
| 59 | return "Error: could not get syntax/semantic model."; |
| 60 | |
| 61 | var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); |
| 62 | var lines = sourceText.Lines; |
| 63 | if (line < 1 || line > lines.Count) |
| 64 | return $"Error: line {line} out of range (1..{lines.Count})."; |
| 65 | var lineInfo = lines[line - 1]; |
| 66 | var columnIndex = column - 1; |
| 67 | if (columnIndex < 0) |
| 68 | return "Error: column must be >= 1."; |
| 69 | var position = lineInfo.Span.Length == 0 |
| 70 | ? lineInfo.Start |
| 71 | : lineInfo.Start + Math.Min(columnIndex, lineInfo.Span.Length); |
| 72 | |
| 73 | var node = root.FindToken(position, findInsideTrivia: true).Parent; |
| 74 | INamedTypeSymbol? typeSymbol = null; |
| 75 | while (node != null) |
| 76 | { |
| 77 | cancellationToken.ThrowIfCancellationRequested(); |
| 78 | var sym = semanticModel.GetDeclaredSymbol(node, cancellationToken); |
| 79 | if (sym is INamedTypeSymbol named && named.TypeKind == TypeKind.Class) |
| 80 | { |
| 81 | typeSymbol = named; |
| 82 | break; |
| 83 | } |
| 84 | node = node.Parent; |
| 85 | } |
| 86 | |
| 87 | if (typeSymbol is null) |
| 88 | return $"Error: no class at {filePath}:{line}:{column}. Position the cursor on the class name or inside the class body. (Struct not supported for base class.)"; |
| 89 | |
| 90 | var style = EditorConfigStyle.GetOptionsForDirectory(Path.GetDirectoryName(document.FilePath) ?? ""); |
| 91 | |
| 92 | var ns = typeSymbol.ContainingNamespace?.IsGlobalNamespace == true |
| 93 | ? null |
| 94 | : typeSymbol.ContainingNamespace?.ToDisplayString(); |
| 95 | var name = baseClassName?.Trim(); |
| 96 | if (string.IsNullOrEmpty(name)) |
| 97 | name = typeSymbol.Name + "Base"; |
| 98 | |
| 99 | var memberSet = memberNames != null && memberNames.Count > 0 |
| 100 | ? new HashSet<string>(memberNames.Select(n => n.Trim()), StringComparer.OrdinalIgnoreCase) |
| 101 | : null; |
| 102 | |
| 103 | var members = new List<string>(); |
| 104 | foreach (var member in typeSymbol.GetMembers()) |
| 105 | { |
| 106 | if (member.IsImplicitlyDeclared || member.DeclaredAccessibility != Accessibility.Public) |
| 107 | continue; |
| 108 | if (memberSet != null && !memberSet.Contains(member.Name)) |
| 109 | continue; |
| 110 | |
| 111 | switch (member) |
| 112 | { |
| 113 | case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary: |
| 114 | members.Add(FormatAbstractMethod(method, style)); |
| 115 | break; |
| 116 | case IPropertySymbol prop when prop.IsIndexer == false: |
| 117 | members.Add(FormatAbstractProperty(prop, style)); |
| 118 | break; |
| 119 | case IEventSymbol evt: |
| 120 | members.Add(FormatAbstractEvent(evt, style)); |
| 121 | break; |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | if (members.Count == 0) |
| 126 | return $"Error: no public instance methods/properties/events to extract for {typeSymbol.Name}. Specify member_names or ensure the class has public members."; |
| 127 | |
| 128 | var code = BuildAbstractClassFile(ns, name, members); |
| 129 | |
| 130 | if (!string.IsNullOrWhiteSpace(outputFilePath)) |
| 131 | { |
| 132 | var outPath = Path.GetFullPath(outputFilePath.Trim()); |
| 133 | var dir = Path.GetDirectoryName(outPath); |
| 134 | if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) |
| 135 | Directory.CreateDirectory(dir); |
| 136 | await File.WriteAllTextAsync(outPath, code, cancellationToken).ConfigureAwait(false); |
| 137 | return $"# Abstract base class generated: {name}\n# Written to: {outPath}\n# Next: add to {typeSymbol.Name} `: {name}` and add override to the extracted members.\n\n{code}"; |
| 138 | } |
| 139 | |
| 140 | return $"# Abstract base class generated: {name}\n# (pass output_file_path to write to disk)\n# Next: add to {typeSymbol.Name} `: {name}` and add override to the extracted members.\n\n{code}"; |
| 141 | } |
| 142 | catch (InvalidOperationException ex) when (ex.Message.Contains("slnx") || ex.Message.Contains("Slnx")) |
| 143 | { |
| 144 | return "Error: .slnx format is not supported. Use .sln or .csproj."; |
| 145 | } |
| 146 | finally |
| 147 | { |
| 148 | solution?.Workspace.Dispose(); |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | private static string FormatAbstractMethod(IMethodSymbol method, EditorStyleOptions style) |
| 153 | { |
| 154 | var ret = style.FormatTypeName(method.ReturnType.ToDisplayString(TypeFormat)); |
| 155 | var ps = string.Join(", ", method.Parameters.Select(p => $"{style.FormatTypeName(p.Type.ToDisplayString(TypeFormat))} {p.Name}")); |
| 156 | return $"\tprotected abstract {ret} {method.Name}({ps});"; |
| 157 | } |
| 158 | |
| 159 | private static string FormatAbstractProperty(IPropertySymbol prop, EditorStyleOptions style) |
| 160 | { |
| 161 | var type = style.FormatTypeName(prop.Type.ToDisplayString(TypeFormat)); |
| 162 | var getSet = prop.IsReadOnly ? "get;" : "get; set;"; |
| 163 | return $"\tprotected abstract {type} {prop.Name} {{ {getSet} }}"; |
| 164 | } |
| 165 | |
| 166 | private static string FormatAbstractEvent(IEventSymbol evt, EditorStyleOptions style) |
| 167 | { |
| 168 | var type = style.FormatTypeName(evt.Type.ToDisplayString(TypeFormat)); |
| 169 | return $"\tprotected abstract event {type} {evt.Name};"; |
| 170 | } |
| 171 | |
| 172 | private static string BuildAbstractClassFile(string? ns, string baseClassName, List<string> members) |
| 173 | { |
| 174 | var sb = new StringBuilder(); |
| 175 | if (!string.IsNullOrEmpty(ns)) |
| 176 | { |
| 177 | sb.Append("namespace ").AppendLine(ns).AppendLine("{"); |
| 178 | } |
| 179 | sb.Append("\tpublic abstract class ").AppendLine(baseClassName); |
| 180 | sb.AppendLine("\t{"); |
| 181 | sb.AppendLine(string.Join(Environment.NewLine, members)); |
| 182 | sb.AppendLine("\t}"); |
| 183 | if (!string.IsNullOrEmpty(ns)) |
| 184 | sb.AppendLine("}"); |
| 185 | return sb.ToString(); |
| 186 | } |
| 187 | } |
| 188 | |