diff --git a/analyzers/src/SonarAnalyzer.Common/AnalysisContext/SonarAnalysisContextBase.cs b/analyzers/src/SonarAnalyzer.Common/AnalysisContext/SonarAnalysisContextBase.cs index d1d0a4ab8e2..70fd96b25ee 100644 --- a/analyzers/src/SonarAnalyzer.Common/AnalysisContext/SonarAnalysisContextBase.cs +++ b/analyzers/src/SonarAnalyzer.Common/AnalysisContext/SonarAnalysisContextBase.cs @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +using System.Collections.Concurrent; using System.IO; using System.Runtime.CompilerServices; using static SonarAnalyzer.Helpers.DiagnosticDescriptorFactory; @@ -26,6 +27,7 @@ namespace SonarAnalyzer.AnalysisContext; public class SonarAnalysisContextBase { + protected static readonly ConditionalWeakTable> FileInclusionCache = new(); protected static readonly ConditionalWeakTable> UnchangedFilesCache = new(); protected static readonly SourceTextValueProvider ProjectConfigProvider = new(x => new ProjectConfigReader(x)); protected static readonly SourceTextValueProvider SonarLintXmlProviderCS = new(x => new SonarLintXmlReader(x, LanguageNames.CSharp)); @@ -55,8 +57,9 @@ protected SonarAnalysisContextBase(SonarAnalysisContext analysisContext, TContex /// Tree to decide on. Can be null for Symbol-based and Compilation-based scenarios. And we want to analyze those too. /// When set, generated trees are analyzed only when language-specific 'analyzeGeneratedCode' configuration property is also set. public bool ShouldAnalyzeTree(SyntaxTree tree, GeneratedCodeRecognizer generatedCodeRecognizer) => - (generatedCodeRecognizer is null || SonarLintFile().AnalyzeGeneratedCode || !tree.IsGenerated(generatedCodeRecognizer, Compilation)) - && (tree is null || !IsUnchanged(tree)); + SonarLintFile() is var sonarLintReader + && (generatedCodeRecognizer is null || sonarLintReader.AnalyzeGeneratedCode || !tree.IsGenerated(generatedCodeRecognizer, Compilation)) + && (tree is null || (!IsUnchanged(tree) && ShouldAnalyzeFile(sonarLintReader, tree.FilePath))); /// /// Reads configuration from SonarProjectConfig.xml file and caches the result for scope of this analysis. @@ -122,6 +125,27 @@ public bool HasMatchingScope(DiagnosticDescriptor descriptor) descriptor.CustomTags.Contains(tag); } + private bool ShouldAnalyzeFile(SonarLintXmlReader sonarLintXml, string filePath) => + ProjectConfiguration().ProjectType != ProjectType.Unknown // Not SonarLint context, NuGet or Scanner <= 5.0 + || (FileInclusionCache.GetValue(Compilation, _ => new()) is var cache + && cache.GetOrAdd(filePath, _ => IsFileIncluded(sonarLintXml, filePath))); + private ImmutableHashSet CreateUnchangedFilesHashSet() => ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, ProjectConfiguration().AnalysisConfig?.UnchangedFiles() ?? Array.Empty()); + + private bool IsFileIncluded(SonarLintXmlReader sonarLintXml, string filePath) => + IsTestProject() + ? IsFileIncluded(sonarLintXml.TestInclusions, sonarLintXml.TestExclusions, sonarLintXml.GlobalTestExclusions, filePath) + : IsFileIncluded(sonarLintXml.Inclusions, sonarLintXml.Exclusions, sonarLintXml.GlobalExclusions, filePath); + + private static bool IsFileIncluded(string[] inclusions, string[] exclusions, string[] globalExclusions, string filePath) => + IsIncluded(inclusions, filePath) + && !IsExcluded(exclusions, filePath) + && !IsExcluded(globalExclusions, filePath); + + private static bool IsIncluded(string[] inclusions, string filePath) => + inclusions is { Length: 0 } || inclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath)); + + private static bool IsExcluded(string[] exclusions, string filePath) => + exclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath)); } diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/AnalysisContext/SonarAnalysisContextBaseTest.ShouldAnalyzeTree.cs b/analyzers/tests/SonarAnalyzer.UnitTest/AnalysisContext/SonarAnalysisContextBaseTest.ShouldAnalyzeTree.cs index 0d1d18583c9..c667b8898e2 100644 --- a/analyzers/tests/SonarAnalyzer.UnitTest/AnalysisContext/SonarAnalysisContextBaseTest.ShouldAnalyzeTree.cs +++ b/analyzers/tests/SonarAnalyzer.UnitTest/AnalysisContext/SonarAnalysisContextBaseTest.ShouldAnalyzeTree.cs @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System.IO; using System.Text; using Microsoft.CodeAnalysis.Text; using Moq; @@ -78,7 +77,7 @@ public void ShouldAnalyzeTree_Scanner_UnchangedFiles_ContainsOtherFile() [DataRow(OtherFileName, true)] public void ShouldAnalyzeTree_GeneratedFile_NoSonarLintXml(string fileName, bool expected) { - var sonarLintXml = CreateSonarLintXml(true); + var sonarLintXml = new DummySourceText(AnalysisScaffolding.GenerateSonarLintXmlContent(analyzeGeneratedCode: true)); var (compilation, tree) = CreateDummyCompilation(AnalyzerLanguage.CSharp, fileName); var sut = CreateSut(compilation, CreateOptions(sonarLintXml, @"ResourceTests\Foo.xml")); @@ -89,7 +88,7 @@ public void ShouldAnalyzeTree_GeneratedFile_NoSonarLintXml(string fileName, bool [TestMethod] public void ShouldAnalyzeTree_GeneratedFile_ShouldAnalyzeGeneratedProvider_IsCached() { - var sonarLintXml = CreateSonarLintXml(true); + var sonarLintXml = new DummySourceText(AnalysisScaffolding.GenerateSonarLintXmlContent(analyzeGeneratedCode: true)); var additionalText = new Mock(); additionalText.Setup(x => x.Path).Returns("SonarLint.xml"); additionalText.Setup(x => x.GetText(default)).Returns(sonarLintXml); @@ -129,7 +128,7 @@ public void ShouldAnalyzeTree_GeneratedFile_InvalidSonarLintXml(string fileName, [DataRow(OtherFileName)] public void ShouldAnalyzeTree_GeneratedFile_AnalyzeGenerated_AnalyzeAllFiles(string fileName) { - var sonarLintXml = CreateSonarLintXml(true); + var sonarLintXml = new DummySourceText(AnalysisScaffolding.GenerateSonarLintXmlContent(analyzeGeneratedCode: true)); var (compilation, tree) = CreateDummyCompilation(AnalyzerLanguage.CSharp, fileName); var sut = CreateSut(compilation, CreateOptions(sonarLintXml)); @@ -137,25 +136,24 @@ public void ShouldAnalyzeTree_GeneratedFile_AnalyzeGenerated_AnalyzeAllFiles(str } [DataTestMethod] - [DataRow(GeneratedFileName, false)] - [DataRow(OtherFileName, true)] - public void ShouldAnalyzeTree_CorrectSettingUsed(string fileName, bool expectedCSharp) + [DataRow(GeneratedFileName, LanguageNames.CSharp, false)] + [DataRow(OtherFileName, LanguageNames.CSharp, true)] + [DataRow(GeneratedFileName, LanguageNames.VisualBasic, false)] + [DataRow(OtherFileName, LanguageNames.VisualBasic, true)] + public void ShouldAnalyzeTree_CorrectSettingUsed(string fileName, string language, bool expected) { - var sonarLintXml = CreateSonarLintXml(false); - var (compilationCS, treeCS) = CreateDummyCompilation(AnalyzerLanguage.CSharp, fileName); - var (compilationVB, treeVB) = CreateDummyCompilation(AnalyzerLanguage.VisualBasic, fileName); - var sutCS = CreateSut(compilationCS, CreateOptions(sonarLintXml)); - var sutVB = CreateSut(compilationVB, CreateOptions(sonarLintXml)); - - sutCS.ShouldAnalyzeTree(treeCS, CSharpGeneratedCodeRecognizer.Instance).Should().Be(expectedCSharp); - sutVB.ShouldAnalyzeTree(treeVB, VisualBasicGeneratedCodeRecognizer.Instance).Should().BeTrue(); + var sonarLintXml = new DummySourceText(AnalysisScaffolding.GenerateSonarLintXmlContent(language: language, analyzeGeneratedCode: false)); + var analyzerLanguage = language == LanguageNames.CSharp ? AnalyzerLanguage.CSharp : AnalyzerLanguage.VisualBasic; + var (compilation, tree) = CreateDummyCompilation(analyzerLanguage, fileName); + var sut = CreateSut(compilation, CreateOptions(sonarLintXml)); + GeneratedCodeRecognizer generatedCodeRecognizer = language == LanguageNames.CSharp ? CSharpGeneratedCodeRecognizer.Instance : VisualBasicGeneratedCodeRecognizer.Instance; - sonarLintXml.ToStringCallCount.Should().Be(2, "file should be read once per language"); + sut.ShouldAnalyzeTree(tree, generatedCodeRecognizer).Should().Be(expected); + sonarLintXml.ToStringCallCount.Should().Be(1, "file should be read once per language"); // Read again to check caching - sutVB.ShouldAnalyzeTree(treeVB, VisualBasicGeneratedCodeRecognizer.Instance).Should().BeTrue(); - - sonarLintXml.ToStringCallCount.Should().Be(2, "file should not have been read again"); + sut.ShouldAnalyzeTree(tree, generatedCodeRecognizer).Should().Be(expected); + sonarLintXml.ToStringCallCount.Should().Be(1, "file should not have been read again"); } // Until https://github.com/SonarSource/sonar-dotnet/issues/2228, we were considering a file as generated if the word "generated" was contained inside a region. @@ -329,26 +327,106 @@ public class GenericAttribute : Attribute { } VerifyEmpty("test.cs", sourceCs, new CS.EmptyStatement()); } - private static DummySourceText CreateSonarLintXml(bool analyzeGeneratedCSharp) => - new($""" - - - - - dummy - false - - - sonar.cs.analyzeGeneratedCode - {analyzeGeneratedCSharp.ToString().ToLower()} - - - sonar.vbnet.analyzeGeneratedCode - true - - - - """); + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, false)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, true)] + public void ShouldAnalyzeTree_Exclusions_ReturnExpected(string filePath, string[] exclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, exclusions: exclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, false)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, true)] + public void ShouldAnalyzeTree_GlobalExclusions_ReturnExpected(string filePath, string[] globalExclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, globalExclusions: globalExclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, false)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, true)] + public void ShouldAnalyzeTree_TestExclusions_ReturnExpected(string filePath, string[] testExclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, testExclusions: testExclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, false)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, true)] + public void ShouldAnalyzeTree_GlobalTestExclusions_ReturnExpected(string filePath, string[] globalTestExclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, globalTestExclusions: globalTestExclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, false)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, true)] + public void ShouldAnalyzeTree_Inclusions_ReturnExpected(string filePath, string[] inclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, inclusions: inclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Product, true)] + [DataRow("Foo", new string[] { "Foo" }, ProjectType.Test, true)] + [DataRow("Foo", new string[] { "NotFoo" }, ProjectType.Test, false)] + public void ShouldAnalyzeTree_TestInclusions_ReturnExpected(string filePath, string[] testInclusions, ProjectType projectType, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, projectType, expectedResult, testInclusions: testInclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, new string[] { "Foo" }, false)] + [DataRow("Foo", new string[] { "NotFoo" }, new string[] { "Foo" }, false)] + [DataRow("Foo", new string[] { "Foo" }, new string[] { "NotFoo" }, true)] + [DataRow("Foo", new string[] { "NotFoo" }, new string[] { "NotFoo" }, false)] + public void ShouldAnalyzeTree_MixedInput_ProductProject_ReturnExpected(string filePath, string[] inclusions, string[] exclusions, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, ProjectType.Product, expectedResult, inclusions: inclusions, exclusions: exclusions); + + [DataTestMethod] + [DataRow("Foo", new string[] { "Foo" }, new string[] { "Foo" }, false)] + [DataRow("Foo", new string[] { "NotFoo" }, new string[] { "Foo" }, false)] + [DataRow("Foo", new string[] { "Foo" }, new string[] { "NotFoo" }, true)] + [DataRow("Foo", new string[] { "NotFoo" }, new string[] { "NotFoo" }, false)] + public void ShouldAnalyzeTree_MixedInput_TestProject_ReturnExpected(string filePath, string[] testInclusions, string[] testExclusions, bool expectedResult) => + ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly(filePath, ProjectType.Test, expectedResult, testInclusions: testInclusions, testExclusions: testExclusions); + + private void ShouldAnalyzeTree_WithExclusionInclusionParametersSet_ReturnsTrueForIncludedFilesOnly( + string fileName, + ProjectType projectType, + bool shouldAnalyze, + string language = LanguageNames.CSharp, + string[] exclusions = null, + string[] inclusions = null, + string[] globalExclusions = null, + string[] testExclusions = null, + string[] testInclusions = null, + string[] globalTestExclusions = null) + { + var analyzerLanguage = language == LanguageNames.CSharp ? AnalyzerLanguage.CSharp : AnalyzerLanguage.VisualBasic; + var sonarLintXml = AnalysisScaffolding.CreateSonarLintXml( + TestContext, + language: language, + exclusions: exclusions, + inclusions: inclusions, + globalExclusions: globalExclusions, + testExclusions: testExclusions, + testInclusions: testInclusions, + globalTestExclusions: globalTestExclusions); + var options = AnalysisScaffolding.CreateOptions(sonarLintXml); + + var compilation = SolutionBuilder + .Create() + .AddProject(analyzerLanguage, createExtraEmptyFile: false) + .AddReferences(TestHelper.ProjectTypeReference(projectType)) + .AddSnippet(string.Empty, fileName) + .GetCompilation(); + var tree = compilation.SyntaxTrees.Single(x => x.FilePath.Contains(fileName)); + var sut = CreateSut(compilation, options); + + GeneratedCodeRecognizer codeRecognizer = language == LanguageNames.CSharp ? CSharpGeneratedCodeRecognizer.Instance : VisualBasicGeneratedCodeRecognizer.Instance; + sut.ShouldAnalyzeTree(tree, codeRecognizer).Should().Be(shouldAnalyze); + } private AnalyzerOptions CreateOptions(string[] unchangedFiles) => AnalysisScaffolding.CreateOptions(AnalysisScaffolding.CreateSonarProjectConfigWithUnchangedFiles(TestContext, unchangedFiles)); diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestFramework/AnalysisScaffolding.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestFramework/AnalysisScaffolding.cs index d15e6b096af..5f518397a40 100644 --- a/analyzers/tests/SonarAnalyzer.UnitTest/TestFramework/AnalysisScaffolding.cs +++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestFramework/AnalysisScaffolding.cs @@ -19,6 +19,7 @@ */ using System.IO; +using System.Xml.Linq; using Microsoft.CodeAnalysis.Text; using Moq; using SonarAnalyzer.AnalysisContext; @@ -80,6 +81,45 @@ public static string CreateSonarProjectConfigWithFilesToAnalyze(TestContext cont public static string CreateSonarProjectConfig(TestContext context, ProjectType projectType, bool isScannerRun = true) => CreateSonarProjectConfig(context, "ProjectType", projectType.ToString(), isScannerRun); + public static string CreateSonarLintXml( + TestContext context, + string language = LanguageNames.CSharp, + bool analyzeGeneratedCode = false, + string[] exclusions = null, + string[] inclusions = null, + string[] globalExclusions = null, + string[] testExclusions = null, + string[] testInclusions = null, + string[] globalTestExclusions = null) => + TestHelper.WriteFile(context, "SonarLint.xml", GenerateSonarLintXmlContent(language, analyzeGeneratedCode, exclusions, inclusions, globalExclusions, testExclusions, testInclusions, globalTestExclusions)); + + public static string GenerateSonarLintXmlContent( + string language = LanguageNames.CSharp, + bool analyzeGeneratedCode = false, + string[] exclusions = null, + string[] inclusions = null, + string[] globalExclusions = null, + string[] testExclusions = null, + string[] testInclusions = null, + string[] globalTestExclusions = null) => + new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("AnalysisInput", + new XElement("Settings", + CreateSetting($"sonar.{(language == LanguageNames.CSharp ? "cs" : "vbnet")}.analyzeGeneratedCode", analyzeGeneratedCode.ToString()), + CreateSetting("sonar.exclusions", ConcatenateStringArray(exclusions)), + CreateSetting("sonar.inclusions", ConcatenateStringArray(inclusions)), + CreateSetting("sonar.global.exclusions", ConcatenateStringArray(globalExclusions)), + CreateSetting("sonar.test.exclusions", ConcatenateStringArray(testExclusions)), + CreateSetting("sonar.test.inclusions", ConcatenateStringArray(testInclusions)), + CreateSetting("sonar.global.test.exclusions", ConcatenateStringArray(globalTestExclusions))))).ToString(); + + private static XElement CreateSetting(string key, string value) => + new("Setting", new XElement("Key", key), new XElement("Value", value)); + + private static string ConcatenateStringArray(string[] array) => + string.Join(",", array ?? Array.Empty()); + private static string CreateSonarProjectConfig(TestContext context, string element, string value, bool isScannerRun, string analysisConfigPath = null) { var sonarProjectConfigPath = TestHelper.TestPath(context, "SonarProjectConfig.xml");