diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineAnalyzer.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineAnalyzer.cs index 6909599cd3aa..44805a58ddfe 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineAnalyzer.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineAnalyzer.cs @@ -114,6 +114,11 @@ public override void Initialize(AnalysisContext context) } context.RegisterOperationAction(context => AnalyzeInvocation(context, loggerType, loggerExtensionsType, loggerMessageType), OperationKind.Invocation); + + if (wellKnownTypeProvider.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.MicrosoftExtensionsLoggingLoggerMessageAttribute, out var loggerMessageAttributeType)) + { + context.RegisterSymbolAction(context => AnalyzeMethodSymbol(context, loggerMessageAttributeType), SymbolKind.Method); + } }); } @@ -191,6 +196,60 @@ private void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbo } } + private void AnalyzeMethodSymbol(SymbolAnalysisContext context, INamedTypeSymbol loggerMessageAttributeType) + { + var method = (IMethodSymbol)context.Symbol; + + // Look for LoggerMessageAttribute on the method + var attribute = method.GetAttribute(loggerMessageAttributeType); + if (attribute == null) + return; + + // Extract the message template from the attribute + var messageValue = ExtractMessageFromAttribute(attribute); + if (messageValue == null) + return; + + // Analyze the message template for CA1727, CA2253, and CA2023 rules + AnalyzeMessageTemplateFromSymbol(context, messageValue, method); + } + + private string? ExtractMessageFromAttribute(AttributeData attribute) + { + // Check constructor arguments (positional) - typically: LoggerMessage(eventId, level, message) + if (attribute.ConstructorArguments.Length >= 3 && + attribute.ConstructorArguments[2].Value is string constructorMessage) + { + return constructorMessage; + } + + // Check named arguments - Message = "..." + var messageArg = attribute.NamedArguments.FirstOrDefault(arg => arg.Key == "Message"); + if (messageArg.Value.Value is string namedMessage) + { + return namedMessage; + } + + return null; + } + + private void AnalyzeMessageTemplateFromSymbol(SymbolAnalysisContext context, string text, IMethodSymbol methodSymbol) + { + // Get the first syntax reference for reporting location + var syntaxReference = methodSymbol.DeclaringSyntaxReferences.FirstOrDefault(); + if (syntaxReference == null) + return; + + var location = syntaxReference.GetSyntax().GetLocation(); + + // Use the common analysis logic but report diagnostics directly to the symbol context + AnalyzeMessageTemplateCore(text, + onInvalidTemplate: () => context.ReportDiagnostic(Diagnostic.Create(CA2023Rule, location)), + onNumericPlaceholder: () => context.ReportDiagnostic(Diagnostic.Create(CA2253Rule, location)), + onCamelCasePlaceholder: () => context.ReportDiagnostic(Diagnostic.Create(CA1727Rule, location)), + onParameterCountMismatch: null); // Skip parameter count validation for LoggerMessageAttribute methods + } + private void AnalyzeFormatArgument(OperationAnalysisContext context, IOperation formatExpression, int paramsCount, bool argsIsArray, bool usingLoggerExtensionsTypes, IMethodSymbol methodSymbol) { var text = TryGetFormatText(formatExpression); @@ -204,6 +263,26 @@ private void AnalyzeFormatArgument(OperationAnalysisContext context, IOperation return; } + // Use the common analysis logic but report diagnostics to the operation context + AnalyzeMessageTemplateCore(text, + onInvalidTemplate: () => context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2023Rule)), + onNumericPlaceholder: () => context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2253Rule)), + onCamelCasePlaceholder: () => context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA1727Rule)), + onParameterCountMismatch: (expectedCount) => + { + var argsPassedDirectly = argsIsArray && paramsCount == 1; + if (!argsPassedDirectly && paramsCount != expectedCount) + { + context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2017Rule)); + } + }); + } + + /// + /// Core logic for analyzing message templates. Uses callbacks to report diagnostics to different contexts. + /// + private void AnalyzeMessageTemplateCore(string text, Action onInvalidTemplate, Action onNumericPlaceholder, Action onCamelCasePlaceholder, Action? onParameterCountMismatch) + { LogValuesFormatter formatter; try { @@ -218,7 +297,7 @@ private void AnalyzeFormatArgument(OperationAnalysisContext context, IOperation if (!IsValidMessageTemplate(formatter.OriginalFormat)) { - context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2023Rule)); + onInvalidTemplate(); return; } @@ -226,19 +305,16 @@ private void AnalyzeFormatArgument(OperationAnalysisContext context, IOperation { if (int.TryParse(valueName, out _)) { - context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2253Rule)); + onNumericPlaceholder(); } else if (!string.IsNullOrEmpty(valueName) && char.IsLower(valueName[0])) { - context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA1727Rule)); + onCamelCasePlaceholder(); } } - var argsPassedDirectly = argsIsArray && paramsCount == 1; - if (!argsPassedDirectly && paramsCount != formatter.ValueNames.Count) - { - context.ReportDiagnostic(formatExpression.CreateDiagnostic(CA2017Rule)); - } + // Parameter count validation (only for operation-based analysis) + onParameterCountMismatch?.Invoke(formatter.ValueNames.Count); } private static SymbolDisplayFormat GetLanguageSpecificFormat(IOperation operation) => @@ -353,4 +429,4 @@ private static bool FindLogParameters(IMethodSymbol methodSymbol, [NotNullWhen(t return message != null; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineTests.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineTests.cs index 8396cbb225b8..90c2a90b6c48 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineTests.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Runtime/LoggerMessageDefineTests.cs @@ -70,6 +70,52 @@ public async Task CA1727IsProducedForCamelCasedFormatArgumentAsync(string format await TriggerCodeAsync(format); } + [Fact] + public async Task CA1727IsProducedForCamelCasedFormatArgumentInLoggerMessageAttributeAsync() + { + string code = @" +using Microsoft.Extensions.Logging; +public static partial class C +{ + [LoggerMessage(1, LogLevel.Error, ""Unsuccessful status code {|CA1727:{statusCode}|}"")] + static partial void LogUnsuccessfulStatusCode(HttpStatusCode statusCode); + + [LoggerMessage(2, LogLevel.Information, ""User {|CA1727:{userName}|} logged in"")] + static partial void LogUserAction(string userName); + + // This should not trigger CA1727 - PascalCase is correct + [LoggerMessage(3, LogLevel.Debug, ""Processing {ItemCount} items"")] + static partial void LogProcessing(int itemCount); +}"; + await new VerifyCS.Test + { + LanguageVersion = CodeAnalysis.CSharp.LanguageVersion.CSharp9, + TestCode = code, + ReferenceAssemblies = AdditionalMetadataReferences.DefaultWithMELogging, + }.RunAsync(); + } + + [Fact] + public async Task CA1727IsProducedForCamelCasedFormatArgumentInLoggerMessageAttributeWithNamedArgumentAsync() + { + string code = @" +using Microsoft.Extensions.Logging; +public static partial class C +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = ""Error occurred: {|CA1727:{errorMessage}|}"")] + static partial void LogError(string errorMessage); + + [LoggerMessage(EventId = 2, Message = ""Processing {|CA1727:{itemName}|}"")] + static partial void LogProcessingItem(LogLevel level, string itemName); +}"; + await new VerifyCS.Test + { + LanguageVersion = CodeAnalysis.CSharp.LanguageVersion.CSharp9, + TestCode = code, + ReferenceAssemblies = AdditionalMetadataReferences.DefaultWithMELogging, + }.RunAsync(); + } + [Theory] // Concat would be optimized by compiler [MemberData(nameof(GenerateTemplateAndDefineUsageIgnoresCA1848ForBeginScope), @"nameof(ILogger) + "" string""", "")]