| 1 | namespace RoslynMcp.ServiceLayer; |
| 2 | |
| 3 | /// <summary>Чтение стиля кода из .editorconfig (от каталога файла вверх по дереву; ближайший к файлу переопределяет).</summary> |
| 4 | public static class EditorConfigStyle |
| 5 | { |
| 6 | private const string PreferIntrinsicLocalsKey = "dotnet_style_predefined_type_for_locals_parameters_members"; |
| 7 | private const string PreferIntrinsicMemberAccessKey = "dotnet_style_predefined_type_for_member_access"; |
| 8 | private const string VarForBuiltInKey = "csharp_style_var_for_built_in_types"; |
| 9 | private const string VarWhenApparentKey = "csharp_style_var_when_type_is_apparent"; |
| 10 | private const string VarElsewhereKey = "csharp_style_var_elsewhere"; |
| 11 | private const string PreferBracesKey = "csharp_prefer_braces"; |
| 12 | private const string NewLineBeforeOpenBraceKey = "csharp_new_line_before_open_brace"; |
| 13 | private const string IndentStyleKey = "indent_style"; |
| 14 | private const string IndentSizeKey = "indent_size"; |
| 15 | private const string TabWidthKey = "tab_width"; |
| 16 | |
| 17 | /// <summary>Собирает .editorconfig от directory вверх до корня, парсит [*.cs] и объединяет (ближайший к directory выигрывает).</summary> |
| 18 | public static EditorStyleOptions GetOptionsForDirectory(string directory) |
| 19 | { |
| 20 | var dir = Path.GetFullPath(directory.Trim()); |
| 21 | if (!Directory.Exists(dir)) |
| 22 | dir = Path.GetDirectoryName(dir) ?? dir; |
| 23 | |
| 24 | var configPaths = new List<string>(); |
| 25 | while (!string.IsNullOrEmpty(dir)) |
| 26 | { |
| 27 | var editorConfigPath = Path.Combine(dir, ".editorconfig"); |
| 28 | if (File.Exists(editorConfigPath)) |
| 29 | configPaths.Add(editorConfigPath); |
| 30 | var parent = Path.GetDirectoryName(dir); |
| 31 | if (parent == dir) |
| 32 | break; |
| 33 | dir = parent ?? ""; |
| 34 | } |
| 35 | |
| 36 | if (configPaths.Count == 0) |
| 37 | return EditorStyleOptions.Default; |
| 38 | |
| 39 | // configPaths[0] = ближайший к directory, configPaths[last] = корень. Применяем от корня к directory, чтобы ближайший переопределял. |
| 40 | EditorStyleOptions? merged = null; |
| 41 | for (var i = configPaths.Count - 1; i >= 0; i--) |
| 42 | { |
| 43 | var parsed = ParseEditorConfig(configPaths[i]); |
| 44 | if (parsed != null) |
| 45 | merged = parsed; |
| 46 | } |
| 47 | |
| 48 | return merged ?? EditorStyleOptions.Default; |
| 49 | } |
| 50 | |
| 51 | /// <summary>Собирает из .editorconfig (от directory вверх) множество ID диагностик с severity = none (их не показывать в выдаче roslyn_get_diagnostics).</summary> |
| 52 | public static HashSet<string> GetDiagnosticIdsSeverityNone(string directory) |
| 53 | { |
| 54 | var dir = Path.GetFullPath(directory.Trim()); |
| 55 | if (!Directory.Exists(dir)) |
| 56 | dir = Path.GetDirectoryName(dir) ?? dir; |
| 57 | |
| 58 | var configPaths = new List<string>(); |
| 59 | while (!string.IsNullOrEmpty(dir)) |
| 60 | { |
| 61 | var editorConfigPath = Path.Combine(dir, ".editorconfig"); |
| 62 | if (File.Exists(editorConfigPath)) |
| 63 | configPaths.Add(editorConfigPath); |
| 64 | var parent = Path.GetDirectoryName(dir); |
| 65 | if (parent == dir) |
| 66 | break; |
| 67 | dir = parent ?? ""; |
| 68 | } |
| 69 | |
| 70 | var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| 71 | for (var i = configPaths.Count - 1; i >= 0; i--) |
| 72 | { |
| 73 | try |
| 74 | { |
| 75 | var lines = File.ReadAllLines(configPaths[i]); |
| 76 | var inRelevantSection = false; |
| 77 | foreach (var line in lines) |
| 78 | { |
| 79 | var trimmed = line.Trim(); |
| 80 | if (trimmed.StartsWith('[')) |
| 81 | { |
| 82 | inRelevantSection = trimmed.Contains('*') && (trimmed.Contains("*.cs", StringComparison.OrdinalIgnoreCase) || trimmed == "[*]"); |
| 83 | continue; |
| 84 | } |
| 85 | if (!inRelevantSection) |
| 86 | continue; |
| 87 | var eq = trimmed.IndexOf('='); |
| 88 | if (eq <= 0) |
| 89 | continue; |
| 90 | var key = trimmed[..eq].Trim(); |
| 91 | if (!key.StartsWith("dotnet_diagnostic.", StringComparison.Ordinal) || !key.EndsWith(".severity", StringComparison.Ordinal)) |
| 92 | continue; |
| 93 | var id = key["dotnet_diagnostic.".Length..^".severity".Length].Trim(); |
| 94 | var value = trimmed[(eq + 1)..].Trim(); |
| 95 | var valuePart = value.Contains(':') ? value[..value.IndexOf(':')].Trim() : value.Trim(); |
| 96 | if (valuePart.Equals("none", StringComparison.OrdinalIgnoreCase)) |
| 97 | result.Add(id); |
| 98 | else |
| 99 | result.Remove(id); |
| 100 | } |
| 101 | } |
| 102 | catch |
| 103 | { |
| 104 | // ignore |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | return result; |
| 109 | } |
| 110 | |
| 111 | /// <summary>Парсит один .editorconfig: секция [*.cs], из значений берётся часть до двоеточия (severity отбрасывается).</summary> |
| 112 | private static EditorStyleOptions? ParseEditorConfig(string filePath) |
| 113 | { |
| 114 | try |
| 115 | { |
| 116 | var lines = File.ReadAllLines(filePath); |
| 117 | var inCsSection = false; |
| 118 | bool? preferIntrinsicLocals = null; |
| 119 | bool? preferIntrinsicMemberAccess = null; |
| 120 | bool? varForBuiltIn = null; |
| 121 | bool? varWhenApparent = null; |
| 122 | bool? varElsewhere = null; |
| 123 | bool? preferBraces = null; |
| 124 | string? newLineBeforeOpenBrace = null; |
| 125 | string? indentStyle = null; |
| 126 | int? indentSize = null; |
| 127 | int? tabWidth = null; |
| 128 | |
| 129 | foreach (var line in lines) |
| 130 | { |
| 131 | var trimmed = line.Trim(); |
| 132 | if (trimmed.StartsWith('[')) |
| 133 | { |
| 134 | inCsSection = trimmed.Contains("*.cs", StringComparison.OrdinalIgnoreCase); |
| 135 | continue; |
| 136 | } |
| 137 | if (!inCsSection) |
| 138 | continue; |
| 139 | var eq = trimmed.IndexOf('='); |
| 140 | if (eq <= 0) |
| 141 | continue; |
| 142 | var key = trimmed[..eq].Trim(); |
| 143 | var value = trimmed[(eq + 1)..].Trim(); |
| 144 | var valuePart = value.Contains(':') ? value[..value.IndexOf(':')].Trim() : value.Trim(); |
| 145 | var isTrue = valuePart.Equals("true", StringComparison.OrdinalIgnoreCase); |
| 146 | |
| 147 | if (key.Equals(PreferIntrinsicLocalsKey, StringComparison.OrdinalIgnoreCase)) |
| 148 | preferIntrinsicLocals = isTrue; |
| 149 | else if (key.Equals(PreferIntrinsicMemberAccessKey, StringComparison.OrdinalIgnoreCase)) |
| 150 | preferIntrinsicMemberAccess = isTrue; |
| 151 | else if (key.Equals(VarForBuiltInKey, StringComparison.OrdinalIgnoreCase)) |
| 152 | varForBuiltIn = isTrue; |
| 153 | else if (key.Equals(VarWhenApparentKey, StringComparison.OrdinalIgnoreCase)) |
| 154 | varWhenApparent = isTrue; |
| 155 | else if (key.Equals(VarElsewhereKey, StringComparison.OrdinalIgnoreCase)) |
| 156 | varElsewhere = isTrue; |
| 157 | else if (key.Equals(PreferBracesKey, StringComparison.OrdinalIgnoreCase)) |
| 158 | preferBraces = isTrue; |
| 159 | else if (key.Equals(NewLineBeforeOpenBraceKey, StringComparison.OrdinalIgnoreCase)) |
| 160 | newLineBeforeOpenBrace = valuePart; |
| 161 | else if (key.Equals(IndentStyleKey, StringComparison.OrdinalIgnoreCase)) |
| 162 | indentStyle = valuePart; |
| 163 | else if (key.Equals(IndentSizeKey, StringComparison.OrdinalIgnoreCase) && int.TryParse(valuePart, out var size)) |
| 164 | indentSize = size; |
| 165 | else if (key.Equals(TabWidthKey, StringComparison.OrdinalIgnoreCase) && int.TryParse(valuePart, out var tw)) |
| 166 | tabWidth = tw; |
| 167 | } |
| 168 | |
| 169 | return new EditorStyleOptions( |
| 170 | preferIntrinsicTypeNames: preferIntrinsicLocals ?? preferIntrinsicMemberAccess ?? true, |
| 171 | preferVarForBuiltInTypes: varForBuiltIn ?? false, |
| 172 | preferVarWhenTypeApparent: varWhenApparent ?? false, |
| 173 | preferVarElsewhere: varElsewhere ?? false, |
| 174 | preferBraces: preferBraces ?? true, |
| 175 | newLineBeforeOpenBrace: newLineBeforeOpenBrace ?? "all", |
| 176 | indentStyle: indentStyle ?? "space", |
| 177 | indentSize: indentSize ?? 4, |
| 178 | tabWidth: tabWidth ?? 4); |
| 179 | } |
| 180 | catch |
| 181 | { |
| 182 | return null; |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /// <summary>Опции стиля для генерации кода (по .editorconfig или по умолчанию).</summary> |
| 188 | public sealed class EditorStyleOptions |
| 189 | { |
| 190 | public static EditorStyleOptions Default { get; } = new(); |
| 191 | |
| 192 | public bool PreferIntrinsicTypeNames { get; } |
| 193 | public bool PreferVarForBuiltInTypes { get; } |
| 194 | public bool PreferVarWhenTypeApparent { get; } |
| 195 | public bool PreferVarElsewhere { get; } |
| 196 | public bool PreferBraces { get; } |
| 197 | public string NewLineBeforeOpenBrace { get; } |
| 198 | public string IndentStyle { get; } |
| 199 | public int IndentSize { get; } |
| 200 | public int TabWidth { get; } |
| 201 | |
| 202 | private readonly string _newLine = Environment.NewLine; |
| 203 | |
| 204 | /// <summary>Строка отступа для одного уровня (пробелы или таб по indent_style/indent_size/tab_width).</summary> |
| 205 | public string IndentString => |
| 206 | IndentStyle.Equals("tab", StringComparison.OrdinalIgnoreCase) |
| 207 | ? new string('\t', 1) |
| 208 | : new string(' ', IndentSize > 0 ? IndentSize : 4); |
| 209 | |
| 210 | public EditorStyleOptions( |
| 211 | bool preferIntrinsicTypeNames = true, |
| 212 | bool preferVarForBuiltInTypes = false, |
| 213 | bool preferVarWhenTypeApparent = false, |
| 214 | bool preferVarElsewhere = false, |
| 215 | bool preferBraces = true, |
| 216 | string? newLineBeforeOpenBrace = "all", |
| 217 | string? indentStyle = "space", |
| 218 | int indentSize = 4, |
| 219 | int tabWidth = 4) |
| 220 | { |
| 221 | PreferIntrinsicTypeNames = preferIntrinsicTypeNames; |
| 222 | PreferVarForBuiltInTypes = preferVarForBuiltInTypes; |
| 223 | PreferVarWhenTypeApparent = preferVarWhenTypeApparent; |
| 224 | PreferVarElsewhere = preferVarElsewhere; |
| 225 | PreferBraces = preferBraces; |
| 226 | NewLineBeforeOpenBrace = newLineBeforeOpenBrace ?? "all"; |
| 227 | IndentStyle = indentStyle ?? "space"; |
| 228 | IndentSize = indentSize; |
| 229 | TabWidth = tabWidth; |
| 230 | } |
| 231 | |
| 232 | /// <summary>Заменяет отображаемые имена типов .NET на ключевые слова C# (Int32 → int и т.д.), если PreferIntrinsicTypeNames = true.</summary> |
| 233 | public string FormatTypeName(string displayName) |
| 234 | { |
| 235 | if (string.IsNullOrEmpty(displayName) || !PreferIntrinsicTypeNames) |
| 236 | return displayName; |
| 237 | |
| 238 | var s = displayName.AsSpan().Trim(); |
| 239 | var nullable = false; |
| 240 | if (s.EndsWith("?", StringComparison.Ordinal)) |
| 241 | { |
| 242 | nullable = true; |
| 243 | s = s[..^1].Trim(); |
| 244 | } |
| 245 | |
| 246 | var typePart = s.ToString(); |
| 247 | if (typePart.StartsWith("System.", StringComparison.Ordinal)) |
| 248 | typePart = typePart["System.".Length..]; |
| 249 | var replacement = typePart switch |
| 250 | { |
| 251 | "Int32" => "int", |
| 252 | "Int64" => "long", |
| 253 | "Int16" => "short", |
| 254 | "UInt32" => "uint", |
| 255 | "UInt64" => "ulong", |
| 256 | "UInt16" => "ushort", |
| 257 | "Byte" => "byte", |
| 258 | "SByte" => "sbyte", |
| 259 | "Single" => "float", |
| 260 | "Double" => "double", |
| 261 | "Decimal" => "decimal", |
| 262 | "Boolean" => "bool", |
| 263 | "String" => "string", |
| 264 | "Object" => "object", |
| 265 | "Void" => "void", |
| 266 | _ => null |
| 267 | }; |
| 268 | |
| 269 | if (replacement != null) |
| 270 | return nullable ? replacement + "?" : replacement; |
| 271 | return nullable ? typePart + "?" : typePart; |
| 272 | } |
| 273 | |
| 274 | /// <summary>Новая строка перед открывающей скобкой по опции csharp_new_line_before_open_brace (all/none и т.д.).</summary> |
| 275 | public string NewLine => _newLine; |
| 276 | |
| 277 | /// <summary>Для однострочного блока: если PreferBraces = true, возвращает " { }"; иначе ";" (для выражений мы всё равно генерируем с телом).</summary> |
| 278 | public string OpenBraceOrSpace => PreferBraces ? " {" : ""; |
| 279 | public string CloseBraceOrEmpty => PreferBraces ? " }" : ""; |
| 280 | } |
| 281 | |