From bd692ea992ce39355a21fbdac81f7be71c9c310c Mon Sep 17 00:00:00 2001 From: Jeroen Vannevel Date: Sat, 24 Jan 2026 02:03:19 +0000 Subject: [PATCH 1/3] LoggerMessageAttribute --- AGENTS.md | 1 + CHANGELOG.md | 3 + Directory.Build.props | 2 +- Directory.Packages.props | 1 + README.md | 3 +- .../LoggerMessageAttributeCodeFix.cs | 245 +++++ .../SharpSource.Package.csproj | 2 +- .../Helpers/CSCodeFix+Test.cs | 5 +- .../SharpSource.Test/Helpers/CSCodeFix.cs | 12 +- .../LoggerMessageAttributeTests.cs | 983 ++++++++++++++++++ .../SharpSource.Test/SharpSource.Test.csproj | 1 + .../LoggerMessageAttributeAnalyzer.cs | 122 +++ .../SharpSource/Utilities/DiagnosticId.cs | 1 + docs/SS065-LoggerMessageAttribute.md | 61 ++ sharpsource.sln | 1 + 15 files changed, 1436 insertions(+), 7 deletions(-) create mode 100644 SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs create mode 100644 SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs create mode 100644 SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs create mode 100644 docs/SS065-LoggerMessageAttribute.md diff --git a/AGENTS.md b/AGENTS.md index 2337aac0..fbb33db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ Generally speaking you want to follow the following pattern: * Avoid exceptions at all costs. You want to religiously `null`-check everything because you must assume that code is most frequently in an invalid state during active development. * When asserting against a diagnostic you must use the special `{|#` and `|}` tags to indicate where in the source code the squiggly lines are shown. The `` is to be replaced with a 0-indexed numeral (0, 1, 2, 3) and represents the index of the diagnostic that is being reported. * When writing tests, give the test a descriptive name that captures the nuance of the scenario it's testing. Do not hesitate to use long names if it captures the intent better. +* Make sure there are no return statements inside the `RegisterCodeFix` callback - it means the user would see a preview of the same document. Do all precondition checks before this point. ## How to run diff --git a/CHANGELOG.md b/CHANGELOG.md index ab18e065..e719a956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # CHANGELOG https://keepachangelog.com/en/1.0.0/ +## [1.31.0] - 2026-01-24 +- `LoggerMessageAttribute`: This analyzer suggests converting regular logging calls to the source-generated logging pattern. + ## [1.30.0] - 2026-01-23 - `StringConcatenatedInLoop`: Now also catches interpolated strings - `StringPlaceholdersInWrongOrder`: Retains existing whitespace when applying the code fix diff --git a/Directory.Build.props b/Directory.Build.props index 28c948e0..3bb2365d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ enable - 11.0 + 12.0 true latest NU1902;NU1903;NU1904 diff --git a/Directory.Packages.props b/Directory.Packages.props index ee08b7ca..89e33f5c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/README.md b/README.md index e1b16d44..bd4ee022 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ or add a reference yourself: ```xml - + ``` @@ -96,6 +96,7 @@ Detailed explanations of each analyzer can be found in the documentation: https: | SS061 | ImmutableCollectionCreatedIncorrectly | | SS062 | ActivityWasNotStopped | | SS063 | ValueTaskAwaitedMultipleTimes | +| SS064 | LoggerMessageAttribute | ## Configuration Is a particular rule not to your liking? There are many ways to adjust their severity and even disable them altogether. For an overview of some of the options, check out [this document](https://docs.microsoft.com/en-gb/dotnet/fundamentals/code-analysis/suppress-warnings). diff --git a/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs b/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs new file mode 100644 index 00000000..8d97afa1 --- /dev/null +++ b/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; +using SharpSource.Utilities; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace SharpSource.Diagnostics; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public class LoggerMessageAttributeCodeFix : CodeFixProvider +{ + private static readonly Regex NonAlphaRegex = new(@"[^a-zA-Z\s]", RegexOptions.Compiled); + private static readonly Regex PlaceholderRegex = new(@"\{([^}:]+)(?::[^}]*)?\}", RegexOptions.Compiled); + + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(LoggerMessageAttributeAnalyzer.Rule.Id); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var diagnostic = context.Diagnostics[0]; + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var invocation = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + if (invocation is null) + { + return; + } + + // Get the containing type - needed for adding the partial method + var containingType = invocation.Ancestors().OfType().FirstOrDefault(); + if (containingType is null) + { + return; + } + + diagnostic.Properties.TryGetValue("logLevel", out var logLevel); + diagnostic.Properties.TryGetValue("message", out var message); + + // Only offer code fix if we have enough info + if (logLevel is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + "Use LoggerMessage attribute", + _ => UseLoggerMessageAttribute(context.Document, root, invocation, containingType, logLevel, message), + LoggerMessageAttributeAnalyzer.Rule.Id), + diagnostic); + } + + private static Task UseLoggerMessageAttribute(Document document, SyntaxNode root, InvocationExpressionSyntax invocation, TypeDeclarationSyntax containingType, string logLevel, string? message) + { + // Generate a method name based on the log message or log level + var methodName = GenerateMethodName(logLevel, message); + + // Extract arguments that should become method parameters + var (parameters, arguments, templateArgs) = ExtractParametersAndArguments(invocation, message); + + // Create the new partial method + var newMethod = CreateLoggerMessageMethod(methodName, logLevel, message, parameters); + + // Create the new invocation + var newInvocation = CreateInvocation(invocation, methodName, arguments); + + // First, replace the invocation within the containing type + var newContainingType = containingType.ReplaceNode(invocation, newInvocation); + + // Add partial modifier if not present + if (!newContainingType.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + newContainingType = newContainingType.AddModifiers(Token(SyntaxKind.PartialKeyword)); + } + + // Add the new method + newContainingType = newContainingType.AddMembers(newMethod); + + // Replace the containing type in the root + var newRoot = root.ReplaceNode(containingType, newContainingType); + + var newDocument = document.WithSyntaxRoot(newRoot); + return Task.FromResult(newDocument); + } + + + private static string GenerateMethodName(string logLevel, string? message) + { + if (message is not null) + { + // Extract meaningful words from the message + var words = NonAlphaRegex.Replace(message, " ") + .Split([' '], System.StringSplitOptions.RemoveEmptyEntries) + .Take(4); + + if (words.Any()) + { + var methodName = string.Join("", words.Select(w => char.ToUpperInvariant(w[0]) + w.Substring(1).ToLowerInvariant())); + return "Log" + methodName; + } + } + + return "Log" + logLevel; + } + + private static (ParameterListSyntax parameters, ArgumentListSyntax arguments, string[] templateArgs) ExtractParametersAndArguments(InvocationExpressionSyntax invocation, string? message) + { + var parameters = new System.Collections.Generic.List(); + var arguments = new System.Collections.Generic.List(); + var templateArgs = Array.Empty(); + + // First, add the logger parameter + ExpressionSyntax? loggerExpression = null; + + // Find the logger expression from the invocation + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + loggerExpression = memberAccess.Expression; + } + + // Extract template placeholders from the message + if (message is not null) + { + var placeholders = PlaceholderRegex.Matches(message); + templateArgs = placeholders.Cast().Select(m => m.Groups[1].Value).ToArray(); + } + + // Find format arguments from the invocation + var invocationArgs = invocation.ArgumentList.Arguments.ToList(); + + // Skip logger (for extension methods), log level (for Log method), and message arguments + var formatArgsStart = invocationArgs.FindIndex(a => + { + var paramName = a.NameColon?.Name.Identifier.Text; + return paramName == "args" || (a.Expression is not LiteralExpressionSyntax && !IsLogLevelExpression(a.Expression) && paramName != "message" && paramName != "eventId" && paramName != "exception"); + }); + + if (formatArgsStart >= 0) + { + for (var i = formatArgsStart; i < invocationArgs.Count; i++) + { + var arg = invocationArgs[i]; + var paramName = i - formatArgsStart < templateArgs.Length + ? ToCamelCase(templateArgs[i - formatArgsStart]) + : $"arg{i - formatArgsStart}"; + + // Create parameter + var paramType = ParseTypeName("object"); // Default to object, ideally we'd infer the type + parameters.Add(Parameter(Identifier(paramName)).WithType(paramType)); + + // Create argument + arguments.Add(Argument(arg.Expression)); + } + } + + var parameterList = ParameterList(SeparatedList(parameters)); + var argumentList = ArgumentList(SeparatedList(arguments)); + + return (parameterList, argumentList, templateArgs); + } + + private static bool IsLogLevelExpression(ExpressionSyntax expression) => + expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression.ToString().Contains("LogLevel"); + + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + + private static MethodDeclarationSyntax CreateLoggerMessageMethod(string methodName, string logLevel, string? message, ParameterListSyntax parameters) + { + // Build the attribute + var attributeArguments = new List + { + AttributeArgument( + NameEquals("Level"), + null, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("LogLevel"), + IdentifierName(logLevel))) + }; + + // Generate message template - either from the original message or from parameter names + var messageTemplate = message; + if (messageTemplate is null && parameters.Parameters.Count > 0) + { + // Generate a message template from the parameter names + var placeholders = parameters.Parameters.Select(p => $"{{{p.Identifier.Text}}}"); + messageTemplate = string.Join(" ", placeholders); + } + + if (messageTemplate is not null) + { + attributeArguments.Add( + AttributeArgument( + NameEquals("Message"), + null, + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(messageTemplate)))); + } + + var attribute = Attribute(IdentifierName("LoggerMessage")) + .WithArgumentList(AttributeArgumentList(SeparatedList(attributeArguments))); + + var attributeList = AttributeList(SingletonSeparatedList(attribute)); + + // Create the method + var method = MethodDeclaration(PredefinedType(Token(SyntaxKind.VoidKeyword)), methodName) + .WithAttributeLists(SingletonList(attributeList)) + .AddModifiers( + Token(SyntaxKind.PrivateKeyword), + Token(SyntaxKind.PartialKeyword)) + .WithParameterList(parameters) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.AddImportsAnnotation); + + return method; + } + + private static InvocationExpressionSyntax CreateInvocation(InvocationExpressionSyntax originalInvocation, string methodName, ArgumentListSyntax arguments) => + InvocationExpression(IdentifierName(methodName)) + .WithArgumentList(arguments) + .WithAdditionalAnnotations(Formatter.Annotation); +} diff --git a/SharpSource/SharpSource.Package/SharpSource.Package.csproj b/SharpSource/SharpSource.Package/SharpSource.Package.csproj index 3632bfff..704ebcd5 100644 --- a/SharpSource/SharpSource.Package/SharpSource.Package.csproj +++ b/SharpSource/SharpSource.Package/SharpSource.Package.csproj @@ -9,7 +9,7 @@ SharpSource - 1.30.0 + 1.31.0 Jeroen Vannevel https://github.com/Vannevelj/SharpSource/blob/master/LICENSE.md https://github.com/Vannevelj/SharpSource diff --git a/SharpSource/SharpSource.Test/Helpers/CSCodeFix+Test.cs b/SharpSource/SharpSource.Test/Helpers/CSCodeFix+Test.cs index 75f6f8c4..c1ce83cb 100644 --- a/SharpSource/SharpSource.Test/Helpers/CSCodeFix+Test.cs +++ b/SharpSource/SharpSource.Test/Helpers/CSCodeFix+Test.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; using System.Net.Http; using Microsoft.CodeAnalysis; @@ -38,6 +39,7 @@ public Test() TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestFixtureAttribute).Assembly.Location); TestState.AdditionalReferences.Add(typeof(Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute).Assembly.Location); TestState.AdditionalReferences.Add(typeof(Newtonsoft.Json.JsonSerializer).Assembly.Location); + TestState.AdditionalReferences.Add(typeof(Microsoft.Extensions.Logging.LoggerMessageAttribute).Assembly.Location); // Initialized explicitly so the underlying test framework doesn't auto-inject all the netcoreapp3.1 references // Unfortunately MS stopped updating the utility library that abstracted this: https://github.com/dotnet/roslyn-sdk/issues/1047 @@ -60,6 +62,7 @@ protected override CompilationOptions CreateCompilationOptions() => new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true).WithNullableContextOptions(NullableContextOptions); protected override bool IsCompilerDiagnosticIncluded(Diagnostic diagnostic, CompilerDiagnostics compilerDiagnostics) - => diagnostic.Id is not "CS5001" && base.IsCompilerDiagnosticIncluded(diagnostic, compilerDiagnostics); + // CS5001: Program does not contain a static 'Main' method suitable for an entry point + => diagnostic.Id is not "CS5001" && !DisabledDiagnostics.Contains(diagnostic.Id) && base.IsCompilerDiagnosticIncluded(diagnostic, compilerDiagnostics); } } \ No newline at end of file diff --git a/SharpSource/SharpSource.Test/Helpers/CSCodeFix.cs b/SharpSource/SharpSource.Test/Helpers/CSCodeFix.cs index 1ca02fcd..40bfc6e7 100644 --- a/SharpSource/SharpSource.Test/Helpers/CSCodeFix.cs +++ b/SharpSource/SharpSource.Test/Helpers/CSCodeFix.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -57,11 +58,11 @@ public static async Task VerifyCodeFix(string source, string fixedSource) => await VerifyCodeFix(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); /// - public static async Task VerifyCodeFix(string source, DiagnosticResult expected, string fixedSource, int codeActionIndex = 0) - => await VerifyCodeFix(source, new[] { expected }, fixedSource, codeActionIndex); + public static async Task VerifyCodeFix(string source, DiagnosticResult expected, string fixedSource, int codeActionIndex = 0, string[]? disabledDiagnostics = null) + => await VerifyCodeFix(source, [expected], fixedSource, codeActionIndex, additionalFiles: null, batchFixedSource: null, disabledDiagnostics); /// - public static async Task VerifyCodeFix(string source, DiagnosticResult[] expected, string fixedSource, int codeActionIndex = 0, string[]? additionalFiles = null, string? batchFixedSource = null) + public static async Task VerifyCodeFix(string source, DiagnosticResult[] expected, string fixedSource, int codeActionIndex = 0, string[]? additionalFiles = null, string? batchFixedSource = null, string[]? disabledDiagnostics = null) { var test = new Test { @@ -71,6 +72,11 @@ public static async Task VerifyCodeFix(string source, DiagnosticResult[] expecte CodeActionIndex = codeActionIndex }; + if (disabledDiagnostics != null) + { + test.DisabledDiagnostics.AddRange(disabledDiagnostics); + } + if (additionalFiles != null) { foreach (var file in additionalFiles) diff --git a/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs b/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs new file mode 100644 index 00000000..7f99d922 --- /dev/null +++ b/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs @@ -0,0 +1,983 @@ +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using VerifyCS = SharpSource.Test.CSharpCodeFixVerifier; + +namespace SharpSource.Test; + +[TestClass] +public class LoggerMessageAttributeTests +{ + [TestMethod] + public async Task LoggerMessageAttribute_LogInformation_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation(""User logged in"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogUserLoggedIn(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User logged in"")] + private partial void LogUserLoggedIn(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_LogWarning_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogWarning(""Cache miss detected"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogCacheMissDetected(); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = ""Cache miss detected"")] + private partial void LogCacheMissDetected(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogWarning"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_LogError_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogError(""Failed to process request"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogFailedToProcessRequest(); + } + + [LoggerMessage(Level = LogLevel.Error, Message = ""Failed to process request"")] + private partial void LogFailedToProcessRequest(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogError"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_LogDebug_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogDebug(""Entering method"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogEnteringMethod(); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = ""Entering method"")] + private partial void LogEnteringMethod(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogDebug"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_LogCritical_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogCritical(""System failure"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogSystemFailure(); + } + + [LoggerMessage(Level = LogLevel.Critical, Message = ""System failure"")] + private partial void LogSystemFailure(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogCritical"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_LogTrace_WithMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogTrace(""Verbose trace"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogVerboseTrace(); + } + + [LoggerMessage(Level = LogLevel.Trace, Message = ""Verbose trace"")] + private partial void LogVerboseTrace(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogTrace"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_Log_WithLogLevel() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.Log(LogLevel.Warning, ""Something happened"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogSomethingHappened(); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = ""Something happened"")] + private partial void LogSomethingHappened(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of Log"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_Log_FullyQualifiedStaticCall() + { + // Extension method called as static method without using statement + var original = @" +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public MyClass(Microsoft.Extensions.Logging.ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:Microsoft.Extensions.Logging.LoggerExtensions.Log(_logger, Microsoft.Extensions.Logging.LogLevel.Error, ""An error occurred"")|}; + } + } +}"; + + // Just verify the diagnostic is raised (code fix for static call syntax is complex) + await VerifyCS.VerifyAnalyzerAsync(original, null, + VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of Log")); + } + + [TestMethod] + public async Task LoggerMessageAttribute_WithPlaceholders() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string userId, int count) + { + {|#0:_logger.LogInformation(""User {UserId} performed {Count} actions"", userId, count)|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string userId, int count) + { + LogUserUseridPerformedCount(userId, count); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User {UserId} performed {Count} actions"")] + private partial void LogUserUseridPerformedCount(object userId, object count); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_ClassAlreadyPartial() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation(""User logged in"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogUserLoggedIn(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User logged in"")] + private partial void LogUserLoggedIn(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_UnrelatedMethod_NoDiagnostic() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + _logger.IsEnabled(LogLevel.Information); + } + } +}"; + + await VerifyCS.VerifyNoDiagnostic(original); + } + + [TestMethod] + public async Task LoggerMessageAttribute_DifferentLoggerInterface_NoDiagnostic() + { + // Use a completely different ILogger interface that's not from Microsoft.Extensions.Logging + var original = @" +namespace ConsoleApplication1 +{ + interface ILogger + { + void LogInformation(string message); + } + + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + _logger.LogInformation(""User logged in""); + } + } +}"; + + await VerifyCS.VerifyNoDiagnostic(original); + } + + [TestMethod] + public async Task LoggerMessageAttribute_CustomLogMethod_NoDiagnostic() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogSomething(""test""); + } + + void LogSomething(string message) + { + // Not an ILogger method + } + } +}"; + + await VerifyCS.VerifyNoDiagnostic(original); + } + + [TestMethod] + public async Task LoggerMessageAttribute_TopLevelStatement_NoDiagnostic() + { + // Top-level statements don't have a containing type to add the partial method to + var original = @" +using Microsoft.Extensions.Logging; + +ILogger logger = null!; +logger.LogInformation(""Application started""); +"; + + await VerifyCS.VerifyNoDiagnostic(original); + } + + [TestMethod] + public async Task LoggerMessageAttribute_FullyQualifiedLoggerType() + { + // Uses fully qualified type names but still needs using for extension methods + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public MyClass(Microsoft.Extensions.Logging.ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation(""User logged in"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public MyClass(Microsoft.Extensions.Logging.ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogUserLoggedIn(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User logged in"")] + private partial void LogUserLoggedIn(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_CustomLoggerInterfaceInheritingFromILogger() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + interface IMyLogger : ILogger + { + void LogCustom(string message); + } + + class MyClass + { + private readonly IMyLogger _logger; + + public MyClass(IMyLogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation(""User logged in"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + interface IMyLogger : ILogger + { + void LogCustom(string message); + } + + partial class MyClass + { + private readonly IMyLogger _logger; + + public MyClass(IMyLogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogUserLoggedIn(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User logged in"")] + private partial void LogUserLoggedIn(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_NonConstantMessage() + { + // When the message is not a constant, we still flag it and generate a message template from parameters + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string message) + { + {|#0:_logger.LogInformation(message)|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string message) + { + LogInformation(message); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""{arg0}"")] + private partial void LogInformation(object arg0); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_MultiplePlaceholders() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string userId, string action, int duration, bool success) + { + {|#0:_logger.LogInformation(""User {UserId} performed {Action} in {Duration}ms with success={Success}"", userId, action, duration, success)|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod(string userId, string action, int duration, bool success) + { + LogUserUseridPerformedAction(userId, action, duration, success); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""User {UserId} performed {Action} in {Duration}ms with success={Success}"")] + private partial void LogUserUseridPerformedAction(object userId, object action, object duration, object success); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_MultiplePlaceholders_NonLiteralArguments() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + var user = GetUser(); + var count = GetCount(); + {|#0:_logger.LogInformation(""User {UserId} performed {Count} actions"", user.Id, count + 1)|}; + } + + User GetUser() => new User(); + int GetCount() => 5; + } + + class User { public string Id { get; set; } } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + var user = GetUser(); + var count = GetCount(); + LogUserUseridPerformedCount(user.Id, count + 1); + } + + User GetUser() => new User(); + int GetCount() => 5; + [LoggerMessage(Level = LogLevel.Information, Message = ""User {UserId} performed {Count} actions"")] + private partial void LogUserUseridPerformedCount(object userId, object count); + } + + class User { public string Id { get; set; } } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_SingleCharacterWords() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation(""A B C"")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogABC(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = ""A B C"")] + private partial void LogABC(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } + + [TestMethod] + public async Task LoggerMessageAttribute_EmptyMessage() + { + var original = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + {|#0:_logger.LogInformation("""")|}; + } + } +}"; + + var result = @" +using Microsoft.Extensions.Logging; + +namespace ConsoleApplication1 +{ + partial class MyClass + { + private readonly ILogger _logger; + + public MyClass(ILogger logger) + { + _logger = logger; + } + + void MyMethod() + { + LogInformation(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = """")] + private partial void LogInformation(); + } +}"; + + await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); + } +} diff --git a/SharpSource/SharpSource.Test/SharpSource.Test.csproj b/SharpSource/SharpSource.Test/SharpSource.Test.csproj index 81368926..7d602b15 100644 --- a/SharpSource/SharpSource.Test/SharpSource.Test.csproj +++ b/SharpSource/SharpSource.Test/SharpSource.Test.csproj @@ -16,6 +16,7 @@ + diff --git a/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs b/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs new file mode 100644 index 00000000..969a7b4a --- /dev/null +++ b/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs @@ -0,0 +1,122 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using SharpSource.Utilities; + +namespace SharpSource.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class LoggerMessageAttributeAnalyzer : DiagnosticAnalyzer +{ + private static readonly string[] LogMethodNames = { "Log", "LogTrace", "LogDebug", "LogInformation", "LogWarning", "LogError", "LogCritical" }; + + public static DiagnosticDescriptor Rule => new( + DiagnosticId.LoggerMessageAttribute, + "Use the LoggerMessage attribute for high-performance logging", + "Use the LoggerMessage attribute for high-performance logging instead of {0}", + Categories.Performance, + DiagnosticSeverity.Info, + true, + helpLinkUri: "https://github.com/Vannevelj/SharpSource/blob/master/docs/SS065-LoggerMessageAttribute.md"); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.RegisterCompilationStartAction(compilationContext => + { + var loggerMessageAttributeSymbol = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.LoggerMessageAttribute"); + if (loggerMessageAttributeSymbol is null) + { + // LoggerMessageAttribute is not available in this compilation + return; + } + + var iloggerSymbol = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.ILogger"); + if (iloggerSymbol is null) + { + return; + } + + var loggerExtensionsSymbol = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.LoggerExtensions"); + + compilationContext.RegisterOperationAction( + context => Analyze(context, (IInvocationOperation)context.Operation, iloggerSymbol, loggerExtensionsSymbol), + OperationKind.Invocation); + }); + } + + private static void Analyze(OperationAnalysisContext context, IInvocationOperation invocation, INamedTypeSymbol iloggerSymbol, INamedTypeSymbol? loggerExtensionsSymbol) + { + var targetMethod = invocation.TargetMethod; + var methodName = targetMethod.Name; + + // Check if it's a log method we care about + if (!LogMethodNames.Contains(methodName)) + { + return; + } + + // Check if it's on ILogger or LoggerExtensions + var containingType = targetMethod.ContainingType; + var isOnILogger = containingType.Equals(iloggerSymbol, SymbolEqualityComparer.Default) || + containingType.AllInterfaces.Any(i => i.Equals(iloggerSymbol, SymbolEqualityComparer.Default)); + var isOnLoggerExtensions = loggerExtensionsSymbol is not null && + containingType.Equals(loggerExtensionsSymbol, SymbolEqualityComparer.Default); + + if (!isOnILogger && !isOnLoggerExtensions) + { + return; + } + + // Don't flag if we're in top-level statements (no containing type to add the partial method to) + // Top-level statements have a synthesized Program class, but no TypeDeclarationSyntax in the tree + var hasContainingTypeDeclaration = invocation.Syntax.Ancestors() + .Any(a => a is Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax); + if (!hasContainingTypeDeclaration) + { + return; + } + + // Extract info needed for the code fix + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("methodName", methodName); + + // Try to extract the log level + string? logLevel = null; + if (methodName != "Log") + { + if (methodName.Length >= 3) + { + // LogInformation -> Information, LogError -> Error, etc. + logLevel = methodName.Substring(3); + } + } + else + { + // For generic Log method, try to get the LogLevel from the first argument + var logLevelArg = invocation.Arguments.FirstOrDefault(a => + a.Parameter?.Type?.Name == "LogLevel"); + if (logLevelArg?.Value is IFieldReferenceOperation fieldRef) + { + logLevel = fieldRef.Field.Name; + } + } + + properties.Add("logLevel", logLevel); + + // Try to find the message template + var messageArg = invocation.Arguments.FirstOrDefault(a => + a.Parameter?.Name == "message" && a.Parameter.Type?.SpecialType == SpecialType.System_String); + if (messageArg?.Value.ConstantValue.HasValue == true) + { + properties.Add("message", messageArg.Value.ConstantValue.Value?.ToString()); + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), properties.ToImmutable(), methodName)); + } +} diff --git a/SharpSource/SharpSource/Utilities/DiagnosticId.cs b/SharpSource/SharpSource/Utilities/DiagnosticId.cs index bf6931b7..48a2ad10 100644 --- a/SharpSource/SharpSource/Utilities/DiagnosticId.cs +++ b/SharpSource/SharpSource/Utilities/DiagnosticId.cs @@ -64,4 +64,5 @@ public static class DiagnosticId public const string ActivityWasNotStopped = "SS062"; public const string ValueTaskAwaitedMultipleTimes = "SS063"; public const string UnnecessaryToStringOnSpan = "SS064"; + public const string LoggerMessageAttribute = "SS065"; } \ No newline at end of file diff --git a/docs/SS065-LoggerMessageAttribute.md b/docs/SS065-LoggerMessageAttribute.md new file mode 100644 index 00000000..faa22188 --- /dev/null +++ b/docs/SS065-LoggerMessageAttribute.md @@ -0,0 +1,61 @@ +# SS065 - LoggerMessageAttribute + +[![Generic badge](https://img.shields.io/badge/Severity-Info-blue.svg)](https://shields.io/) [![Generic badge](https://img.shields.io/badge/CodeFix-Yes-green.svg)](https://shields.io/) + +--- + +The `LoggerMessage` attribute provides a high-performance logging API that eliminates boxing, temporary allocations, and reduces overhead compared to standard `ILogger` extension methods. This analyzer suggests converting regular logging calls to the source-generated logging pattern. + +For more information, see [Compile-time logging source generation](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator). + +--- + +## Violation +```cs +public class MyService +{ + private readonly ILogger _logger; + + public MyService(ILogger logger) + { + _logger = logger; + } + + public void DoWork(string userId) + { + _logger.LogInformation("User {UserId} started work", userId); + } +} +``` + +## Fix +```cs +public partial class MyService +{ + private readonly ILogger _logger; + + public MyService(ILogger logger) + { + _logger = logger; + } + + public void DoWork(string userId) + { + LogUserStartedWork(userId); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} started work")] + private partial void LogUserStartedWork(string userId); +} +``` + +## Benefits + +- **Performance**: Source-generated logging avoids boxing of value types and reduces allocations +- **Structure**: Log messages are parsed at compile time rather than runtime +- **Consistency**: Enforces a consistent logging pattern across the codebase +- **Type Safety**: Parameters are strongly typed in the generated method + +## Notes + +This analyzer only triggers when `Microsoft.Extensions.Logging.LoggerMessageAttribute` is available in the project (typically .NET 6+). diff --git a/sharpsource.sln b/sharpsource.sln index 61d992e0..06dc6274 100644 --- a/sharpsource.sln +++ b/sharpsource.sln @@ -17,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject AGENTS.md = AGENTS.md CHANGELOG.md = CHANGELOG.md + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Global From a4b139f2587b1a987a6e347df2faab7e17ed71c0 Mon Sep 17 00:00:00 2001 From: Jeroen Vannevel Date: Sat, 24 Jan 2026 02:09:04 +0000 Subject: [PATCH 2/3] added missings docs and improved README --- README.md | 129 ++++++++++++------------ docs/SS064-UnnecessaryToStringOnSpan.md | 37 +++++++ 2 files changed, 102 insertions(+), 64 deletions(-) create mode 100644 docs/SS064-UnnecessaryToStringOnSpan.md diff --git a/README.md b/README.md index bd4ee022..0e4bb8d8 100644 --- a/README.md +++ b/README.md @@ -33,70 +33,71 @@ Interested in contributing? Take a look at [the guidelines](./CONTRIBUTING.md)! Detailed explanations of each analyzer can be found in the documentation: https://github.com/Vannevelj/SharpSource/tree/master/docs -| Code | Name | -|---|---| -| SS001 | AsyncMethodWithVoidReturnType | -| SS002 | DateTimeNow | -| SS003 | DivideIntegerByInteger | -| SS004 | ElementaryMethodsOfTypeInCollectionNotOverridden | -| SS005 | EqualsAndGetHashcodeNotImplementedTogether | -| SS006 | ThrowNull | -| SS007 | FlagsEnumValuesAreNotPowersOfTwo | -| SS008 | GetHashCodeRefersToMutableMember | -| SS009 | LoopedRandomInstantiation | -| SS010 | NewGuid | -| SS011 | OnPropertyChangedWithoutNameofOperator | -| SS012 | RecursiveOperatorOverload | -| SS013 | RethrowExceptionWithoutLosingStacktrace | -| SS014 | StringDotFormatWithDifferentAmountOfArguments | -| SS015 | StringPlaceholdersInWrongOrder | -| SS017 | StructWithoutElementaryMethodsOverridden | -| SS018 | SwitchDoesNotHandleAllEnumOptions | -| SS019 | SwitchIsMissingDefaultLabel | -| SS020 | TestMethodWithoutPublicModifier | -| SS021 | TestMethodWithoutTestAttribute | -| SS022 | ExceptionThrownFromImplicitOperator | -| SS023 | ExceptionThrownFromPropertyGetter | -| SS024 | ExceptionThrownFromStaticConstructor | -| SS025 | ExceptionThrownFromFinallyBlock | -| SS026 | ExceptionThrownFromEqualityOperator | -| SS027 | ExceptionThrownFromDispose | -| SS028 | ExceptionThrownFromFinalizer | -| SS029 | ExceptionThrownFromGetHashCode | -| SS030 | ExceptionThrownFromEquals | -| SS032 | ThreadSleepInAsyncMethod | -| SS033 | AsyncOverloadsAvailable | -| SS034 | AccessingTaskResultWithoutAwait | -| SS035 | SynchronousTaskWait | -| SS036 | ExplicitEnumValues | -| SS037 | HttpClientInstantiatedDirectly | -| SS038 | HttpContextStoredInField | -| SS039 | EnumWithoutDefaultValue | -| SS040 | UnusedResultOnImmutableObject | -| SS041 | UnnecessaryEnumerableMaterialization | -| SS042 | InstanceFieldWithThreadStatic | -| SS043 | MultipleFromBodyParameters | -| SS044 | AttributeMustSpecifyAttributeUsage | -| SS045 | StaticInitializerAccessedBeforeInitialization | -| SS046 | UnboundedStackalloc | -| SS047 | LinqTraversalBeforeFilter | -| SS048 | LockingOnDiscouragedObject | -| SS049 | ComparingStringsWithoutStringComparison | -| SS050 | ParameterAssignedInConstructor | -| SS051 | LockingOnMutableReference | -| SS052 | ThreadStaticWithInitializer | -| SS053 | PointlessCollectionToString | -| SS054 | NewtonsoftMixedWithSystemTextJson | -| SS055 | MultipleOrderByCalls | -| SS056 | FormReadSynchronously | -| SS057 | CollectionManipulatedDuringTraversal | -| SS058 | StringConcatenatedInLoop | -| SS059 | DisposeAsyncDisposable | -| SS060 | ConcurrentDictionaryEmptyCheck | -| SS061 | ImmutableCollectionCreatedIncorrectly | -| SS062 | ActivityWasNotStopped | -| SS063 | ValueTaskAwaitedMultipleTimes | -| SS064 | LoggerMessageAttribute | +| Code | Name | Description | +|--------|------|-------------| +| SS001 | AsyncMethodWithVoidReturnType | Async methods should return `Task` instead of `void` to allow proper exception handling and awaiting. | +| SS002 | DateTimeNow | Use `DateTime.UtcNow` instead of `DateTime.Now` to avoid timezone-related issues. | +| SS003 | DivideIntegerByInteger | Dividing integers results in integer division; cast to floating-point if a decimal result is expected. | +| SS004 | ElementaryMethodsOfTypeInCollectionNotOverridden | Types used as collection keys should override `Equals` and `GetHashCode`. | +| SS005 | EqualsAndGetHashcodeNotImplementedTogether | `Equals` and `GetHashCode` should always be overridden together. | +| SS006 | ThrowNull | Throwing `null` will result in a `NullReferenceException` at runtime. | +| SS007 | FlagsEnumValuesAreNotPowersOfTwo | Flags enum values should be powers of two to allow proper bitwise operations. | +| SS008 | GetHashCodeRefersToMutableMember | `GetHashCode` should not reference mutable members as this breaks hash-based collections. | +| SS009 | LoopedRandomInstantiation | Creating `Random` instances in a loop can produce identical sequences; reuse a single instance. | +| SS010 | NewGuid | Use `Guid.NewGuid()` instead of `new Guid()` to generate a unique identifier. | +| SS011 | OnPropertyChangedWithoutNameofOperator | Use `nameof()` instead of hardcoded property name strings in `OnPropertyChanged`. | +| SS012 | RecursiveOperatorOverload | Operator overloads calling themselves will cause infinite recursion. | +| SS013 | RethrowExceptionWithoutLosingStacktrace | Use `throw;` instead of `throw ex;` to preserve the original stack trace. | +| SS014 | StringDotFormatWithDifferentAmountOfArguments | The number of format placeholders should match the number of arguments. | +| SS015 | StringPlaceholdersInWrongOrder | Format placeholders should be in sequential order starting from `{0}`. | +| SS017 | StructWithoutElementaryMethodsOverridden | Structs should override `Equals`, `GetHashCode`, and implement `IEquatable` for performance. | +| SS018 | SwitchDoesNotHandleAllEnumOptions | Switch statements on enums should handle all possible values. | +| SS019 | SwitchIsMissingDefaultLabel | Switch statements should have a default case to handle unexpected values. | +| SS020 | TestMethodWithoutPublicModifier | Test methods must be public to be discovered by test runners. | +| SS021 | TestMethodWithoutTestAttribute | Methods that look like tests should have a test attribute to be executed. | +| SS022 | ExceptionThrownFromImplicitOperator | Implicit operators should not throw exceptions as they are called invisibly. | +| SS023 | ExceptionThrownFromPropertyGetter | Property getters should not throw exceptions; consider using a method instead. | +| SS024 | ExceptionThrownFromStaticConstructor | Exceptions in static constructors cause `TypeInitializationException` and make the type unusable. | +| SS025 | ExceptionThrownFromFinallyBlock | Exceptions in finally blocks can mask original exceptions from try blocks. | +| SS026 | ExceptionThrownFromEqualityOperator | Equality operators should not throw exceptions; return `false` for invalid comparisons. | +| SS027 | ExceptionThrownFromDispose | `Dispose` methods should not throw exceptions as they may be called during exception unwinding. | +| SS028 | ExceptionThrownFromFinalizer | Finalizers should not throw exceptions as this will terminate the process. | +| SS029 | ExceptionThrownFromGetHashCode | `GetHashCode` should not throw exceptions; return a consistent value instead. | +| SS030 | ExceptionThrownFromEquals | `Equals` should not throw exceptions; return `false` for invalid comparisons. | +| SS032 | ThreadSleepInAsyncMethod | Use `await Task.Delay()` instead of `Thread.Sleep()` in async methods to avoid blocking threads. | +| SS033 | AsyncOverloadsAvailable | Use async overloads when available to avoid blocking the calling thread. | +| SS034 | AccessingTaskResultWithoutAwait | Accessing `Task.Result` without awaiting can cause deadlocks; use `await` instead. | +| SS035 | SynchronousTaskWait | Using `.Wait()` or `.Result` on tasks can cause deadlocks; use `await` instead. | +| SS036 | ExplicitEnumValues | Enum members should have explicit values when the values are persisted or serialized. | +| SS037 | HttpClientInstantiatedDirectly | Use `IHttpClientFactory` instead of creating `HttpClient` directly to avoid socket exhaustion. | +| SS038 | HttpContextStoredInField | `HttpContext` should not be stored in fields as it's request-scoped and may be invalid later. | +| SS039 | EnumWithoutDefaultValue | Enums should have a member with value `0` to represent the default state. | +| SS040 | UnusedResultOnImmutableObject | Methods on immutable types return new instances; the result should not be discarded. | +| SS041 | UnnecessaryEnumerableMaterialization | Avoid materializing enumerables (e.g., `ToList()`) when the result is immediately enumerated. | +| SS042 | InstanceFieldWithThreadStatic | `[ThreadStatic]` only works on static fields; it has no effect on instance fields. | +| SS043 | MultipleFromBodyParameters | Web API actions can only have one `[FromBody]` parameter. | +| SS044 | AttributeMustSpecifyAttributeUsage | Custom attributes should specify `[AttributeUsage]` to define valid targets. | +| SS045 | StaticInitializerAccessedBeforeInitialization | Static field initializers may access fields before they are initialized. | +| SS046 | UnboundedStackalloc | `stackalloc` without a size limit can cause stack overflow; consider using a maximum size. | +| SS047 | LinqTraversalBeforeFilter | Apply `Where` filters before `Select` projections to avoid unnecessary work. | +| SS048 | LockingOnDiscouragedObject | Avoid locking on `this`, `typeof()`, or strings as these can cause deadlocks. | +| SS049 | ComparingStringsWithoutStringComparison | String comparisons should specify a `StringComparison` to ensure correct behavior. | +| SS050 | ParameterAssignedInConstructor | Assigning to a parameter instead of a field in a constructor is likely a mistake. | +| SS051 | LockingOnMutableReference | Locking on a field that can be reassigned may cause race conditions. | +| SS052 | ThreadStaticWithInitializer | `[ThreadStatic]` field initializers only run once; use lazy initialization instead. | +| SS053 | PointlessCollectionToString | Calling `ToString()` on collections returns the type name, not the contents. | +| SS054 | NewtonsoftMixedWithSystemTextJson | Mixing Newtonsoft.Json and System.Text.Json attributes causes serialization issues. | +| SS055 | MultipleOrderByCalls | Multiple `OrderBy` calls override each other; use `ThenBy` for secondary sorting. | +| SS056 | FormReadSynchronously | Reading form data synchronously blocks threads; use async methods instead. | +| SS057 | CollectionManipulatedDuringTraversal | Modifying a collection while iterating over it causes `InvalidOperationException`. | +| SS058 | StringConcatenatedInLoop | Use `StringBuilder` instead of string concatenation in loops for better performance. | +| SS059 | DisposeAsyncDisposable | Types implementing `IAsyncDisposable` should be disposed with `await using`. | +| SS060 | ConcurrentDictionaryEmptyCheck | Use `IsEmpty` instead of `Count == 0` on `ConcurrentDictionary` for thread safety. | +| SS061 | ImmutableCollectionCreatedIncorrectly | Use builder methods or `Create()` instead of constructors for immutable collections. | +| SS062 | ActivityWasNotStopped | `Activity` instances must be stopped to ensure telemetry data is recorded. | +| SS063 | ValueTaskAwaitedMultipleTimes | `ValueTask` can only be awaited once; store the result or convert to `Task` if needed. | +| SS064 | UnnecessaryToStringOnSpan | Avoid calling `ToString()` on spans when an overload accepting spans directly is available. | +| SS065 | LoggerMessageAttribute | Use the `[LoggerMessage]` attribute for high-performance logging instead of extension methods. | ## Configuration Is a particular rule not to your liking? There are many ways to adjust their severity and even disable them altogether. For an overview of some of the options, check out [this document](https://docs.microsoft.com/en-gb/dotnet/fundamentals/code-analysis/suppress-warnings). diff --git a/docs/SS064-UnnecessaryToStringOnSpan.md b/docs/SS064-UnnecessaryToStringOnSpan.md new file mode 100644 index 00000000..e7b35699 --- /dev/null +++ b/docs/SS064-UnnecessaryToStringOnSpan.md @@ -0,0 +1,37 @@ +# SS064 - UnnecessaryToStringOnSpan + +[![Generic badge](https://img.shields.io/badge/Severity-Warning-yellow.svg)](https://shields.io/) [![Generic badge](https://img.shields.io/badge/CodeFix-Yes-green.svg)](https://shields.io/) + +--- + +Calling `ToString()` on a `Span` or `ReadOnlySpan` is unnecessary when an overload that directly accepts spans is available. Using the span-accepting overload avoids allocating a new string and improves performance. + +--- + +## Violation +```cs +ReadOnlySpan span = "hello world".AsSpan(); +Console.WriteLine(span.ToString()); +``` + +## Fix +```cs +ReadOnlySpan span = "hello world".AsSpan(); +Console.WriteLine(span); +``` + +## Why? + +Many APIs in .NET now have overloads that accept `Span` or `ReadOnlySpan` directly. When you call `ToString()` on a span just to pass it to such a method, you: + +1. **Allocate a new string**: The `ToString()` call creates a new string object on the heap +2. **Copy the data**: The characters are copied from the span to the new string +3. **Lose the benefit of spans**: The whole point of spans is to avoid allocations + +By using the span-accepting overload directly, you avoid these costs entirely. + +## Common scenarios + +- `Console.WriteLine(span)` instead of `Console.WriteLine(span.ToString())` +- `StringBuilder.Append(span)` instead of `StringBuilder.Append(span.ToString())` +- `TextWriter.Write(span)` instead of `TextWriter.Write(span.ToString())` From 177e31659dbd26abce5bf399258743cce8fcc16e Mon Sep 17 00:00:00 2001 From: Jeroen Vannevel Date: Sat, 24 Jan 2026 02:11:25 +0000 Subject: [PATCH 3/3] formatting --- .../Diagnostics/LoggerMessageAttributeCodeFix.cs | 4 ++-- SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs | 2 +- .../SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs b/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs index 8d97afa1..ab003536 100644 --- a/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs +++ b/SharpSource/SharpSource.CodeFixes/Diagnostics/LoggerMessageAttributeCodeFix.cs @@ -147,7 +147,7 @@ private static (ParameterListSyntax parameters, ArgumentListSyntax arguments, st var formatArgsStart = invocationArgs.FindIndex(a => { var paramName = a.NameColon?.Name.Identifier.Text; - return paramName == "args" || (a.Expression is not LiteralExpressionSyntax && !IsLogLevelExpression(a.Expression) && paramName != "message" && paramName != "eventId" && paramName != "exception"); + return paramName == "args" || ( a.Expression is not LiteralExpressionSyntax && !IsLogLevelExpression(a.Expression) && paramName != "message" && paramName != "eventId" && paramName != "exception" ); }); if (formatArgsStart >= 0) @@ -242,4 +242,4 @@ private static InvocationExpressionSyntax CreateInvocation(InvocationExpressionS InvocationExpression(IdentifierName(methodName)) .WithArgumentList(arguments) .WithAdditionalAnnotations(Formatter.Annotation); -} +} \ No newline at end of file diff --git a/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs b/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs index 7f99d922..1f80cb42 100644 --- a/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs +++ b/SharpSource/SharpSource.Test/LoggerMessageAttributeTests.cs @@ -980,4 +980,4 @@ void MyMethod() await VerifyCS.VerifyCodeFix(original, VerifyCS.Diagnostic().WithMessage("Use the LoggerMessage attribute for high-performance logging instead of LogInformation"), result, disabledDiagnostics: ["CS8795"]); } -} +} \ No newline at end of file diff --git a/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs b/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs index 969a7b4a..63f148d2 100644 --- a/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs +++ b/SharpSource/SharpSource/Diagnostics/LoggerMessageAttributeAnalyzer.cs @@ -94,7 +94,7 @@ private static void Analyze(OperationAnalysisContext context, IInvocationOperati { // LogInformation -> Information, LogError -> Error, etc. logLevel = methodName.Substring(3); - } + } } else { @@ -119,4 +119,4 @@ private static void Analyze(OperationAnalysisContext context, IInvocationOperati context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), properties.ToImmutable(), methodName)); } -} +} \ No newline at end of file