diff --git a/Example/ExampleService.cs b/Example/ExampleService.cs index 382b6a3..3ba0e6d 100644 --- a/Example/ExampleService.cs +++ b/Example/ExampleService.cs @@ -379,6 +379,19 @@ private async Task ErrorHandlingExamples() logger.LogWarning("Legacy format: Error {0} occurred in method {1} at line {2}", "ValidationFailed", "ProcessData", 42); + // LogError with exception parameter examples (demonstrates exception parameter highlighting) + var userId = 42; + var errorCode = "E123"; + var errorMessage = "Something went wrong"; + + // Example 1: Exception with string literal constructor + logger.LogError(new Exception("Database connection failed"), "Error processing {UserId} with {ErrorCode} and {Message}", + userId, errorCode, errorMessage); + + // Example 2: Exception with variable constructor + logger.LogError(new Exception(errorMessage), "Failed to validate {UserId} with status {ErrorCode} and details {Message}", + userId, errorCode, errorMessage); + await Task.Delay(100); } diff --git a/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs b/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs index b508407..3ad4629 100644 --- a/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs +++ b/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs @@ -219,5 +219,122 @@ public void GetClassificationSpans_OutputTemplateWithNewLine_HighlightsPropertie c.ClassificationType.Classification == SerilogClassificationTypes.FormatSpecifier).ToList(); Assert.True(formatSpecifiers.Count >= 3, $"Expected at least 3 format specifiers but found {formatSpecifiers.Count}"); } + + [Fact] + public void GetClassificationSpans_LogErrorWithExceptionParameter_HighlightsMessageTemplateProperties() + { + // Arrange - LogError with exception parameter followed by message template and arguments + var code = @"logger.LogError(new Exception(""foo""), ""Error processing {UserId} with {ErrorCode} and {Message}"", userId, errorCode, errorMessage);"; + var textBuffer = MockTextBuffer.Create(code); + var classifier = CreateClassifier(textBuffer); + var span = new SnapshotSpan(textBuffer.CurrentSnapshot, 0, textBuffer.CurrentSnapshot.Length); + + // Act + var result = classifier.GetClassificationSpans(span); + + // Assert - should find classifications for message template properties, not exception constructor + var classifications = result.ToList(); + Assert.NotEmpty(classifications); + + // Should find property names for UserId, ErrorCode, Message from the message template + var properties = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyName).ToList(); + Assert.Equal(3, properties.Count); // UserId, ErrorCode, Message + + // Should find braces for the three properties + var braces = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); + Assert.Equal(6, braces.Count); // 3 opening + 3 closing braces + } + + [Fact] + public void GetClassificationSpans_LogErrorWithExceptionVariable_HighlightsMessageTemplateProperties() + { + // Arrange - LogError with exception variable (not string literal) followed by message template + var code = @"logger.LogError(new Exception(errorMessage), ""Error processing {UserId} with {ErrorCode} and {Message}"", userId, errorCode, errorMessage);"; + var textBuffer = MockTextBuffer.Create(code); + var classifier = CreateClassifier(textBuffer); + var span = new SnapshotSpan(textBuffer.CurrentSnapshot, 0, textBuffer.CurrentSnapshot.Length); + + // Act + var result = classifier.GetClassificationSpans(span); + + // Assert - should find classifications for message template properties + var classifications = result.ToList(); + Assert.NotEmpty(classifications); + + // Should find property names for UserId, ErrorCode, Message from the message template + var properties = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyName).ToList(); + Assert.Equal(3, properties.Count); // UserId, ErrorCode, Message + + // Should find braces for the three properties + var braces = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); + Assert.Equal(6, braces.Count); // 3 opening + 3 closing braces + } + + [Fact] + public void GetClassificationSpans_LogErrorWithRawStringException_HighlightsMessageTemplateProperties() + { + // Arrange - LogError with raw string literal in exception parameter + var code = "logger.LogError(new Exception(\"\"\"Database connection failed\"\"\"), \"Error processing {UserId} with {ErrorCode}\", userId, errorCode);"; + var textBuffer = MockTextBuffer.Create(code); + var classifier = CreateClassifier(textBuffer); + var span = new SnapshotSpan(textBuffer.CurrentSnapshot, 0, textBuffer.CurrentSnapshot.Length); + + // Act + var result = classifier.GetClassificationSpans(span); + + // Assert - should find classifications for message template properties, not exception raw string + var classifications = result.ToList(); + Assert.NotEmpty(classifications); + + // Should find property names for UserId, ErrorCode from the message template + var properties = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyName).ToList(); + Assert.Equal(2, properties.Count); // UserId, ErrorCode + + // Should find braces for the two properties + var braces = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); + Assert.Equal(4, braces.Count); // 2 opening + 2 closing braces + } + + [Fact] + public void GetClassificationSpans_LogErrorWithRawStringContainingProperties_DoesNotHighlightExceptionProperties() + { + // Arrange - LogError with raw string literal containing template-like syntax in exception parameter + // This tests that the raw string properties {ErrorId} and {Details} are NOT highlighted, + // while the actual message template properties {UserId} and {Action} ARE highlighted + var code = "logger.LogError(new Exception(\"\"\"Error {ErrorId} occurred with details {Details}\"\"\"), \"User {UserId} performed {Action}\", userId, action);"; + var textBuffer = MockTextBuffer.Create(code); + var classifier = CreateClassifier(textBuffer); + var span = new SnapshotSpan(textBuffer.CurrentSnapshot, 0, textBuffer.CurrentSnapshot.Length); + + // Act + var result = classifier.GetClassificationSpans(span); + + // Assert + var classifications = result.ToList(); + Assert.NotEmpty(classifications); + + // Should find property names for UserId, Action from the message template (not ErrorId, Details from exception) + var properties = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyName).ToList(); + Assert.Equal(2, properties.Count); // UserId, Action + + // Verify the specific property names found + var propertyTexts = properties.Select(p => textBuffer.CurrentSnapshot.GetText(p.Span)).ToList(); + Assert.Contains("UserId", propertyTexts); + Assert.Contains("Action", propertyTexts); + Assert.DoesNotContain("ErrorId", propertyTexts); // Should NOT highlight properties from raw string exception + Assert.DoesNotContain("Details", propertyTexts); // Should NOT highlight properties from raw string exception + + // Should find braces for the two properties + var braces = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); + Assert.Equal(4, braces.Count); // 2 opening + 2 closing braces for message template only + } } } \ No newline at end of file diff --git a/SerilogSyntax/Classification/SerilogClassifier.cs b/SerilogSyntax/Classification/SerilogClassifier.cs index d205956..8e1318b 100644 --- a/SerilogSyntax/Classification/SerilogClassifier.cs +++ b/SerilogSyntax/Classification/SerilogClassifier.cs @@ -850,8 +850,15 @@ public IList GetClassificationSpans(SnapshotSpan span) } else { + // Check if this is LogError with exception parameter + bool skipFirstString = IsLogErrorWithExceptionParameter(text, match, searchStart); + +#if DEBUG + DiagnosticLogger.Log($" IsLogErrorWithException: {skipFirstString}"); +#endif + // Find just the first string literal (simple case) - var stringLiteral = _stringLiteralParser.FindStringLiteral(text, searchStart, span.Start); + var stringLiteral = _stringLiteralParser.FindStringLiteral(text, searchStart, span.Start, skipFirstString); allStringLiterals = stringLiteral.HasValue ? new List<(int, int, string, bool, int)> { stringLiteral.Value } : []; } } @@ -1033,6 +1040,79 @@ private bool IsInsideRawStringLiteral(SnapshotSpan span) return _multiLineStringDetector.IsInsideRawStringLiteral(span); } + /// + /// Determines if a LogError call has an exception parameter before the message template. + /// Pattern: LogError(exception, "message template", args...) vs LogError("message template", args...) + /// + /// The line of text containing the LogError call. + /// The regex match for the LogError method. + /// The position to start searching from. + /// True if this is LogError with exception parameter, false otherwise. + private bool IsLogErrorWithExceptionParameter(string text, Match match, int searchStart) + { + // Only check LogError calls + if (!match.Value.Contains("LogError")) + return false; + + // Look for the parameter structure by finding the first comma at depth 1 + // LogError(exception, "message", args...) has a comma before the first string + // LogError("message", args...) has the string as the first parameter + + int pos = searchStart; + int parenDepth = 1; // We start after LogError( + bool foundCommaBeforeString = false; + + while (pos < text.Length && parenDepth > 0) + { + char c = text[pos]; + + // Skip whitespace + if (char.IsWhiteSpace(c)) + { + pos++; + continue; + } + + // Track parentheses depth + if (c == '(') + { + parenDepth++; + pos++; + continue; + } + else if (c == ')') + { + parenDepth--; + if (parenDepth == 0) + break; + pos++; + continue; + } + + // At depth 1 (LogError's parameters) + if (parenDepth == 1) + { + // Check for string literal + if (c == '"' || (c == '@' && pos + 1 < text.Length && text[pos + 1] == '"')) + { + // If we found a comma before this string, it means there's an exception parameter + return foundCommaBeforeString; + } + else if (c == ',') + { + foundCommaBeforeString = true; + pos++; + continue; + } + } + + pos++; + } + + // If we never found a string literal, default to false + return false; + } + /// /// Finds the position of the actual string literal when we're inside a multi-line string. /// This is needed because line-by-line processing passes positions that may not be inside diff --git a/SerilogSyntax/Parsing/StringLiteralParser.cs b/SerilogSyntax/Parsing/StringLiteralParser.cs index 9491b60..e0f3084 100644 --- a/SerilogSyntax/Parsing/StringLiteralParser.cs +++ b/SerilogSyntax/Parsing/StringLiteralParser.cs @@ -222,6 +222,7 @@ public bool TryParseRawStringLiteral(string text, int startIndex, out (int Start /// The text to search in. /// The index to start searching from. /// The absolute position of the span start in the document. + /// If true, skips the first string literal found (used for LogError with exception parameter). /// /// A tuple containing the start position, end position, content, whether it's a verbatim string, /// and the quote count for raw strings, or null if not found. @@ -229,10 +230,12 @@ public bool TryParseRawStringLiteral(string text, int startIndex, out (int Start public (int start, int end, string text, bool isVerbatim, int quoteCount)? FindStringLiteral( string text, int startIndex, - int spanStart) + int spanStart, + bool skipFirstString = false) { // Look for string literal after Serilog method call int parenDepth = 1; + bool firstStringSkipped = !skipFirstString; // If we don't need to skip, act as if we already skipped while (startIndex < text.Length && parenDepth > 0) { @@ -246,6 +249,14 @@ public bool TryParseRawStringLiteral(string text, int startIndex, out (int Start // Check for different string literal types if (TryParseStringLiteral(text, startIndex, out var result)) { + if (!firstStringSkipped) + { + // Skip this string literal and continue looking for the next one + firstStringSkipped = true; + startIndex = result.End + 1; + continue; + } + // Determine string type bool isVerbatim = startIndex < text.Length - 1 && text[startIndex] == '@' && text[startIndex + 1] == '"'; @@ -269,6 +280,19 @@ public bool TryParseRawStringLiteral(string text, int startIndex, out (int Start return (spanStart + result.Start, spanStart + result.End, result.Content, isVerbatim, quoteCount); } + // If we're looking to skip the first string but haven't found any string yet, + // check if we've passed the first parameter (comma or end of parameters) + if (!firstStringSkipped && parenDepth == 1) + { + if (text[startIndex] == ',') + { + // Found a comma - we've passed the first parameter without finding a string + firstStringSkipped = true; + startIndex++; + continue; + } + } + // Track parenthesis depth if (text[startIndex] == '(') parenDepth++;