diff --git a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs index b99bf81abaf..46af17c1e0b 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs @@ -104,6 +104,14 @@ internal static class DiagDescriptors description: Resources.PublishedSymbolsCantChangeDescription, defaultSeverity: DiagnosticSeverity.Warning); + public static DiagnosticDescriptor InternalReferencedInPublicDoc { get; } = Make( + id: "LA0008", + messageFormat: Resources.InternalReferencedInPublicDocMessage, + title: Resources.InternalReferencedInPublicDocTitle, + category: Correctness, + description: Resources.InternalReferencedInPublicDocDescription, + defaultSeverity: DiagnosticSeverity.Warning); + private static DiagnosticDescriptor Make(string id, string title, string description, string messageFormat, string category, DiagnosticSeverity defaultSeverity) => new(id, title, messageFormat, category, defaultSeverity, true, description); } diff --git a/src/Analyzers/Microsoft.Analyzers.Local/InternalReferencedInPublicDocAnalyzer.cs b/src/Analyzers/Microsoft.Analyzers.Local/InternalReferencedInPublicDocAnalyzer.cs new file mode 100644 index 00000000000..e83ae1f28f8 --- /dev/null +++ b/src/Analyzers/Microsoft.Analyzers.Local/InternalReferencedInPublicDocAnalyzer.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.Extensions.LocalAnalyzers.Utilities; + +namespace Microsoft.Extensions.LocalAnalyzers; + +/// +/// C# analyzer that warns about referencing internal symbols in public xml documentation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class InternalReferencedInPublicDocAnalyzer : DiagnosticAnalyzer +{ + private static readonly ImmutableArray _supportedDiagnostics = ImmutableArray.Create(DiagDescriptors.InternalReferencedInPublicDoc); + + private static MemberDeclarationSyntax? FindDocumentedSymbol(XmlCrefAttributeSyntax crefNode) + { + // Find the documentation comment the cref node is part of + var documentationComment = crefNode.Ancestors(ascendOutOfTrivia: false).OfType().FirstOrDefault(); + if (documentationComment == null) + { + return null; + } + + // Find documented symbol simply as first parent that is a declaration + // If the comment is not placed above any declaration, this takes the enclosing declaration + var symbolNode = crefNode.Ancestors().OfType().FirstOrDefault(); + if (symbolNode == null) + { + return null; + } + + // To filter out the cases when enclosing declaration is taken, + // make sure that the comment of found symbol is the same as the comment of cref being analyzed + var symbolComment = symbolNode.GetLeadingTrivia() + .Select(trivia => trivia.GetStructure()) + .OfType() + .FirstOrDefault(); + if (symbolComment != documentationComment) + { + return null; + } + + return symbolNode; + } + + private static bool IsNodeExternallyVisible(MemberDeclarationSyntax memberNode) + { + // In a way, the code replicates SymbolExtensions.IsExternallyVisible on syntax tree level + // It traverses up to namespace declaration and checks if all levels are externally visible + MemberDeclarationSyntax? node = memberNode; + while (node != null && !IsNamespace(node)) + { + bool isPublic = false; + bool isProtected = false; + bool isPrivate = false; + bool hasModifiers = false; + foreach (var modifier in node.Modifiers) + { + switch (modifier.Text) + { + case "public": + isPublic = true; + break; + case "protected": + isProtected = true; + break; + case "private": + isPrivate = true; + break; + } + + hasModifiers = true; + } + + if (!hasModifiers // no modifiers => internal, not visible + || isPrivate // private and private protected are both not visible + || (!isPublic && !isProtected) // public and protected are only other externally visible options + ) + { + return false; + } + + node = node.Parent as MemberDeclarationSyntax; + } + + return true; + + static bool IsNamespace(MemberDeclarationSyntax n) => + n is BaseNamespaceDeclarationSyntax; + } + + /// + public override ImmutableArray SupportedDiagnostics => _supportedDiagnostics; + + /// + public override void Initialize(AnalysisContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(ValidateCref, SyntaxKind.XmlCrefAttribute); + } + + private void ValidateCref(SyntaxNodeAnalysisContext context) + { + var crefNode = (XmlCrefAttributeSyntax)context.Node; + if (crefNode.IsMissing) + { + return; + } + + var symbolNode = FindDocumentedSymbol(crefNode); + if (symbolNode == null) + { + return; + } + + // Only externally visible symbols should be considered + // Sometimes (for fields and events) the symbol is unknown + // In such a case, use nodes instead of symbols + var symbol = context.SemanticModel.GetDeclaredSymbol(symbolNode); + var isExternallyVisible = symbol?.IsExternallyVisible() ?? IsNodeExternallyVisible(symbolNode); + if (!isExternallyVisible) + { + return; + } + + var referencedName = crefNode.Cref.ToString(); + if (string.IsNullOrWhiteSpace(referencedName)) + { + return; + } + + // Find what the cref attribute references; only successful binding is considered now, candidates aren't analyzed + var referencedSymbol = context.SemanticModel.GetSymbolInfo(crefNode.Cref).Symbol; + if (referencedSymbol == null) + { + return; + } + + // Report referencing a not externally visible symbol + if (!referencedSymbol.IsExternallyVisible()) + { + var diagnostic = Diagnostic.Create(DiagDescriptors.InternalReferencedInPublicDoc, crefNode.Cref.GetLocation(), referencedName); + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/src/Analyzers/Microsoft.Analyzers.Local/Resources.Designer.cs b/src/Analyzers/Microsoft.Analyzers.Local/Resources.Designer.cs index d19c3e90704..b9868d9fc50 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/Resources.Designer.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/Resources.Designer.cs @@ -114,6 +114,33 @@ internal static string ExperimentalSymbolsCantBeMarkedObsoleteTitle { } } + /// + /// Looks up a localized string similar to Externally visible XML documentation references an internal member, this will perplex the readers.. + /// + internal static string InternalReferencedInPublicDocDescription { + get { + return ResourceManager.GetString("InternalReferencedInPublicDocDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove the reference or make '{0}' externally visible; also consider making the referencing member private or internal. + /// + internal static string InternalReferencedInPublicDocMessage { + get { + return ResourceManager.GetString("InternalReferencedInPublicDocMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Externally visible documentation references internals. + /// + internal static string InternalReferencedInPublicDocTitle { + get { + return ResourceManager.GetString("InternalReferencedInPublicDocTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Symbols being added to the public API of an assembly must be marked as experimental until they have been appoved. /// diff --git a/src/Analyzers/Microsoft.Analyzers.Local/Resources.resx b/src/Analyzers/Microsoft.Analyzers.Local/Resources.resx index 704117a6d59..d438082456b 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/Resources.resx +++ b/src/Analyzers/Microsoft.Analyzers.Local/Resources.resx @@ -204,4 +204,13 @@ Symbols that have been removed from the public API of an assembly must be marked as obsolete - \ No newline at end of file + + Externally visible XML documentation references an internal member, this will perplex the readers. + + + Remove the reference or make '{0}' externally visible; also consider making the referencing member private or internal + + + Externally visible documentation references internals + + diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Latency/RequestCheckpointConstants.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Latency/RequestCheckpointConstants.cs index 02fed943a5e..e03d87bf4c9 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Latency/RequestCheckpointConstants.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Latency/RequestCheckpointConstants.cs @@ -19,7 +19,7 @@ public static class RequestCheckpointConstants public const string ElapsedTillFinished = "eltltf"; /// - /// The time elapsed before hitting the middleware. + /// The time elapsed before hitting the pipeline exit middleware. /// public const string ElapsedTillPipelineExitMiddleware = "eltexm"; diff --git a/src/Libraries/Microsoft.Extensions.Options.Contextual/NullConfigureContextualOptions.cs b/src/Libraries/Microsoft.Extensions.Options.Contextual/NullConfigureContextualOptions.cs index 19c35c1611a..caaa969ee55 100644 --- a/src/Libraries/Microsoft.Extensions.Options.Contextual/NullConfigureContextualOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Options.Contextual/NullConfigureContextualOptions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.Options.Contextual; public static class NullConfigureContextualOptions { /// - /// Gets a singleton instance of . + /// Gets a singleton instance of an empty configuration context. /// /// The options type to configure. /// A do-nothing instance of . diff --git a/test/Analyzers/Microsoft.Analyzers.Local.Tests/InternalReferencedInPublicDocAnalyzerTests.cs b/test/Analyzers/Microsoft.Analyzers.Local.Tests/InternalReferencedInPublicDocAnalyzerTests.cs new file mode 100644 index 00000000000..4f6962bbc0b --- /dev/null +++ b/test/Analyzers/Microsoft.Analyzers.Local.Tests/InternalReferencedInPublicDocAnalyzerTests.cs @@ -0,0 +1,491 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.LocalAnalyzers.Resource.Test; +using Xunit; + +namespace Microsoft.Extensions.LocalAnalyzers.Test; + +public class InternalReferencedInPublicDocAnalyzerTests +{ + private static readonly string[] _members = + { + "void Method() {}", "int Property {get; set;}", "event System.EventHandler Event;", "int _field;", "string this[int i] { get => string.Empty; set {} }", + }; + private static readonly string[] _membersReferenced = { "void Referenced() {}", "int Referenced {get; set;}", "event System.EventHandler Referenced;", "int Referenced;", }; + private static readonly string[] _typesReferenced = { "class Referenced {}", "struct Referenced {}", "interface Referenced {}", "delegate void Referenced(int value);", "enum Referenced {}", }; + private static readonly string[] _compositeTypeNames = { "class", "struct", "interface" }; + + public static IEnumerable References => new Assembly[] + { + // add references here + }; + + public static IEnumerable GetMemberAndTypePairs(string memberAccessModifier, string typeAccessModifier) + { + return MakePairs(memberAccessModifier, _members, typeAccessModifier, _typesReferenced); + } + + public static IEnumerable GetCompositeTypeAndMemberPairs(string typeAccessModifier, string memberAccessModifier) + { + return MakePairs(typeAccessModifier, _compositeTypeNames, memberAccessModifier, _membersReferenced); + } + + private static IEnumerable MakePairs(string firstPrefix, IReadOnlyList firstList, string secondPrefix, IReadOnlyList secondList) + { + var casesCnt = Math.Max(firstList.Count, secondList.Count); + for (var i = 0; i < casesCnt; i++) + { + var first = $"{firstPrefix} {firstList[i % firstList.Count]}"; + var second = $"{secondPrefix} {secondList[i % secondList.Count]}"; + yield return new object[] { first, second }; + } + } + + private static string MemberReferencesTopLevelTypeLine6(string classAccess, string member, string type) + { + return @" + namespace Example; + + " + classAccess + @" class TestClass + { + /// + /// Does something with . This is line #6. + /// + " + member + @" + } + + " + type + @" + "; + } + + private static string TopLevelTypeReferencesItsMemberLine4(string type, string member) + { + return @" + namespace Example + { + /// + /// Does something with . This is line #4. + /// + " + type + @" Test + { + " + member + @" + } + } + "; + } + + [Fact] + public void CheckExceptionIsThrownWhenNullIsPassedToInitializeCall() + { + var a = new InternalReferencedInPublicDocAnalyzer(); + Assert.Throws(() => a.Initialize(null!)); + } + + [Theory] + [MemberData(nameof(GetMemberAndTypePairs), "public", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "public", "")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "")] + public async Task ShouldIndicateWhenExternallyVisibleMemberReferencesTopLevelInternalType(string member, string type) + { + var source = MemberReferencesTopLevelTypeLine6("public", member, type); + + var result = await Analyze(source); + + AssertDetected(result, source, 6, "Referenced"); + } + + [Theory] + [MemberData(nameof(GetMemberAndTypePairs), "public", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "private", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "private", "")] + [MemberData(nameof(GetMemberAndTypePairs), "private protected", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "private protected", "")] + [MemberData(nameof(GetMemberAndTypePairs), "internal", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "internal", "")] + public async Task ShouldNotIndicateWhenMemberReferencesTopLevelType(string member, string type) + { + var source = MemberReferencesTopLevelTypeLine6("public", member, type); + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + [Theory] + [MemberData(nameof(GetMemberAndTypePairs), "public", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "public", "")] + [MemberData(nameof(GetMemberAndTypePairs), "public", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "")] + [MemberData(nameof(GetMemberAndTypePairs), "protected", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "")] + [MemberData(nameof(GetMemberAndTypePairs), "protected internal", "public")] + [MemberData(nameof(GetMemberAndTypePairs), "private", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "private", "")] + [MemberData(nameof(GetMemberAndTypePairs), "private protected", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "private protected", "")] + [MemberData(nameof(GetMemberAndTypePairs), "internal", "internal")] + [MemberData(nameof(GetMemberAndTypePairs), "internal", "")] + public async Task ShouldNotIndicateWhenInternalClassMemberReferencesTopLevelType(string member, string type) + { + var source = MemberReferencesTopLevelTypeLine6("internal", member, type); + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + [Theory] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "private")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "private protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "private")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "private protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "private")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "private protected")] + public async Task ShouldIndicateWhenExternallyVisibleTopLevelTypeReferencesItsInvisibleMember(string type, string member) + { + var source = TopLevelTypeReferencesItsMemberLine4(type, member); + + var result = await Analyze(source); + + AssertDetected(result, source, 4, "Referenced"); + } + + [Theory] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "public")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "public", "protected internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "public")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected", "protected internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "public")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "protected internal", "protected internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "public")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "protected internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "private")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "internal", "private protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "public")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "protected")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "protected internal")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "private")] + [MemberData(nameof(GetCompositeTypeAndMemberPairs), "", "private protected")] + public async Task ShouldNotIndicateWhenTopLevelTypeReferencesItsMember(string type, string member) + { + var source = TopLevelTypeReferencesItsMemberLine4(type, member); + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + [Theory] + [InlineData("public", false)] + [InlineData("internal", true)] + [InlineData("", true)] + public async Task ShouldSupportReferencesToEnumMembers(string enumAccess, bool shouldIndicate) + { + var source = @" + namespace Example + { + /// + /// Use . This is line #4. + /// + public class TestClass + { + public void Method X() {} + } + + " + enumAccess + @" enum MyEnum + { + Member1, + Member2, + } + } + "; + + var result = await Analyze(source); + + if (shouldIndicate) + { + AssertDetected(result, source, 4, "MyEnum.Member1"); + } + else + { + AssertNotDetected(result); + } + } + + [Theory] + [InlineData("public", "public", false)] + [InlineData("public", "internal", true)] + [InlineData("internal", "public", false)] + [InlineData("", "internal", false)] + public async Task ShouldSupportCommentsOnEnumMembers(string enumAccess, string typeAccess, bool shouldIndicate) + { + var source = @" + namespace Example + { + " + typeAccess + @" class Referenced + { + public void Method X() {} + } + + " + enumAccess + @" enum MyEnum + { + Member1, + + /// + /// Uses . This is line #13. + /// + Member2, + } + } + "; + + var result = await Analyze(source); + + if (shouldIndicate) + { + AssertDetected(result, source, 13, "Referenced"); + } + else + { + AssertNotDetected(result); + } + } + + [Theory] + [InlineData("public", "public", false)] + [InlineData("public", "internal", true)] + [InlineData("public", "private", true)] + [InlineData("public", "protected", false)] + [InlineData("public", "protected internal", false)] + [InlineData("public", "private protected", true)] + [InlineData("internal", "public", true)] + [InlineData("internal", "internal", true)] + [InlineData("internal", "private", true)] + [InlineData("internal", "protected", true)] + [InlineData("internal", "protected internal", true)] + [InlineData("internal", "private protected", true)] + [InlineData("", "public", true)] + [InlineData("", "internal", true)] + [InlineData("", "private", true)] + [InlineData("", "protected", true)] + [InlineData("", "protected internal", true)] + [InlineData("", "private protected", true)] + public async Task ShouldSupportCrefPointingToNestedType(string enclosingTypeAccess, string nestedTypeAccess, bool shouldIndicate) + { + var source = @" + namespace Example + { + " + enclosingTypeAccess + @" class Enclosing + { + " + nestedTypeAccess + @" class Referenced + { + } + } + + /// + /// Uses . This is line #11. + /// + public interface ITest + { + } + } + "; + + var result = await Analyze(source); + + if (shouldIndicate) + { + AssertDetected(result, source, 11, "Enclosing.Referenced"); + } + else + { + AssertNotDetected(result); + } + } + + [Theory] + [InlineData("public", "public", true)] + [InlineData("public", "internal", false)] + [InlineData("public", "private", false)] + [InlineData("public", "protected", true)] + [InlineData("public", "protected internal", true)] + [InlineData("public", "private protected", false)] + [InlineData("internal", "public", false)] + [InlineData("internal", "internal", false)] + [InlineData("internal", "private", false)] + [InlineData("internal", "protected", false)] + [InlineData("internal", "protected internal", false)] + [InlineData("internal", "private protected", false)] + [InlineData("", "public", false)] + [InlineData("", "internal", false)] + [InlineData("", "private", false)] + [InlineData("", "protected", false)] + [InlineData("", "protected internal", false)] + [InlineData("", "private protected", false)] + public async Task ShouldSupportCrefOnNestedType(string enclosingTypeAccess, string nestedTypeAccess, bool shouldIndicate) + { + var source = @" + namespace Example + { + " + enclosingTypeAccess + @" class Enclosing + { + /// + /// Uses . This is line #6. + /// + " + nestedTypeAccess + @" class Test + { + } + } + + internal interface Referenced + { + } + } + "; + + var result = await Analyze(source); + + if (shouldIndicate) + { + AssertDetected(result, source, 6, "Referenced"); + } + else + { + AssertNotDetected(result); + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("NotExists")] + [InlineData("this is not a valid reference")] + [InlineData("$^&#@")] + public async Task ShouldNotIndicateWhenCrefIsInvalid(string cref) + { + var source = @" + namespace Example + { + public class TestClass + { + /// + /// Use . + /// + public void Method X() {} + } + } + "; + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + [Fact] + public async Task ShouldNotIndicateWhenCommentIsOrphan() + { + var source = @" + namespace Example + { + public class TestClass + { + /// + /// Use . + /// + } + + internal class Referenced {} + } + "; + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + [Fact] + public async Task ShouldNotIndicateWhenCRefDoesNotBelongToXmlDocumentation() + { + var source = @" + namespace Example + { + /* + Type is referenced + */ + public class TestClass + { + // Use + public void Method() {}; + } + + internal class Referenced {} + } + "; + + var result = await Analyze(source); + + AssertNotDetected(result); + } + + private static void AssertDetected(IReadOnlyList result, string source, int lineNumber, string detectedText) => + AssertDetected(result, source, new[] { lineNumber }, new[] { detectedText }); + + private static void AssertDetected(IReadOnlyList result, string source, int[] lineNumbers, string[] detectedTexts) + { + Debug.Assert(lineNumbers.Length == detectedTexts.Length, "Line numbers and texts should be the same length"); + + var detected = result.Where(IsInternalReferencedInPublicDocDiagnostic).ToList(); + + var expectedNumberOfWarnings = lineNumbers.Length; + Assert.Equal(expectedNumberOfWarnings, detected.Count); + + for (int i = 0; i < detected.Count; i++) + { + var location = detected[i].Location; + Assert.Equal(lineNumbers[i], location.GetLineSpan().StartLinePosition.Line); + + var text = source.Substring(location.SourceSpan.Start, location.SourceSpan.Length); + Assert.Equal(detectedTexts[i], text, StringComparer.Ordinal); + } + } + + private static bool IsInternalReferencedInPublicDocDiagnostic(Diagnostic d) => ReferenceEquals(d.Descriptor, DiagDescriptors.InternalReferencedInPublicDoc); + + private static void AssertNotDetected(IReadOnlyList result) + { + var detected = result.Where(IsInternalReferencedInPublicDocDiagnostic); + Assert.Empty(detected); + } + + private static async Task> Analyze(string source) + { + return await RoslynTestUtils.RunAnalyzer( + new InternalReferencedInPublicDocAnalyzer(), + References, + new[] { source }).ConfigureAwait(false); + } +}