Forge
csharp551a3611
1using System.Text;
2using Microsoft.CodeAnalysis;
3using Microsoft.CodeAnalysis.MSBuild;
4
5namespace RoslynMcp.ServiceLayer;
6
7/// <summary>Генерация интерфейса по классу без диалогов Roslyn: загрузка solution, символ типа по позиции, сбор public-членов, вывод C#.</summary>
8public static class GenerateInterface
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>По позиции (класс в файле) генерирует C# интерфейс. Опционально: имя интерфейса, путь к файлу для записи, фильтр по именам членов.</summary>
25 public static async Task<string> GenerateInterfaceFromClassAsync(
26 string solutionOrProjectPath,
27 string filePath,
28 int line,
29 int column,
30 string? interfaceName = 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 || named.TypeKind == TypeKind.Struct))
80 {
81 typeSymbol = named;
82 break;
83 }
84 node = node.Parent;
85 }
86
87 if (typeSymbol is null)
88 return $"Error: no class or struct at {filePath}:{line}:{column}. Position the cursor on the type name or inside the class body.";
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 = interfaceName?.Trim();
96 if (string.IsNullOrEmpty(name))
97 name = "I" + typeSymbol.Name;
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(FormatMethodSignature(method, style));
115 break;
116 case IPropertySymbol prop when prop.IsIndexer == false:
117 members.Add(FormatPropertySignature(prop, style));
118 break;
119 case IEventSymbol evt:
120 members.Add(FormatEventSignature(evt, style));
121 break;
122 }
123 }
124
125 if (members.Count == 0)
126 return $"Error: no public instance methods/properties to extract for {typeSymbol.Name}. Add member_names to include specific members, or ensure the class has public members.";
127
128 var code = BuildInterfaceFile(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 $"# Interface generated: {name}\n# Written to: {outPath}\n\n{code}";
138 }
139
140 return $"# Interface generated: {name}\n# (pass output_file_path to write to disk)\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 FormatMethodSignature(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 $"\t{ret} {method.Name}({ps});";
157 }
158
159 private static string FormatPropertySignature(IPropertySymbol prop, EditorStyleOptions style)
160 {
161 var type = style.FormatTypeName(prop.Type.ToDisplayString(TypeFormat));
162 var getSet = prop.IsReadOnly ? "{ get; }" : "{ get; set; }";
163 return $"\t{type} {prop.Name} {getSet}";
164 }
165
166 private static string FormatEventSignature(IEventSymbol evt, EditorStyleOptions style)
167 {
168 var type = style.FormatTypeName(evt.Type.ToDisplayString(TypeFormat));
169 return $"\t{type} {evt.Name};";
170 }
171
172 private static string BuildInterfaceFile(string? ns, string interfaceName, 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 interface ").AppendLine(interfaceName);
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
View only · write via MCP/CIDE