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