Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Example/ExampleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
117 changes: 117 additions & 0 deletions SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
82 changes: 81 additions & 1 deletion SerilogSyntax/Classification/SerilogClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -850,8 +850,15 @@ public IList<ClassificationSpan> 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 } : [];
}
}
Expand Down Expand Up @@ -1033,6 +1040,79 @@ private bool IsInsideRawStringLiteral(SnapshotSpan span)
return _multiLineStringDetector.IsInsideRawStringLiteral(span);
}

/// <summary>
/// Determines if a LogError call has an exception parameter before the message template.
/// Pattern: LogError(exception, "message template", args...) vs LogError("message template", args...)
/// </summary>
/// <param name="text">The line of text containing the LogError call.</param>
/// <param name="match">The regex match for the LogError method.</param>
/// <param name="searchStart">The position to start searching from.</param>
/// <returns>True if this is LogError with exception parameter, false otherwise.</returns>
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;
}

/// <summary>
/// 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
Expand Down
26 changes: 25 additions & 1 deletion SerilogSyntax/Parsing/StringLiteralParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,17 +222,20 @@ public bool TryParseRawStringLiteral(string text, int startIndex, out (int Start
/// <param name="text">The text to search in.</param>
/// <param name="startIndex">The index to start searching from.</param>
/// <param name="spanStart">The absolute position of the span start in the document.</param>
/// <param name="skipFirstString">If true, skips the first string literal found (used for LogError with exception parameter).</param>
/// <returns>
/// 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.
/// </returns>
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)
{
Expand All @@ -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] == '"';

Expand All @@ -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++;
Expand Down
Loading