diff --git a/src/DataverseAnalyzer/PluginDocumentationAnalyzer.cs b/src/DataverseAnalyzer/PluginDocumentationAnalyzer.cs new file mode 100644 index 0000000..c5e7224 --- /dev/null +++ b/src/DataverseAnalyzer/PluginDocumentationAnalyzer.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PluginDocumentationAnalyzer : DiagnosticAnalyzer +{ + private static readonly Lazy LazyRule = new(() => new DiagnosticDescriptor( + "CT0006", + Resources.CT0006_Title, + Resources.CT0006_MessageFormat, + "Documentation", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Resources.CT0006_Description)); + + public static DiagnosticDescriptor Rule => LazyRule.Value; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + if (!InheritsFromPlugin(classDeclaration)) + return; + + if (HasValidDocumentation(classDeclaration)) + return; + + var diagnostic = Diagnostic.Create( + Rule, + classDeclaration.Identifier.GetLocation(), + classDeclaration.Identifier.ValueText); + + context.ReportDiagnostic(diagnostic); + } + + private static bool InheritsFromPlugin(ClassDeclarationSyntax classDeclaration) + { + if (classDeclaration.BaseList is null) + return false; + + foreach (var baseType in classDeclaration.BaseList.Types) + { + var typeName = GetBaseTypeName(baseType.Type); + if (typeName == "Plugin") + return true; + } + + return false; + } + + private static string? GetBaseTypeName(TypeSyntax type) + { + return type switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + _ => null, + }; + } + + private static bool HasValidDocumentation(ClassDeclarationSyntax classDeclaration) + { + var leadingTrivia = classDeclaration.GetLeadingTrivia(); + + foreach (var trivia in leadingTrivia) + { + if (!trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia)) + continue; + + var structure = trivia.GetStructure(); + if (structure is null) + continue; + + if (HasInheritdoc(structure)) + return true; + + if (HasNonEmptySummary(structure)) + return true; + } + + return false; + } + + private static bool HasInheritdoc(SyntaxNode structure) + { + foreach (var node in structure.DescendantNodes()) + { + if (node is XmlEmptyElementSyntax emptyElement && + emptyElement.Name.LocalName.ValueText == "inheritdoc") + { + return true; + } + } + + return false; + } + + private static bool HasNonEmptySummary(SyntaxNode structure) + { + foreach (var node in structure.DescendantNodes()) + { + if (node is not XmlElementSyntax element) + continue; + + if (element.StartTag.Name.LocalName.ValueText != "summary") + continue; + + var content = element.Content; + foreach (var contentNode in content) + { + if (contentNode is XmlTextSyntax textSyntax) + { + var text = string.Concat(textSyntax.TextTokens.Select(t => t.ValueText)); + if (!string.IsNullOrWhiteSpace(text)) + return true; + } + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.Designer.cs b/src/DataverseAnalyzer/Resources.Designer.cs index 47abdf9..3b60c90 100644 --- a/src/DataverseAnalyzer/Resources.Designer.cs +++ b/src/DataverseAnalyzer/Resources.Designer.cs @@ -41,5 +41,11 @@ internal static class Resources internal static string CT0005_Description => GetString(nameof(CT0005_Description)); + internal static string CT0006_Title => GetString(nameof(CT0006_Title)); + + internal static string CT0006_MessageFormat => GetString(nameof(CT0006_MessageFormat)); + + internal static string CT0006_Description => GetString(nameof(CT0006_Description)); + private static string GetString(string name) => ResourceManager.GetString(name, CultureInfo.InvariantCulture) ?? name; } \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.resx b/src/DataverseAnalyzer/Resources.resx index aa5c8cc..4bf9345 100644 --- a/src/DataverseAnalyzer/Resources.resx +++ b/src/DataverseAnalyzer/Resources.resx @@ -109,4 +109,13 @@ Having multiple constructor parameters of the same dependency injection type (Service, Repository, Handler, Provider, Factory, Manager, Client) can lead to confusion and accidental parameter swapping. + + Plugin class missing XML documentation summary + + + Plugin class '{0}' should have a short XML documentation summary describing its purpose + + + Classes that inherit from Plugin should have a short XML summary comment explaining what the plugin does. + \ No newline at end of file diff --git a/tests/DataverseAnalyzer.Tests/PluginDocumentationAnalyzerTests.cs b/tests/DataverseAnalyzer.Tests/PluginDocumentationAnalyzerTests.cs new file mode 100644 index 0000000..16a6746 --- /dev/null +++ b/tests/DataverseAnalyzer.Tests/PluginDocumentationAnalyzerTests.cs @@ -0,0 +1,334 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer.Tests; + +public sealed class PluginDocumentationAnalyzerTests +{ + [Fact] + public async Task PluginSubclassWithoutXmlCommentShouldTrigger() + { + var source = """ + class Plugin { } + + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassWithEmptySummaryShouldTrigger() + { + var source = """ + class Plugin { } + + /// + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassWithWhitespaceSummaryShouldTrigger() + { + var source = """ + class Plugin { } + + /// + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassWithValidSummaryShouldNotTrigger() + { + var source = """ + class Plugin { } + + /// Handles account creation. + class CreateAccountPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginSubclassWithMultiLineSummaryShouldNotTrigger() + { + var source = """ + class Plugin { } + + /// + /// Handles account creation and validation. + /// + class CreateAccountPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ClassNotInheritingFromPluginShouldNotTrigger() + { + var source = """ + class MyClass { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ClassInheritingFromNonPluginBaseShouldNotTrigger() + { + var source = """ + class BaseClass { } + + class MyClass : BaseClass { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginSubclassWithOnlyRemarksShouldTrigger() + { + var source = """ + class Plugin { } + + /// Some remarks here. + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassWithInheritdocShouldNotTrigger() + { + var source = """ + class Plugin { } + + /// + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginSubclassWithInheritdocCrefShouldNotTrigger() + { + var source = """ + class Plugin { } + + /// + class MyPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task MultiplePluginSubclassesWithMixedDocsShouldTriggerOnlyForMissing() + { + var source = """ + class Plugin { } + + class UndocumentedPlugin : Plugin { } + + /// Documented plugin. + class DocumentedPlugin : Plugin { } + + class AnotherUndocumentedPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Equal(2, diagnostics.Length); + Assert.All(diagnostics, d => Assert.Equal("CT0006", d.Id)); + } + + [Fact] + public async Task PluginSubclassWithQualifiedBaseTypeShouldTrigger() + { + var source = """ + namespace MyNamespace + { + class Plugin { } + } + + class MyPlugin : MyNamespace.Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassWithQualifiedBaseTypeAndSummaryShouldNotTrigger() + { + var source = """ + namespace MyNamespace + { + class Plugin { } + } + + /// My plugin. + class MyPlugin : MyNamespace.Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NestedPluginSubclassShouldTrigger() + { + var source = """ + class Plugin { } + + class OuterClass + { + class NestedPlugin : Plugin { } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task NestedPluginSubclassWithSummaryShouldNotTrigger() + { + var source = """ + class Plugin { } + + class OuterClass + { + /// Nested plugin. + class NestedPlugin : Plugin { } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginBaseClassItselfShouldNotTrigger() + { + var source = """ + class Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ClassWithPluginInNameButNotInheritingShouldNotTrigger() + { + var source = """ + class MyPluginHelper { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginSubclassWithSummaryAndOtherTagsShouldNotTrigger() + { + var source = """ + class Plugin { } + + /// Handles account creation. + /// Additional details here. + class CreateAccountPlugin : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task PluginSubclassImplementingInterfaceShouldTrigger() + { + var source = """ + class Plugin { } + interface IMyInterface { } + + class MyPlugin : Plugin, IMyInterface { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0006", diagnostics[0].Id); + } + + [Fact] + public async Task PluginSubclassImplementingInterfaceWithSummaryShouldNotTrigger() + { + var source = """ + class Plugin { } + interface IMyInterface { } + + /// My plugin. + class MyPlugin : Plugin, IMyInterface { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DiagnosticContainsClassName() + { + var source = """ + class Plugin { } + + class TestPluginName : Plugin { } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Contains("TestPluginName", diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + private static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new PluginDocumentationAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + return diagnostics.Where(d => d.Id == "CT0006").ToArray(); + } +} \ No newline at end of file