diff --git a/docs/AnalyzerReleases.Unshipped.md b/docs/AnalyzerReleases.Unshipped.md index 549d30e..9d12090 100644 --- a/docs/AnalyzerReleases.Unshipped.md +++ b/docs/AnalyzerReleases.Unshipped.md @@ -3,3 +3,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- PA0003 | Usage | Error | PostTypeAttributeOnlyForClassesInheritingPostAnalyzer, [Documentation](PA0003/README.md) PA0004 | Usage | Error | PageTypeAttributeOnlyForClassesInheritingPageAnalyzer, [Documentation](PA0004/README.md) +PA0005 | Usage | Error | SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzer, [Documentation](PA0005/README.md) diff --git a/docs/PA0005/README.md b/docs/PA0005/README.md new file mode 100644 index 0000000..2e06969 --- /dev/null +++ b/docs/PA0005/README.md @@ -0,0 +1,10 @@ +# PA0005 - Classes marked with the SiteTypeAttribute should inherit SiteContent + +The analyzer will check classes marked with the `Piranha.AttributeBuilder.SiteTypeAttribute` and mark the class as violating if it does not inherit `Piranha.Models.SiteContent`. + +| Severity | Category | +|----------|----------| +| Error | Usage | + +## Available code fixes +None currently. diff --git a/src/Piranha.Analyzers/Constants.cs b/src/Piranha.Analyzers/Constants.cs index c8d4c07..f029c7e 100644 --- a/src/Piranha.Analyzers/Constants.cs +++ b/src/Piranha.Analyzers/Constants.cs @@ -21,6 +21,7 @@ namespace Piranha.Analyzers { internal const string PiranhaAttributeBuilderPageTypeAttribute = "Piranha.AttributeBuilder.PageTypeAttribute"; internal const string PiranhaAttributeBuilderPostTypeAttribute = "Piranha.AttributeBuilder.PostTypeAttribute"; + internal const string PiranhaAttributeBuilderSiteTypeAttribute = "Piranha.AttributeBuilder.SiteTypeAttribute"; internal const string PiranhaExtendFieldAttribute = "Piranha.Extend.FieldAttribute"; internal const string PiranhaExtendRegionAttribute = "Piranha.Extend.RegionAttribute"; @@ -39,6 +40,7 @@ namespace Piranha.Analyzers internal const string PiranhaModelsPage = "Piranha.Models.Page"; internal const string PiranhaModelsPost = "Piranha.Models.Post"; + internal const string PiranhaModelsSiteContent = "Piranha.Models.SiteContent"; } } } diff --git a/src/Piranha.Analyzers/Resources.Designer.cs b/src/Piranha.Analyzers/Resources.Designer.cs index 698cde5..3db9a16 100644 --- a/src/Piranha.Analyzers/Resources.Designer.cs +++ b/src/Piranha.Analyzers/Resources.Designer.cs @@ -167,5 +167,32 @@ namespace Piranha.Analyzers { return ResourceManager.GetString("PostTypeAttributeOnlyForClassesInheritingPostAnalyzerTitle", resourceCulture); } } + + /// + /// Looks up a localized string similar to [SiteType] should only be applied to classes inheriting SiteContent.. + /// + internal static string SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerDescription { + get { + return ResourceManager.GetString("SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} does not extend SiteContent, but is marked with [SiteType]. + /// + internal static string SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerMessageFormat { + get { + return ResourceManager.GetString("SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to [SiteType] should only be applied to classes inheriting SiteContent.. + /// + internal static string SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerTitle { + get { + return ResourceManager.GetString("SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerTitle", resourceCulture); + } + } } } diff --git a/src/Piranha.Analyzers/Resources.resx b/src/Piranha.Analyzers/Resources.resx index 415601b..bb0ef0e 100644 --- a/src/Piranha.Analyzers/Resources.resx +++ b/src/Piranha.Analyzers/Resources.resx @@ -159,4 +159,13 @@ [PageType] should only be applied to classes inheriting Page. + + [SiteType] should only be applied to classes inheriting SiteContent. + + + {0} does not extend SiteContent, but is marked with [SiteType] + + + [SiteType] should only be applied to classes inheriting SiteContent. + \ No newline at end of file diff --git a/src/Piranha.Analyzers/SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzer.cs b/src/Piranha.Analyzers/SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzer.cs new file mode 100644 index 0000000..c44c3c4 --- /dev/null +++ b/src/Piranha.Analyzers/SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzer.cs @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2020 Mikael Lindemann + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + * https://github.com/piranhacms/piranha.core.analyzers + * + */ + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Piranha.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "PA0005"; + private const string Category = "Usage"; + private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.SiteTypeAttributeOnlyForClassesInheritingSiteAnalyzerDescription), Resources.ResourceManager, typeof(Resources)); + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeSyntaxNode, SyntaxKind.Attribute); + } + + private static void AnalyzeSyntaxNode(SyntaxNodeAnalysisContext context) + { + if (!(context.Node is AttributeSyntax attribute)) + { + return; + } + + var regionAttributeType = context.Compilation.GetTypeByMetadataName(Constants.Types.PiranhaAttributeBuilderSiteTypeAttribute); + + if (regionAttributeType == null) + { + return; + } + + var attributeType = context.SemanticModel.GetTypeInfo(attribute, context.CancellationToken); + + if (!regionAttributeType.Equals(attributeType.ConvertedType, SymbolEqualityComparer.IncludeNullability)) + { + return; + } + + if (!(attribute.Parent is AttributeListSyntax attributeList)) + { + return; + } + + if (!(attributeList.Parent is ClassDeclarationSyntax @class)) + { + return; + } + + var siteType = context.Compilation.GetTypeByMetadataName($"{Constants.Types.PiranhaModelsSiteContent}`1"); + + if (siteType == null) + { + return; + } + + var unboundSiteType = siteType.ConstructUnboundGenericType(); + + if (@class.BaseList != null && @class.BaseList.Types.Count != 0) + { + foreach (var type in @class.BaseList.Types) + { + if (!(context.SemanticModel.GetTypeInfo(type.Type, context.CancellationToken).ConvertedType is INamedTypeSymbol convertedType)) + { + continue; + } + + if (InheritsSite(convertedType, siteType, unboundSiteType)) + { + return; + } + } + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, @class.GetLocation(), @class.Identifier.ValueText)); + } + + private static bool InheritsSite(INamedTypeSymbol type, INamedTypeSymbol siteType, INamedTypeSymbol unboundSiteType) + { + if (type == null) + { + return false; + } + + if (type.SpecialType == SpecialType.System_Object) + { + return false; + } + + if (!type.IsGenericType || !unboundSiteType.Equals(type.ConstructUnboundGenericType(), SymbolEqualityComparer.IncludeNullability)) + { + var baseType = type.BaseType; + if (baseType != null && InheritsSite(baseType, siteType, unboundSiteType)) + { + return true; + } + } + + if (type.TypeArguments.Length != 1) + { + return false; + } + + var constructedType = siteType.Construct(type.TypeArguments.First()); + + if (constructedType.Equals(type, SymbolEqualityComparer.IncludeNullability)) + { + return true; + } + + return false; + } + } +} diff --git a/test/Piranha.Analyzers.Test/SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzerTests.cs b/test/Piranha.Analyzers.Test/SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzerTests.cs new file mode 100644 index 0000000..53b251a --- /dev/null +++ b/test/Piranha.Analyzers.Test/SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzerTests.cs @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2020 Mikael Lindemann + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + * https://github.com/piranhacms/piranha.core.analyzers + * + */ + +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Threading.Tasks; +using TestHelper; +using Xunit; + +namespace Piranha.Analyzers.Test +{ + public class SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzerTests : CodeFixVerifier + { + [Fact] + public async Task ClassWithSiteTypeAttributeDirectlyInheritingSiteContent() + { + var test = string.Join(Environment.NewLine, + "using Piranha.AttributeBuilder;", + "using Piranha.Extend;", + "using Piranha.Extend.Fields;", + "using Piranha.Models;", + "", + "namespace ConsoleApplication1", + "{", + " [SiteType]", + " class TypeName : SiteContent", + " {", + " [Region]", + " public HeroRegion Hero { get; set; }", + " }", + " class HeroRegion", + " {", + " [Field]", + " public AudioField Audio { get; set; }", + " [Field]", + " public AudioField Audio2 { get; set; }", + " }", + "}"); + + await VerifyCSharpDiagnosticAsync(test); + } + + [Fact] + public async Task ClassWithSiteTypeAttributeIndirectlyInheritingSiteContent() + { + var test = string.Join(Environment.NewLine, + "using Piranha.AttributeBuilder;", + "using Piranha.Extend;", + "using Piranha.Extend.Fields;", + "using Piranha.Models;", + "", + "namespace ConsoleApplication1", + "{", + " [SiteType]", + " class TypeName : MySiteContent", + " {", + " [Region]", + " public HeroRegion Hero { get; set; }", + " }", + " class MySiteContent : SiteContent", + " {}", + " class HeroRegion", + " {", + " [Field]", + " public AudioField Audio { get; set; }", + " [Field]", + " public AudioField Audio2 { get; set; }", + " }", + "}"); + + await VerifyCSharpDiagnosticAsync(test); + } + + [Fact] + public async Task ClassWithSiteTypeAttributeIndirectlyInheritingSiteContent2() + { + var test = string.Join(Environment.NewLine, + "using Piranha.AttributeBuilder;", + "using Piranha.Extend;", + "using Piranha.Extend.Fields;", + "using Piranha.Models;", + "", + "namespace ConsoleApplication1", + "{", + " [SiteType]", + " class TypeName : MySiteContent2", + " {", + " [Region]", + " public HeroRegion Hero { get; set; }", + " }", + " abstract class MySiteContent : SiteContent", + " {}", + " abstract class MySiteContent2 : MySiteContent", + " {}", + " class HeroRegion", + " {", + " [Field]", + " public AudioField Audio { get; set; }", + " [Field]", + " public AudioField Audio2 { get; set; }", + " }", + "}"); + + await VerifyCSharpDiagnosticAsync(test); + } + + [Fact] + public async Task ClassWithSiteTypeAttributeWithoutSiteContentAsBaseClass() + { + var test = string.Join(Environment.NewLine, + "using Piranha.AttributeBuilder;", + "using Piranha.Extend;", + "using Piranha.Extend.Fields;", + "using Piranha.Models;", + "", + "namespace ConsoleApplication1", + "{", + " [SiteType]", + " class TypeName", + " {", + " [Region]", + " public HeroRegion Hero { get; set; }", + " }", + " class HeroRegion", + " {", + " [Field]", + " public AudioField Audio { get; set; }", + " [Field]", + " public AudioField Audio2 { get; set; }", + " }", + "}"); + var expected = new DiagnosticResult + { + Id = "PA0005", + Message = "TypeName does not extend SiteContent, but is marked with [SiteType]", + Severity = DiagnosticSeverity.Error, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 8, 5) + } + }; + + await VerifyCSharpDiagnosticAsync(test, expected); + } + + + + [Fact] + public async Task ClassWithSiteTypeAttributeWithCustomSiteContentAsBaseClass() + { + var test = string.Join(Environment.NewLine, + "using Piranha.AttributeBuilder;", + "using Piranha.Extend;", + "using Piranha.Extend.Fields;", + "using Piranha.Models;", + "", + "namespace ConsoleApplication1", + "{", + " [SiteType]", + " class TypeName : SiteContent", + " {", + " [Region]", + " public HeroRegion Hero { get; set; }", + " }", + " class SiteContent where T : SiteContent", + " {}", + " class HeroRegion", + " {", + " [Field]", + " public AudioField Audio { get; set; }", + " [Field]", + " public AudioField Audio2 { get; set; }", + " }", + "}"); + var expected = new DiagnosticResult + { + Id = "PA0005", + Message = "TypeName does not extend SiteContent, but is marked with [SiteType]", + Severity = DiagnosticSeverity.Error, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 8, 5) + } + }; + + await VerifyCSharpDiagnosticAsync(test, expected); + } + + + protected override CodeFixProvider GetCSharpCodeFixProvider() + { + return null; + } + + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() + { + return new SiteTypeAttributeOnlyForClassesInheritingSiteContentAnalyzer(); + } + } +}