From 66bad3c4e34962c70723c899baaec57291308d3b Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 1 Feb 2026 14:38:55 +0100 Subject: [PATCH] Add "Xml Documentation" to DocumentationCop --- src/ALCops.Common/Reflection/EnumProvider.cs | 6 ++ .../HasDiagnostic/DuplicateParameter.al | 12 +++ .../HasDiagnostic/DuplicateReturns.al | 12 +++ .../HasDiagnostic/Parameter.al | 28 +++++ .../HasDiagnostic/Return.al | 19 ++++ .../HasDiagnostic/TryFunction.al | 11 ++ .../NoDiagnostic/NoDocumentationComment.al | 8 ++ .../NoDiagnostic/Parameter.al | 11 ++ .../NoDiagnostic/Return.al | 11 ++ .../NoDiagnostic/TryFunction.al | 12 +++ .../XmlDocumentationProcedureConsistency.cs | 48 +++++++++ .../ALCops.DocumentationCopAnalyzers.cs | 27 +++++ .../ALCops.DocumentationCopAnalyzers.resx | 9 ++ .../XmlDocumentationProcedureConsistency.cs | 102 ++++++++++++++++++ .../DiagnosticDescriptors.cs | 10 ++ src/ALCops.DocumentationCop/DiagnosticIds.cs | 1 + 16 files changed, 327 insertions(+) create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateParameter.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateReturns.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Parameter.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Return.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/TryFunction.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/NoDocumentationComment.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Parameter.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Return.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/TryFunction.al create mode 100644 src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/XmlDocumentationProcedureConsistency.cs create mode 100644 src/ALCops.DocumentationCop/Analyzers/XmlDocumentationProcedureConsistency.cs diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 673d639..3b7b0b6 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -1606,6 +1606,10 @@ public static class SyntaxKind new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.WhileKeyword))); private static readonly Lazy _whileStatement = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.WhileStatement))); + private static readonly Lazy _xmlElement = + new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.XmlElement))); + private static readonly Lazy _xmlNameAttribute = + new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.XmlNameAttribute))); private static readonly Lazy _xmlPortKeyword = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.XmlPortKeyword))); private static readonly Lazy _xmlPortObject = @@ -1749,7 +1753,9 @@ public static class SyntaxKind public static NavCodeAnalysis.SyntaxKind VarSection => _varSection.Value; public static NavCodeAnalysis.SyntaxKind WhileKeyword => _whileKeyword.Value; public static NavCodeAnalysis.SyntaxKind WhileStatement => _whileStatement.Value; + public static NavCodeAnalysis.SyntaxKind XmlElement => _xmlElement.Value; public static NavCodeAnalysis.SyntaxKind XmlPortKeyword => _xmlPortKeyword.Value; + public static NavCodeAnalysis.SyntaxKind XmlNameAttribute => _xmlNameAttribute.Value; public static NavCodeAnalysis.SyntaxKind XmlPortObject => _xmlPortObject.Value; } diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateParameter.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateParameter.al new file mode 100644 index 0000000..65712bf --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateParameter.al @@ -0,0 +1,12 @@ +codeunit 50100 MyCodeunit +{ + /// + /// Duplicate documentation parameter. + /// + /// The parameter documentation. + /// [|The duplicate parameter documentation.|] + procedure MyProcedure(Value: Boolean) + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateReturns.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateReturns.al new file mode 100644 index 0000000..e5600c8 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/DuplicateReturns.al @@ -0,0 +1,12 @@ +codeunit 50100 MyCodeunit +{ + /// + /// Duplicate documentation returns. + /// + /// A Value. + /// [|A Value (duplicate).|] + procedure MyProcedure() Value: Boolean + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Parameter.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Parameter.al new file mode 100644 index 0000000..3fbf0d6 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Parameter.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + /// + /// Documentation comment parameter but no procedure parameter. + /// + /// [|The value.|] + procedure NoParameter() + begin + + end; + + /// + /// Procedure parameter but no documentation comment parameter. + /// + procedure ParameterButNoComment([|Value: Boolean|]) + begin + + end; + + /// + /// Parameter name mismatch. + /// + /// [|The value.|] + procedure NameMissmatch([|Value: Boolean|]) + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Return.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Return.al new file mode 100644 index 0000000..2fedd71 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/Return.al @@ -0,0 +1,19 @@ +codeunit 50100 MyCodeunit +{ + /// + /// Documentation comment with returns but no return value. + /// + /// [|Some value.|] + procedure DoesNotReturn() + begin + + end; + + /// + /// Return value but no documentation comment returns. + /// + procedure DoesReturnButNoComment() [|ReturnValue: Boolean|] + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/TryFunction.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/TryFunction.al new file mode 100644 index 0000000..d892208 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/HasDiagnostic/TryFunction.al @@ -0,0 +1,11 @@ +codeunit 50100 MyCodeunit +{ + /// + /// A method with TryFunction attribute has a implicit (boolean) return value. + /// + [[|TryFunction|]] + procedure MyTryFunction() + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/NoDocumentationComment.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/NoDocumentationComment.al new file mode 100644 index 0000000..5e6ae06 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/NoDocumentationComment.al @@ -0,0 +1,8 @@ +codeunit 50100 MyCodeunit +{ + [|// just a normal procedure without a (structured) documentation comment + procedure NoDocumentationComment(Param: Boolean) ReturnValue: Boolean + begin + + end;|] +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Parameter.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Parameter.al new file mode 100644 index 0000000..a4f24c6 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Parameter.al @@ -0,0 +1,11 @@ +codeunit 50100 MyCodeunit +{ + [|/// + /// Has a valid documentation comment. + /// + /// The parameter. + procedure ValidWithParameter(Param: Boolean) + begin + + end;|] +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Return.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Return.al new file mode 100644 index 0000000..51f1e83 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/Return.al @@ -0,0 +1,11 @@ +codeunit 50100 MyCodeunit +{ + [|/// + /// Has a valid documentation comment. + /// + /// The return value. + procedure ValidWithReturn() ReturnValue: Boolean + begin + + end;|] +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/TryFunction.al b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/TryFunction.al new file mode 100644 index 0000000..34d7a98 --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/NoDiagnostic/TryFunction.al @@ -0,0 +1,12 @@ +codeunit 50100 MyCodeunit +{ + /// + /// A method with TryFunction attribute has a implicit (boolean) return value. + /// + /// Returns success (true/false) + [[|TryFunction|]] + procedure MyTryFunction() + begin + + end; +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/XmlDocumentationProcedureConsistency.cs b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/XmlDocumentationProcedureConsistency.cs new file mode 100644 index 0000000..e47d5fc --- /dev/null +++ b/src/ALCops.DocumentationCop.Test/Rules/XmlDocumentationProcedureConsistency/XmlDocumentationProcedureConsistency.cs @@ -0,0 +1,48 @@ +using RoslynTestKit; + +namespace ALCops.DocumentationCop.Test +{ + public class XmlDocumentationProcedureConsistency : NavCodeAnalysisBase + { + private AnalyzerTestFixture _fixture; + private string _testCasePath; + + [SetUp] + public void Setup() + { + _fixture = RoslynFixtureFactory.Create(); + + _testCasePath = Path.Combine( + Directory.GetParent( + Environment.CurrentDirectory)!.Parent!.Parent!.FullName, + Path.Combine("Rules", nameof(XmlDocumentationProcedureConsistency))); + } + + [Test] + [TestCase("Return")] + [TestCase("Parameter")] + [TestCase("DuplicateParameter")] + [TestCase("DuplicateReturns")] + [TestCase("TryFunction")] + public async Task HasDiagnostic(string testCase) + { + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) + .ConfigureAwait(false); + + _fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.XmlDocumentationProcedureConsistency); + } + + [Test] + [TestCase("Return")] + [TestCase("Parameter")] + [TestCase("NoDocumentationComment")] + [TestCase("TryFunction")] + public async Task NoDiagnostic(string testCase) + { + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) + .ConfigureAwait(false); + + _fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.XmlDocumentationProcedureConsistency); + } + } +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.cs b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.cs index 6f39896..2c26b2e 100644 --- a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.cs +++ b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.cs @@ -164,5 +164,32 @@ internal static string WriteToFlowFieldRequiresCommentTitle { return ResourceManager.GetString("WriteToFlowFieldRequiresCommentTitle", resourceCulture); } } + + /// + /// Looks up a localized string similar to The XML documentation for a procedure must accurately reflect its signature.. + /// + internal static string XmlDocumentationProcedureConsistencyDescription { + get { + return ResourceManager.GetString("XmlDocumentationProcedureConsistencyDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The XML documentation does not match the procedure signature.. + /// + internal static string XmlDocumentationProcedureConsistencyMessageFormat { + get { + return ResourceManager.GetString("XmlDocumentationProcedureConsistencyMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to XML documentation must match the procedure signature. + /// + internal static string XmlDocumentationProcedureConsistencyTitle { + get { + return ResourceManager.GetString("XmlDocumentationProcedureConsistencyTitle", resourceCulture); + } + } } } diff --git a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx index d8c31b9..3d4a5d3 100644 --- a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx +++ b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx @@ -153,4 +153,13 @@ FlowFields are calculated fields and are not intended to be written to. Writing to a FlowField, whether accidental or intentional, can lead to runtime errors. + + XML documentation must match the procedure signature + + + The XML documentation does not match the procedure signature. + + + The XML documentation for a procedure must accurately reflect its signature. + \ No newline at end of file diff --git a/src/ALCops.DocumentationCop/Analyzers/XmlDocumentationProcedureConsistency.cs b/src/ALCops.DocumentationCop/Analyzers/XmlDocumentationProcedureConsistency.cs new file mode 100644 index 0000000..784be05 --- /dev/null +++ b/src/ALCops.DocumentationCop/Analyzers/XmlDocumentationProcedureConsistency.cs @@ -0,0 +1,102 @@ +using System.Collections.Immutable; +using ALCops.Common.Extensions; +using ALCops.Common.Reflection; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Syntax; +using Microsoft.Dynamics.Nav.CodeAnalysis.Utilities; + +namespace ALCops.DocumentationCop.Analyzers; + +[DiagnosticAnalyzer] +public sealed class XmlDocumentationProcedureConsistency : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + DiagnosticDescriptors.XmlDocumentationProcedureConsistency); + + public override void Initialize(AnalysisContext context) => + context.RegisterSyntaxNodeAction( + AnalyzeDocumentationComments, + EnumProvider.SyntaxKind.MethodDeclaration); + + private void AnalyzeDocumentationComments(SyntaxNodeAnalysisContext ctx) + { + if (ctx.IsObsolete() || ctx.Node is not MethodDeclarationSyntax methodDeclarationSyntax) + return; + + var docCommentTrivia = methodDeclarationSyntax.GetLeadingTrivia().FirstOrDefault(trivia => trivia.Kind == EnumProvider.SyntaxKind.SingleLineDocumentationCommentTrivia); + if (docCommentTrivia.IsKind(EnumProvider.SyntaxKind.None)) + return; // no documentation comment exists + + Dictionary docCommentParameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + XmlElementSyntax? docCommentReturns = null; + + var docCommentStructure = (DocumentationCommentTriviaSyntax)docCommentTrivia.GetStructure(); + var docCommentElements = docCommentStructure.Content.Where(xmlNode => xmlNode.Kind == EnumProvider.SyntaxKind.XmlElement); + + // evaluate documentation comment syntax + foreach (XmlElementSyntax element in docCommentElements.Cast()) + { + switch (element.StartTag.Name.LocalName.Text.ToLowerInvariant()) + { + case "param": + var nameAttribute = (XmlNameAttributeSyntax)element.StartTag.Attributes.First(att => att.IsKind(EnumProvider.SyntaxKind.XmlNameAttribute)); + var parameterName = nameAttribute.Identifier.GetText().ToString(); + if (!docCommentParameters.ContainsKey(parameterName)) + docCommentParameters.Add(parameterName, element); + else + // report diagnostic for duplicate parameter documentation + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, element.GetLocation())); + break; + case "returns": + if (docCommentReturns is not null) + // report diagnostic for duplicate returns documentation + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, element.GetLocation())); + docCommentReturns = element; + break; + } + } + + // excess documentation comment return value + if (docCommentReturns is not null && methodDeclarationSyntax.ReturnValue is null) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, docCommentReturns.GetLocation())); + + // return value without documentation comment + if (docCommentReturns is null && (methodDeclarationSyntax.ReturnValue is not null)) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, methodDeclarationSyntax.ReturnValue.GetLocation())); + + // method with TryFunction decorator without return in documentation comment + if (docCommentReturns is null) + { + var tryFunctionAttribute = + methodDeclarationSyntax.Attributes + .FirstOrDefault(attr => + string.Equals( + attr.Name.Identifier.ValueText?.UnquoteIdentifier(), + "TryFunction", + StringComparison.OrdinalIgnoreCase)); + + if (tryFunctionAttribute is not null) + { + ctx.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.XmlDocumentationProcedureConsistency, + tryFunctionAttribute.Name.GetLocation())); + } + } + + // check documentation comment parameters against method syntax + foreach (var docCommentParameter in docCommentParameters) + { + if (!methodDeclarationSyntax.ParameterList.Parameters.Any(param => (param.Name.Identifier.ValueText?.UnquoteIdentifier() ?? string.Empty).Equals(docCommentParameter.Key, StringComparison.OrdinalIgnoreCase))) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, docCommentParameter.Value.GetLocation())); + } + + // check method parameters against documentation comment syntax + foreach (var methodParameter in methodDeclarationSyntax.ParameterList.Parameters) + { + if (!docCommentParameters.Any(docParam => docParam.Key.Equals(methodParameter.Name.Identifier.ValueText?.UnquoteIdentifier(), StringComparison.OrdinalIgnoreCase))) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.XmlDocumentationProcedureConsistency, methodParameter.GetLocation())); + } + } +} \ No newline at end of file diff --git a/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs b/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs index 8204d64..cf23b60 100644 --- a/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs +++ b/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs @@ -45,6 +45,16 @@ public static class DiagnosticDescriptors description: DocumentationCopAnalyzers.WriteToFlowFieldRequiresCommentDescription, helpLinkUri: GetHelpUri(DiagnosticIds.WriteToFlowFieldRequiresComment)); + public static readonly DiagnosticDescriptor XmlDocumentationProcedureConsistency = new( + id: DiagnosticIds.XmlDocumentationProcedureConsistency, + title: DocumentationCopAnalyzers.XmlDocumentationProcedureConsistencyTitle, + messageFormat: DocumentationCopAnalyzers.XmlDocumentationProcedureConsistencyMessageFormat, + category: Category.Design, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: DocumentationCopAnalyzers.XmlDocumentationProcedureConsistencyDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.XmlDocumentationProcedureConsistency)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/documentationcop/{0}/", identifier.ToLower()); diff --git a/src/ALCops.DocumentationCop/DiagnosticIds.cs b/src/ALCops.DocumentationCop/DiagnosticIds.cs index 10fc39c..4b25f7c 100644 --- a/src/ALCops.DocumentationCop/DiagnosticIds.cs +++ b/src/ALCops.DocumentationCop/DiagnosticIds.cs @@ -6,4 +6,5 @@ public static class DiagnosticIds public static readonly string WriteToFlowFieldRequiresComment = "DC0002"; public static readonly string EmptyStatementRequiresComment = "DC0003"; public static readonly string PublicProcedureRequiresDocumentation = "DC0004"; + public static readonly string XmlDocumentationProcedureConsistency = "DC0005"; } \ No newline at end of file