diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AttributesOnSeparateLinesTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AttributesOnSeparateLinesTests.cs index cd7a750..7d8293b 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AttributesOnSeparateLinesTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/AttributesOnSeparateLinesTests.cs @@ -390,6 +390,31 @@ static void Main() await VerifyCSharpFix(test, fixTest); } + [TestMethod] + [Description("Analyzer should not report on generated code")] + public void AttributesOnSameLine_InGeneratedCode_NoDiagnostic() + { + string test = @"using System; +using System.CodeDom.Compiler; + +namespace ConsoleApp +{ + class AAttribute : Attribute { } + class BAttribute : Attribute { } + + [GeneratedCode(""tool"", ""1.0"")] + class Program + { + [A][B] + static void Main() + { + } + } +}"; + // Should NOT produce a diagnostic for attributes on same line inside generated code + VerifyCSharpDiagnostic(test); + } + private static DiagnosticResult GetExpectedDiagnosticResult(int line, int col) { return new DiagnosticResult diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs index 97af362..05389a1 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs @@ -161,5 +161,115 @@ static void Main(string[] args) "; VerifyCSharpDiagnostic(source); } + + [TestMethod] + [Description("Cast() throws InvalidCastException on generic method calls")] + public void GenericMethodCallOnDirectory_DoesNotThrow() + { + // memberAccess.ChildNodes().Cast() will throw + // if any child node is not IdentifierNameSyntax (e.g. GenericNameSyntax). + // This test uses a generic method call on a class named Directory. + string source = @" +using System; +using System.Collections.Generic; + +namespace ConsoleApp +{ + public static class Directory + { + public static List GetItems() => new List(); + } + + class Program + { + static void Main(string[] args) + { + var items = Directory.GetItems(); + } + } +}"; + VerifyCSharpDiagnostic(source); + } + + [TestMethod] + [Description("Analyzer should not report when symbol is unresolved (compile error)")] + public void UnresolvableDirectoryType_NoDiagnostic() + { + // When symbol.Symbol is null it means the code has a compile error, + // not that it's System.IO.Directory. Should not produce a false positive. + string source = @" +namespace ConsoleApp +{ + class Program + { + static void Main(string[] args) + { + var files = Directory.GetFiles("".""); + } + } +}"; + // No 'using System.IO' so Directory is unresolvable — should NOT produce diagnostic + VerifyCSharpDiagnostic(source); + } + + [TestMethod] + [Description("Detect fully-qualified System.IO.Directory.GetFiles()")] + public void FullyQualifiedDirectoryGetFiles_ProducesInfoMessage() + { + string source = @" +using System; + +namespace ConsoleApp +{ + class Program + { + static void Main(string[] args) + { + string[] files = System.IO.Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory); + } + } +}"; + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0301", + Severity = DiagnosticSeverity.Info, + Message = "Favor using the method `EnumerateFiles` over the `GetFiles` method", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 10, 30) + ] + }); + } + + [TestMethod] + [Description("Detect fully-qualified System.IO.Directory.GetDirectories()")] + public void FullyQualifiedDirectoryGetDirectories_ProducesInfoMessage() + { + string source = @" +using System; + +namespace ConsoleApp +{ + class Program + { + static void Main(string[] args) + { + string[] dirs = System.IO.Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory); + } + } +}"; + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0302", + Severity = DiagnosticSeverity.Info, + Message = "Favor using the method `EnumerateDirectories` over the `GetDirectories` method", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 10, 29) + ] + }); + } } } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/NamingFieldPascalUnderscoreTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/NamingFieldPascalUnderscoreTests.cs index 324803e..44f24a4 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/NamingFieldPascalUnderscoreTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/NamingFieldPascalUnderscoreTests.cs @@ -451,6 +451,38 @@ class TypeName VerifyCSharpDiagnostic(test, expected); } + [TestMethod] + [Description("Custom GeneratedCodeAttribute in different namespace should not suppress diagnostics")] + public void FieldWithNamingViolation_CustomGeneratedCodeAttribute_ShouldStillWarn() + { + string test = @" + using System; + + namespace MyNamespace + { + class GeneratedCodeAttribute : Attribute { } + + [GeneratedCode] + class TypeName + { + public string myfield; + } + }"; + + var expected = new DiagnosticResult + { + Id = "INTL0001", + Message = "Field 'myfield' should be named _PascalCase", + Severity = DiagnosticSeverity.Warning, + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 11, 27) + ] + }; + + VerifyCSharpDiagnostic(test, expected); + } + protected override CodeFixProvider GetCSharpCodeFixProvider() { diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/UnusedLocalVariableTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/UnusedLocalVariableTests.cs index 70f6e81..185d69b 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/UnusedLocalVariableTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/UnusedLocalVariableTests.cs @@ -141,7 +141,7 @@ public void Descriptor_ContainsExpectedValues() Assert.AreEqual(DiagnosticSeverity.Info, diagnostic.DefaultSeverity); Assert.IsTrue(diagnostic.IsEnabledByDefault); Assert.AreEqual("All local variables should be accessed, or named with underscores to indicate they are unused.", diagnostic.Description); - Assert.AreEqual("https://github.com/IntelliTect/CodingGuidelines", diagnostic.HelpLinkUri); + Assert.AreEqual(DiagnosticUrlBuilder.GetUrl("Local variable unused", "INTL0303"), diagnostic.HelpLinkUri); } [TestMethod] @@ -249,6 +249,29 @@ bool Bar(Func func) VerifyCSharpDiagnostic(test, result); } + [TestMethod] + [Description("Analyzer should skip generated code")] + public void UnusedLocalVariable_InGeneratedCode_NoDiagnostic() + { + string test = @" +using System; +using System.CodeDom.Compiler; + +namespace ConsoleApplication1 +{ + class TypeName + { + [GeneratedCode(""tool"", ""1.0"")] + public void GeneratedMethod() + { + object foo = new object(); + } + } +}"; + // Should NOT produce a diagnostic for unused variable inside generated code + VerifyCSharpDiagnostic(test); + } + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() { return new Analyzers.UnusedLocalVariable(); diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/AttributesOnSeparateLines.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/AttributesOnSeparateLines.cs index 7a9e642..55d2c45 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/AttributesOnSeparateLines.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/AttributesOnSeparateLines.cs @@ -29,7 +29,7 @@ public override void Initialize(AnalysisContext context) throw new System.ArgumentNullException(nameof(context)); } - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.Property, SymbolKind.NamedType, SymbolKind.Method, SymbolKind.Field); } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs index 9d4939f..c8a6004 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs @@ -40,7 +40,7 @@ public override void Initialize(AnalysisContext context) context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); } - private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) { var expression = (InvocationExpressionSyntax)context.Node; @@ -49,37 +49,52 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - if (memberAccess.Expression is not IdentifierNameSyntax nameSyntax) + string methodName = memberAccess.Name.Identifier.Text; + + if (!IsDirectoryExpression(memberAccess.Expression, context)) { return; } - if (string.Equals(nameSyntax.Identifier.Text, "Directory", StringComparison.CurrentCultureIgnoreCase)) + if (string.Equals(methodName, "GetFiles", StringComparison.OrdinalIgnoreCase)) + { + Location loc = memberAccess.GetLocation(); + context.ReportDiagnostic(Diagnostic.Create(_Rule301, loc, memberAccess.Name)); + } + else if (string.Equals(methodName, "GetDirectories", StringComparison.OrdinalIgnoreCase)) { - if (memberAccess.ChildNodes().Cast().Any(x => - string.Equals(x.Identifier.Text, "GetFiles", StringComparison.CurrentCultureIgnoreCase))) + Location loc = memberAccess.GetLocation(); + context.ReportDiagnostic(Diagnostic.Create(_Rule302, loc, memberAccess.Name)); + } + } + + private static bool IsDirectoryExpression(ExpressionSyntax expression, SyntaxNodeAnalysisContext context) + { + SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(expression); + + // For simple identifiers like 'Directory' + if (expression is IdentifierNameSyntax) + { + if (symbolInfo.Symbol is null) { - // Unsure if this is the best way to determine if member was defined in the project. - SymbolInfo symbol = context.SemanticModel.GetSymbolInfo(nameSyntax); - if (symbol.Symbol == null || symbol.Symbol.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.IO.Directory") - { - Location loc = memberAccess.GetLocation(); - context.ReportDiagnostic(Diagnostic.Create(_Rule301, loc, memberAccess.Name)); - } + return false; } - if (memberAccess.ChildNodes().Cast().Any(x => - string.Equals(x.Identifier.Text, "GetDirectories", StringComparison.CurrentCultureIgnoreCase))) + return symbolInfo.Symbol.OriginalDefinition + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.IO.Directory"; + } + + // For fully-qualified expressions like 'System.IO.Directory' + if (expression is MemberAccessExpressionSyntax) + { + if (symbolInfo.Symbol is INamedTypeSymbol namedType) { - // Unsure if this is the best way to determine if member was defined in the project. - SymbolInfo symbol = context.SemanticModel.GetSymbolInfo(nameSyntax); - if (symbol.Symbol is null || symbol.Symbol.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.IO.Directory") - { - Location loc = memberAccess.GetLocation(); - context.ReportDiagnostic(Diagnostic.Create(_Rule302, loc, memberAccess.Name)); - } + return namedType.OriginalDefinition + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.IO.Directory"; } } + + return false; } private static class Rule301 diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingFieldPascalUnderscore.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingFieldPascalUnderscore.cs index f48e023..32e48a3 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingFieldPascalUnderscore.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingFieldPascalUnderscore.cs @@ -39,9 +39,11 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) { ISymbol namedTypeSymbol = context.Symbol; - // ignore GeneratedCodeAttribute on field and first containing type + INamedTypeSymbol generatedCodeAttribute = context.Compilation + .GetTypeByMetadataName("System.CodeDom.Compiler.GeneratedCodeAttribute"); ImmutableArray attributes = namedTypeSymbol.GetAttributes().AddRange(namedTypeSymbol.ContainingType.GetAttributes()); - if (attributes.Any(attribute => attribute.AttributeClass?.Name == nameof(System.CodeDom.Compiler.GeneratedCodeAttribute))) + if (generatedCodeAttribute is not null && + attributes.Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, generatedCodeAttribute))) { return; } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingMethodPascal.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingMethodPascal.cs index b572f48..bf6ecab 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingMethodPascal.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingMethodPascal.cs @@ -89,8 +89,11 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) return; } + INamedTypeSymbol generatedCodeAttribute = context.Compilation + .GetTypeByMetadataName("System.CodeDom.Compiler.GeneratedCodeAttribute"); ImmutableArray attributes = namedTypeSymbol.GetAttributes().AddRange(namedTypeSymbol.ContainingType.GetAttributes()); - if (attributes.Any(attribute => attribute.AttributeClass?.Name == nameof(System.CodeDom.Compiler.GeneratedCodeAttribute))) + if (generatedCodeAttribute is not null && + attributes.Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, generatedCodeAttribute))) { return; } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingPropertyPascal.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingPropertyPascal.cs index 71410e6..3832f6c 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingPropertyPascal.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/NamingPropertyPascal.cs @@ -39,8 +39,11 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) { var namedTypeSymbol = (IPropertySymbol)context.Symbol; + INamedTypeSymbol generatedCodeAttribute = context.Compilation + .GetTypeByMetadataName("System.CodeDom.Compiler.GeneratedCodeAttribute"); ImmutableArray attributes = namedTypeSymbol.GetAttributes().AddRange(namedTypeSymbol.ContainingType.GetAttributes()); - if (attributes.Any(attribute => attribute.AttributeClass?.Name == nameof(System.CodeDom.Compiler.GeneratedCodeAttribute))) + if (generatedCodeAttribute is not null && + attributes.Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, generatedCodeAttribute))) { return; } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/UnusedLocalVariable.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/UnusedLocalVariable.cs index dfffffc..74b96af 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/UnusedLocalVariable.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/UnusedLocalVariable.cs @@ -16,10 +16,10 @@ public class UnusedLocalVariable : DiagnosticAnalyzer private const string MessageFormat = "Local variable '{0}' should be used"; private const string Description = "All local variables should be accessed, or named with underscores to indicate they are unused."; private const string Category = "Flow"; - private const string HelpLinkUri = "https://github.com/IntelliTect/CodingGuidelines"; + private static readonly string _HelpLinkUri = DiagnosticUrlBuilder.GetUrl(Title, DiagnosticId); private static readonly DiagnosticDescriptor _Rule = new(DiagnosticId, Title, MessageFormat, - Category, DiagnosticSeverity.Info, isEnabledByDefault: true, description: Description, HelpLinkUri); + Category, DiagnosticSeverity.Info, isEnabledByDefault: true, description: Description, _HelpLinkUri); public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(_Rule); @@ -30,7 +30,7 @@ public override void Initialize(AnalysisContext context) throw new System.ArgumentNullException(nameof(context)); } - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); } diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/DiagnosticUrlBuilder.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/DiagnosticUrlBuilder.cs index 13bdebf..343b7ee 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/DiagnosticUrlBuilder.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/DiagnosticUrlBuilder.cs @@ -6,6 +6,7 @@ namespace IntelliTect.Analyzer public static class DiagnosticUrlBuilder { private const string BaseUrl = "https://github.com/IntelliTect/CodingGuidelines"; + private static readonly Regex _HyphenateRegex = new(@"\s+", RegexOptions.Compiled); /// /// Get the full diagnostic help url @@ -21,8 +22,7 @@ public static string GetUrl(string title, string diagnosticId) if (string.IsNullOrWhiteSpace(diagnosticId)) throw new System.ArgumentException("diagnostic ID cannot be empty", nameof(diagnosticId)); - Regex hyphenateRegex = new Regex(@"\s"); - string hyphenatedTitle = hyphenateRegex.Replace(title, "-"); + string hyphenatedTitle = _HyphenateRegex.Replace(title, "-"); return BaseUrl + $"#{diagnosticId.ToUpperInvariant()}" + $"---{hyphenatedTitle.ToUpperInvariant()}"; }