| 1 | using System.Collections.Immutable; |
| 2 | using Microsoft.CodeAnalysis; |
| 3 | using Microsoft.CodeAnalysis.CSharp; |
| 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 5 | using Microsoft.CodeAnalysis.Diagnostics; |
| 6 | |
| 7 | namespace CascadeIDE.ArchitectureAnalyzers; |
| 8 | |
| 9 | /// <summary> |
| 10 | /// ADR 0079: <c>IdeDisplay/</c> — intent/композитор/снимок без Avalonia, без кабины и без хрома <c>UiChrome</c>. |
| 11 | /// </summary> |
| 12 | [DiagnosticAnalyzer(LanguageNames.CSharp)] |
| 13 | public sealed class IdeDisplayLayerArchitectureAnalyzer : DiagnosticAnalyzer |
| 14 | { |
| 15 | public const string CockpitNamespaceId = "CASCOPE013"; |
| 16 | public const string AvaloniaNamespaceId = "CASCOPE014"; |
| 17 | public const string FeaturesUiChromeId = "CASCOPE015"; |
| 18 | |
| 19 | private static readonly DiagnosticDescriptor CockpitRule = new( |
| 20 | CockpitNamespaceId, |
| 21 | "IdeDisplay не должен ссылаться на CascadeIDE.Cockpit", |
| 22 | "В IdeDisplay (ADR 0079) запрещён импорт из кабины (найдено: {0})", |
| 23 | "Architecture", |
| 24 | DiagnosticSeverity.Error, |
| 25 | isEnabledByDefault: true, |
| 26 | description: "Композиторы и снимки оверлеев IDE не зависят от канала/CDS/примитивов кабины."); |
| 27 | |
| 28 | private static readonly DiagnosticDescriptor AvaloniaRule = new( |
| 29 | AvaloniaNamespaceId, |
| 30 | "IdeDisplay не должен ссылаться на Avalonia", |
| 31 | "В IdeDisplay (ADR 0079) запрещены типы Avalonia UI (найдено: {0})", |
| 32 | "Architecture", |
| 33 | DiagnosticSeverity.Error, |
| 34 | isEnabledByDefault: true, |
| 35 | description: "Снимок и композитор IDS остаются переносимыми и тестируемыми без UI-фреймворка."); |
| 36 | |
| 37 | private static readonly DiagnosticDescriptor FeaturesUiChromeRule = new( |
| 38 | FeaturesUiChromeId, |
| 39 | "IdeDisplay не должен импортировать CascadeIDE.Features.UiChrome", |
| 40 | "В IdeDisplay запрещён using CascadeIDE.Features.UiChrome (ADR 0079, хром вне IDS)", |
| 41 | "Architecture", |
| 42 | DiagnosticSeverity.Error, |
| 43 | isEnabledByDefault: true, |
| 44 | description: "Семантика оверлея не смешивается с контролами зон IDE."); |
| 45 | |
| 46 | public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| 47 | ImmutableArray.Create(CockpitRule, AvaloniaRule, FeaturesUiChromeRule); |
| 48 | |
| 49 | public override void Initialize(AnalysisContext context) |
| 50 | { |
| 51 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| 52 | context.EnableConcurrentExecution(); |
| 53 | context.RegisterSymbolAction(AnalyzeNamedTypeSymbol, SymbolKind.NamedType); |
| 54 | context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective); |
| 55 | } |
| 56 | |
| 57 | private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) |
| 58 | { |
| 59 | if (context.Node is not UsingDirectiveSyntax u) |
| 60 | return; |
| 61 | if (!IsIdeDisplayFilePath(context.Node.SyntaxTree.FilePath)) |
| 62 | return; |
| 63 | |
| 64 | var name = u.Name?.ToString() ?? ""; |
| 65 | if (name.StartsWith("CascadeIDE.Cockpit", StringComparison.Ordinal) || name == "CascadeIDE.Cockpit") |
| 66 | { |
| 67 | context.ReportDiagnostic(Diagnostic.Create(CockpitRule, u.GetLocation(), name)); |
| 68 | return; |
| 69 | } |
| 70 | |
| 71 | if (name.StartsWith("Avalonia", StringComparison.Ordinal) || name == "Avalonia") |
| 72 | { |
| 73 | context.ReportDiagnostic(Diagnostic.Create(AvaloniaRule, u.GetLocation(), name)); |
| 74 | return; |
| 75 | } |
| 76 | |
| 77 | if (name.StartsWith("CascadeIDE.Features.UiChrome", StringComparison.Ordinal) |
| 78 | || name == "CascadeIDE.Features.UiChrome") |
| 79 | { |
| 80 | context.ReportDiagnostic(Diagnostic.Create(FeaturesUiChromeRule, u.GetLocation())); |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | private static void AnalyzeNamedTypeSymbol(SymbolAnalysisContext context) |
| 85 | { |
| 86 | if (context.Symbol is not INamedTypeSymbol named) |
| 87 | return; |
| 88 | if (named.TypeKind == TypeKind.Enum) |
| 89 | return; |
| 90 | if (!IsIdeDisplayNamespace(named.ContainingNamespace)) |
| 91 | return; |
| 92 | |
| 93 | foreach (var iface in named.AllInterfaces) |
| 94 | CheckType(context, named, iface, named.DeclaringSyntaxReferences); |
| 95 | |
| 96 | if (named.BaseType is { SpecialType: not SpecialType.System_Object }) |
| 97 | CheckType(context, named, named.BaseType, named.DeclaringSyntaxReferences); |
| 98 | |
| 99 | foreach (var member in named.GetMembers()) |
| 100 | { |
| 101 | switch (member) |
| 102 | { |
| 103 | case IFieldSymbol f when !f.IsImplicitlyDeclared: |
| 104 | CheckType(context, named, f.Type, member.DeclaringSyntaxReferences); |
| 105 | break; |
| 106 | case IPropertySymbol p when !p.IsImplicitlyDeclared: |
| 107 | CheckType(context, named, p.Type, member.DeclaringSyntaxReferences); |
| 108 | break; |
| 109 | case IMethodSymbol m when m is { MethodKind: MethodKind.Ordinary, IsImplicitlyDeclared: false }: |
| 110 | CheckType(context, named, m.ReturnType, member.DeclaringSyntaxReferences); |
| 111 | foreach (var par in m.Parameters) |
| 112 | CheckType(context, named, par.Type, member.DeclaringSyntaxReferences); |
| 113 | break; |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | private static void CheckType( |
| 119 | SymbolAnalysisContext context, |
| 120 | INamedTypeSymbol declaring, |
| 121 | ITypeSymbol type, |
| 122 | ImmutableArray<SyntaxReference> fallbackLocations) |
| 123 | { |
| 124 | VisitTypesForAvaloniaBan(type, context, declaring, fallbackLocations); |
| 125 | } |
| 126 | |
| 127 | private static void VisitTypesForAvaloniaBan( |
| 128 | ITypeSymbol type, |
| 129 | SymbolAnalysisContext context, |
| 130 | INamedTypeSymbol declaring, |
| 131 | ImmutableArray<SyntaxReference> fallbackLocations) |
| 132 | { |
| 133 | switch (type) |
| 134 | { |
| 135 | case IArrayTypeSymbol arr: |
| 136 | VisitTypesForAvaloniaBan(arr.ElementType, context, declaring, fallbackLocations); |
| 137 | return; |
| 138 | case ITypeParameterSymbol: |
| 139 | return; |
| 140 | case INamedTypeSymbol named: |
| 141 | if (named.IsGenericType) |
| 142 | { |
| 143 | foreach (var arg in named.TypeArguments) |
| 144 | VisitTypesForAvaloniaBan(arg, context, declaring, fallbackLocations); |
| 145 | } |
| 146 | |
| 147 | if (IsCockpitFamily(named)) |
| 148 | { |
| 149 | Report(context, CockpitRule, fallbackLocations, named.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); |
| 150 | return; |
| 151 | } |
| 152 | |
| 153 | if (IsFeaturesUiChromeFamily(named)) |
| 154 | { |
| 155 | Report(context, FeaturesUiChromeRule, fallbackLocations); |
| 156 | return; |
| 157 | } |
| 158 | |
| 159 | if (IsAvaloniaUiFamily(named)) |
| 160 | { |
| 161 | Report( |
| 162 | context, |
| 163 | AvaloniaRule, |
| 164 | fallbackLocations, |
| 165 | named.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); |
| 166 | } |
| 167 | |
| 168 | return; |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | private static void Report( |
| 173 | SymbolAnalysisContext context, |
| 174 | DiagnosticDescriptor rule, |
| 175 | ImmutableArray<SyntaxReference> fallbackLocations, |
| 176 | string? formatArg = null) |
| 177 | { |
| 178 | var location = PickLocation(fallbackLocations); |
| 179 | if (location is null) |
| 180 | return; |
| 181 | |
| 182 | var diagnostic = formatArg is null |
| 183 | ? Diagnostic.Create(rule, location) |
| 184 | : Diagnostic.Create(rule, location, formatArg); |
| 185 | context.ReportDiagnostic(diagnostic); |
| 186 | } |
| 187 | |
| 188 | private static Location? PickLocation(ImmutableArray<SyntaxReference> refs) |
| 189 | { |
| 190 | foreach (var r in refs) |
| 191 | { |
| 192 | var syntax = r.GetSyntax(); |
| 193 | if (syntax is null) |
| 194 | continue; |
| 195 | var loc = syntax.GetLocation(); |
| 196 | if (loc.IsInSource) |
| 197 | return loc; |
| 198 | } |
| 199 | |
| 200 | return null; |
| 201 | } |
| 202 | |
| 203 | private static bool IsIdeDisplayNamespace(INamespaceSymbol? ns) |
| 204 | { |
| 205 | if (ns is null) |
| 206 | return false; |
| 207 | var display = ns.ToDisplayString(); |
| 208 | return display.StartsWith("CascadeIDE.IdeDisplay", StringComparison.Ordinal); |
| 209 | } |
| 210 | |
| 211 | private static bool IsIdeDisplayFilePath(string? filePath) |
| 212 | { |
| 213 | if (string.IsNullOrEmpty(filePath)) |
| 214 | return false; |
| 215 | var n = filePath.Replace('\\', '/'); |
| 216 | return n.Contains("/IdeDisplay/", StringComparison.OrdinalIgnoreCase); |
| 217 | } |
| 218 | |
| 219 | private static bool IsAvaloniaUiFamily(ITypeSymbol type) |
| 220 | { |
| 221 | if (type is not INamedTypeSymbol named) |
| 222 | return false; |
| 223 | |
| 224 | for (var ns = type.ContainingNamespace; ns != null && !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) |
| 225 | { |
| 226 | if (ns.Name == "Avalonia") |
| 227 | return true; |
| 228 | } |
| 229 | |
| 230 | var asm = named.ContainingAssembly?.Name ?? ""; |
| 231 | if (asm.Equals("Avalonia", StringComparison.OrdinalIgnoreCase) || asm.StartsWith("Avalonia.", StringComparison.OrdinalIgnoreCase)) |
| 232 | return true; |
| 233 | if (asm.StartsWith("Dock.Avalonia", StringComparison.OrdinalIgnoreCase)) |
| 234 | return true; |
| 235 | |
| 236 | return false; |
| 237 | } |
| 238 | |
| 239 | private static bool IsCockpitFamily(INamedTypeSymbol named) |
| 240 | { |
| 241 | var display = named.ContainingNamespace?.ToDisplayString() ?? ""; |
| 242 | return display.StartsWith("CascadeIDE.Cockpit", StringComparison.Ordinal); |
| 243 | } |
| 244 | |
| 245 | private static bool IsFeaturesUiChromeFamily(INamedTypeSymbol named) |
| 246 | { |
| 247 | var display = named.ContainingNamespace?.ToDisplayString() ?? ""; |
| 248 | return display.StartsWith("CascadeIDE.Features.UiChrome", StringComparison.Ordinal); |
| 249 | } |
| 250 | } |
| 251 | |