From 58d503807128fc7a1b5d101a9c63428fab8c5a45 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 21 Sep 2025 19:20:36 -0700 Subject: [PATCH 1/3] feat: Add property-argument highlighting for Serilog templates Highlights the connection between template properties and their corresponding arguments when cursor is positioned on either. Supports all Serilog logging patterns including multi-line strings, ForContext chaining, LogError with exceptions, and collection expressions. Also improves brace matching for configuration methods like WriteTo.Console. The changes include: - New PropertyArgumentHighlighter and PropertyArgumentHighlighterProvider classes - Test suite covering all edge cases - Enhanced brace matching to support WriteTo.Console(outputTemplate:...) patterns - Support for all string literal types (regular, verbatim, raw with custom delimiters) - Proper handling of LogError exception parameters - Support for ForContext chained calls with collection expressions --- Example/ExampleService.cs | 4 +- .../PropertyArgumentHighlighterTests.cs | 995 ++++++++++++++++++ .../Tagging/SerilogBraceMatcherTests.cs | 50 +- .../SerilogClassificationFormats.cs | 20 + .../SerilogClassificationTypes.cs | 12 + SerilogSyntax/SerilogSyntax.csproj | 2 + .../Tagging/PropertyArgumentHighlighter.cs | 589 +++++++++++ .../PropertyArgumentHighlighterProvider.cs | 31 + SerilogSyntax/Tagging/SerilogBraceMatcher.cs | 20 +- 9 files changed, 1718 insertions(+), 5 deletions(-) create mode 100644 SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs create mode 100644 SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs create mode 100644 SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs diff --git a/Example/ExampleService.cs b/Example/ExampleService.cs index e092790..ef000f0 100644 --- a/Example/ExampleService.cs +++ b/Example/ExampleService.cs @@ -2,7 +2,7 @@ public class ExampleService(ILogger logger) { - private static readonly string[] consoleLoggerScopes = [nameof(RunExamplesAsync), nameof(ConsoleLoggerEmulationExample)]; + private static readonly string[] ConsoleLoggerScopes = [nameof(RunExamplesAsync), nameof(ConsoleLoggerEmulationExample)]; public async Task RunExamplesAsync() { @@ -512,7 +512,7 @@ private async Task ConsoleLoggerEmulationExample() program.Information("Host listening at {ListenUri}", "https://hello-world.local"); program - .ForContext("Scope", consoleLoggerScopes) + .ForContext("Scope", ConsoleLoggerScopes) .Information("HTTP {Method} {Path} responded {StatusCode} in {Elapsed:0.000} ms", "GET", "/api/hello", 200, 1.23); program.Warning("We've reached the end of the line"); diff --git a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs new file mode 100644 index 0000000..4d634e6 --- /dev/null +++ b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs @@ -0,0 +1,995 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using SerilogSyntax.Tagging; +using SerilogSyntax.Tests.TestHelpers; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace SerilogSyntax.Tests.Tagging; + +public class PropertyArgumentHighlighterTests +{ + [Fact] + public void HighlightProperty_WhenCursorOnSimpleProperty_HighlightsPropertyAndArgument() + { + // Arrange + var text = @"logger.LogInformation(""User {UserId} logged in"", userId);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {UserId} + var cursorPosition = text.IndexOf("{UserId}") + 1; // Inside the property + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); // Should highlight property and argument + } + + [Fact] + public void HighlightArgument_WhenCursorOnArgument_HighlightsArgumentAndProperty() + { + // Arrange + var text = @"logger.LogInformation(""User {UserId} logged in"", userId);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on userId argument + var cursorPosition = text.IndexOf(", userId") + 2; // On the argument + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); // Should highlight argument and property + } + + [Fact] + public void HighlightMultipleProperties_SingleLine() + { + // Arrange + var text = @"logger.LogInformation(""User {UserId} ({UserName}) placed {OrderCount} orders totaling {TotalAmount:C}"", userId, userName, orderCount, totalAmount);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Test each property + var properties = new[] { "{UserId}", "{UserName}", "{OrderCount}", "{TotalAmount:C}" }; + var arguments = new[] { "userId", "userName", "orderCount", "totalAmount" }; + + for (int i = 0; i < properties.Length; i++) + { + // Act - cursor on property + var propPosition = text.IndexOf(properties[i]) + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); // Property and its argument + } + } + + [Fact] + public void HighlightMultipleProperties_MultiLine() + { + // Arrange + var text = @"logger.LogInformation(""User {UserId} ({UserName}) placed {OrderCount} orders totaling {TotalAmount:C}"", + userId, userName, orderCount, totalAmount);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Test cursor on second line argument + var cursorPosition = text.IndexOf("userName"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); // Should highlight userName and {UserName} + } + + [Fact] + public void HighlightDestructuredProperty() + { + // Arrange + var text = @"logger.LogInformation(""Processing order {@Order} at {Timestamp:HH:mm:ss}"", order, timestamp);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {@Order} + var cursorPosition = text.IndexOf("{@Order}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightStringifiedProperty() + { + // Arrange + var text = @"logger.LogWarning(""Application version {$AppVersion} using legacy format"", appVersion);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {$AppVersion} + var cursorPosition = text.IndexOf("{$AppVersion}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightPropertyWithFormatSpecifier() + { + // Arrange + var text = @"logger.LogInformation(""Current time: {Timestamp:yyyy-MM-dd HH:mm:ss}"", now);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on formatted property + var cursorPosition = text.IndexOf("{Timestamp:") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightPropertyWithAlignment() + { + // Arrange + var text = @"logger.LogInformation(""Product {Product,-15} | Units: {Units,5}"", productName, units);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on aligned property + var cursorPosition = text.IndexOf("{Product,-15}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightVerbatimStringTemplate() + { + // Arrange + var text = @"logger.LogInformation(@""Processing files in path: {FilePath} +Multiple lines are supported"", filePath);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on property in verbatim string + var cursorPosition = text.IndexOf("{FilePath}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightRawStringLiteral() + { + // Arrange + var text = @"logger.LogInformation("""""" +Raw String Report: +Record: {RecordId} | Status: {Status} +"""""", recordId, status);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on property in raw string + var cursorPosition = text.IndexOf("{RecordId}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightRawStringLiteral_WithMultipleProperties() + { + // Arrange - exactly like the failing scenario + var text = @"var recordId = ""REC-2024""; +var status = ""Processing""; +logger.LogInformation("""""" + Raw String Report: + Record: {RecordId} | Status: {Status,-12} + User: {UserName} (ID: {UserId}) + Order: {@Order} + Timestamp: {Timestamp:yyyy-MM-dd HH:mm:ss} + """""", recordId, status, userName, userId, order, timestamp);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Test cursor on first property {RecordId} + var recordIdPos = text.IndexOf("{RecordId}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, recordIdPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Verify the property span is correct + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == text.IndexOf("{RecordId}")); + Assert.NotNull(propertyTag); + Assert.Equal(10, propertyTag.Span.Length); // Length of "{RecordId}" + + // Verify the argument span is correct + var argTag = tags.FirstOrDefault(t => t.Span.Start == text.IndexOf(", recordId") + 2); + Assert.NotNull(argTag); + } + + [Fact] + public void HighlightLoggerBeginScope() + { + // Arrange + var text = @"using (logger.BeginScope(""Operation={Operation} RequestId={RequestId}"", ""DataExport"", Guid.NewGuid()))"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on first property + var cursorPosition = text.IndexOf("{Operation}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightLogErrorWithException() + { + // Arrange + var text = @"logger.LogError(ex, ""File not found: {FileName} in directory {Directory}"", ex.FileName, Path.GetDirectoryName(ex.FileName));"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on property + var cursorPosition = text.IndexOf("{FileName}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + } + + [Fact] + public void NoHighlight_WhenNotSerilogCall() + { + // Arrange + var text = @"Console.WriteLine(""User {0} logged in"", userId);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act + var cursorPosition = text.IndexOf("{0}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Empty(tags); + } + + [Fact] + public void HighlightRawStringLiteral_VerifyNoOffsetError() + { + // This test captures the exact issue from the screenshot where + // "d: {Record" was highlighted instead of "{RecordId}" + // The bug is a 3-character offset in raw string literals + var text = @"var recordId = ""REC-2024""; +var status = ""Processing""; +logger.LogInformation("""""" + Raw String Report: + Record: {RecordId} | Status: {Status,-12} + """""", recordId, status);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {RecordId} + var cursorPosition = text.IndexOf("{RecordId}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // CRITICAL: The property must be highlighted at exactly the right position + // Not 3 characters before (which would highlight "d: {Record") + var expectedStart = text.IndexOf("{RecordId}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedStart); + + Assert.NotNull(propertyTag); // This will fail if there's an offset + Assert.Equal(10, propertyTag.Span.Length); // Length of "{RecordId}" + + // Also verify the text being highlighted is correct + var highlightedText = text.Substring(propertyTag.Span.Start, propertyTag.Span.Length); + Assert.Equal("{RecordId}", highlightedText); + } + + [Fact] + public void HighlightAnonymousObjectArgument_FullObject() + { + // This test captures the issue where anonymous objects are not fully highlighted + // When cursor is on {@Customer}, it should highlight the entire anonymous object + // including all properties, not just up to the first closing brace + var text = @"expressionLogger.Information(""Order {OrderId} processed successfully for customer {@Customer} in {Duration}ms"", + ""ORD-2024-0042"", new { Name = ""Bob Smith"", Tier = ""Premium"" }, 127);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {@Customer} + var cursorPosition = text.IndexOf("{@Customer}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Find the argument tag (should be the anonymous object) + var anonymousObjectStart = text.IndexOf("new { Name"); + var anonymousObjectEnd = text.IndexOf("Premium\" }") + "Premium\" }".Length; + var expectedLength = anonymousObjectEnd - anonymousObjectStart; + + // The argument tag should cover the entire anonymous object + var argTag = tags.FirstOrDefault(t => t.Span.Start == anonymousObjectStart); + Assert.NotNull(argTag); // This will fail if not found at expected position + + // This assertion will fail if the highlighting stops at the first } + // The expected length should be the full "new { Name = "Bob Smith", Tier = "Premium" }" + // not just "new { Name = "Bob Smith" }" + Assert.Equal(expectedLength, argTag.Span.Length); + + // Also verify we're highlighting the full text + var highlightedText = text.Substring(argTag.Span.Start, argTag.Span.Length); + Assert.Equal(@"new { Name = ""Bob Smith"", Tier = ""Premium"" }", highlightedText); + } + + [Fact] + public void HighlightNestedAnonymousObject() + { + // Test with nested anonymous objects + var text = @"logger.LogInformation(""User {@User} with settings {@Settings}"", + new { Id = 42, Profile = new { Name = ""Alice"", Level = 5 } }, + new { Theme = ""dark"", Features = new[] { ""A"", ""B"" } });"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {@User} + var cursorPosition = text.IndexOf("{@User}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify the first nested object is fully highlighted + var firstObjectStart = text.IndexOf("new { Id = 42"); + var firstObjectEnd = text.IndexOf("Level = 5 } }") + "Level = 5 } }".Length; + var expectedLength = firstObjectEnd - firstObjectStart; + + var argTag = tags.FirstOrDefault(t => t.Span.Start == firstObjectStart); + Assert.NotNull(argTag); + Assert.Equal(expectedLength, argTag.Span.Length); + } + + [Fact] + public void HighlightPositionalParameters_VerbatimString() + { + // This test captures the issue where positional parameters {0}, {1} etc. + // are not highlighted at all, especially in verbatim strings + var text = @"var userId = 42; +logger.LogInformation(@""Database query: +SELECT * FROM Users WHERE Id = {0} AND Status = {1} +Parameters: {0}, {1}"", userId, ""Active"");"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on first {0} + var cursorPosition = text.IndexOf("{0}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight {0} and userId + Assert.Equal(2, tags.Count); + + // Verify {0} is highlighted + var expectedPropStart = text.IndexOf("{0}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + Assert.Equal(3, propertyTag.Span.Length); // Length of "{0}" + + // Verify userId argument is highlighted + var expectedArgStart = text.IndexOf(", userId") + 2; + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(6, argTag.Span.Length); // Length of "userId" + } + + [Fact] + public void HighlightPositionalParameters_MultipleOccurrences() + { + // Test that all occurrences of the same positional parameter are highlighted + var text = @"logger.LogInformation(""User {0} logged in at {1}. Session for {0} started."", userName, DateTime.Now);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on first {0} + var cursorPosition = text.IndexOf("{0}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight both {0} occurrences and userName + // Since we're on {0}, it should highlight the property and its corresponding argument + Assert.Equal(2, tags.Count); + + // Verify the first {0} is highlighted + var firstPropStart = text.IndexOf("{0}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == firstPropStart); + Assert.NotNull(propertyTag); + Assert.Equal(3, propertyTag.Span.Length); + + // Verify userName is highlighted + var argStart = text.IndexOf(", userName") + 2; + var argTag = tags.FirstOrDefault(t => t.Span.Start == argStart); + Assert.NotNull(argTag); + } + + [Fact] + public void HighlightDuplicatePositionalParameters() + { + // Test when the same positional parameter appears multiple times + // Each occurrence should map to a separate argument + var text = @"var userId = 42; +logger.LogInformation(@""Database query: +SELECT * FROM Users WHERE Id = {0} AND Status = {1} +Parameters: {0}, {1}"", userId, ""Active"", userId, ""Active"");"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on the SECOND {0} (in "Parameters: {0}") + var firstZeroPos = text.IndexOf("{0}"); + var secondZeroPos = text.IndexOf("{0}", firstZeroPos + 1); + var cursorPosition = secondZeroPos + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight the second {0} and the THIRD argument (second userId) + Assert.Equal(2, tags.Count); + + // Verify the second {0} is highlighted + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == secondZeroPos); + Assert.NotNull(propertyTag); + Assert.Equal(3, propertyTag.Span.Length); // Length of "{0}" + + // Verify the THIRD argument (second userId) is highlighted + // Arguments are: userId, "Active", userId, "Active" + // The second {0} should map to the third argument (index 2) + // Find ", userId" after ", ""Active"", " + var activeArg = text.IndexOf(@", ""Active"", "); + var thirdArgStart = activeArg + @", ""Active"", ".Length; + + var argTag = tags.FirstOrDefault(t => t.Span.Start == thirdArgStart); + Assert.NotNull(argTag); + Assert.Equal(6, argTag.Span.Length); // Length of "userId" + } + + [Fact] + public void HighlightMixedPositionalAndNamedParameters() + { + // Test mixing positional {0} and named {PropertyName} parameters + // This is actually not recommended in Serilog but should still work + var text = @"logger.LogInformation(""Item {0} has property {Name} with value {1}"", itemId, itemValue, itemName);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Name} (named property) + var cursorPosition = text.IndexOf("{Name}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight {Name} and itemName (third argument) + Assert.Equal(2, tags.Count); + + // Verify {Name} is highlighted + var expectedPropStart = text.IndexOf("{Name}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + + // Verify the correct argument is highlighted + // {0} -> itemId (index 0) + // {1} -> itemValue (index 1) + // {Name} -> itemName (index 2, which is maxPositionalIndex(1) + 1 + 0) + var expectedArgStart = text.IndexOf(", itemName") + 2; + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(8, argTag.Span.Length); // Length of "itemName" + } + + [Fact] + public void HighlightVerbatimStringWithEscapedQuotes() + { + // Test verbatim strings with escaped quotes ("") + // This captures the off-by-1 error where escaped quotes throw off the position calculation + var text = @"var userName = ""Alice""; +logger.LogInformation(@""XML: "", + userName, userId);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {UserName} + var cursorPosition = text.IndexOf("{UserName}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {UserName} is highlighted at the correct position + var expectedPropStart = text.IndexOf("{UserName}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + + // This assertion should fail initially if there's an off-by-1 error + Assert.NotNull(propertyTag); + Assert.Equal(10, propertyTag.Span.Length); // Length of "{UserName}" + + // Also verify the highlighted text is correct (not ""{UserName") + var highlightedText = text.Substring(propertyTag.Span.Start, propertyTag.Span.Length); + Assert.Equal("{UserName}", highlightedText); + + // Verify the argument is highlighted correctly + var expectedArgStart = text.IndexOf("userName, userId") ; // First arg after the template + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(8, argTag.Span.Length); // Length of "userName" + } + + [Fact] + public void HighlightVerbatimStringWithManyEscapedQuotes() + { + // Test with multiple escaped quotes to ensure the offset doesn't accumulate + var text = @"logger.LogInformation(@""JSON: { """"name"""": """"{Name}"""", """"value"""": """"{Value}"""" }"", name, value);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Value} (second property, after many escaped quotes) + var cursorPosition = text.IndexOf("{Value}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {Value} is highlighted at the correct position + var expectedPropStart = text.IndexOf("{Value}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + + Assert.NotNull(propertyTag); + Assert.Equal(7, propertyTag.Span.Length); // Length of "{Value}" + + // Verify correct text is highlighted + var highlightedText = text.Substring(propertyTag.Span.Start, propertyTag.Span.Length); + Assert.Equal("{Value}", highlightedText); + } + + [Fact] + public void HighlightRawStringWithCustomDelimiter_FourQuotes() + { + // Test raw string literals with custom delimiter (4 quotes) + // This allows literal triple quotes inside the string + // Note: Using string concatenation to represent 4+ quotes since .NET Framework doesn't support it + var fourQuotes = "\"\"\"\""; + var text = "var data = \"test-data\";\n" + + "logger.LogInformation(" + fourQuotes + "\n" + + " Template with \"\"\" inside: {Data}\n" + + " This allows literal triple quotes in the string\n" + + " " + fourQuotes + ", data);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Data} + var cursorPosition = text.IndexOf("{Data}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight {Data} and data argument + Assert.Equal(2, tags.Count); + + // Verify {Data} is highlighted + var expectedPropStart = text.IndexOf("{Data}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + Assert.Equal(6, propertyTag.Span.Length); // Length of "{Data}" + + // Verify data argument is highlighted + var expectedArgStart = text.IndexOf(", data") + 2; + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(4, argTag.Span.Length); // Length of "data" + } + + [Fact] + public void HighlightRawStringWithCustomDelimiter_FiveQuotes() + { + // Test raw string literals with 5 quotes delimiter + var fiveQuotes = "\"\"\"\"\""; + var fourQuotes = "\"\"\"\""; + var text = "logger.LogInformation(" + fiveQuotes + "\n" + + " Custom delimiter with {Property}\n" + + " Can contain " + fourQuotes + " (four quotes) inside\n" + + " " + fiveQuotes + ", propertyValue);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Property} + var cursorPosition = text.IndexOf("{Property}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + var expectedPropStart = text.IndexOf("{Property}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + Assert.Equal(10, propertyTag.Span.Length); // Length of "{Property}" + } + + [Fact] + public void HighlightRawStringWithCustomDelimiterAndArgument() + { + // Test clicking on the argument when using custom delimiter + var fourQuotes = "\"\"\"\""; + var text = "var data = \"test-data\";\n" + + "logger.LogInformation(" + fourQuotes + "\n" + + " Template with \"\"\" inside: {Data}\n" + + " " + fourQuotes + ", data);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on data argument + var cursorPosition = text.IndexOf(", data") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - should highlight data and {Data} + Assert.Equal(2, tags.Count); + + // Verify data argument is highlighted + var expectedArgStart = text.IndexOf(", data") + 2; + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + + // Verify {Data} property is highlighted + var expectedPropStart = text.IndexOf("{Data}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + } + + [Fact] + public void NoHighlight_RegularStringWithTripleQuotes() + { + // Ensure we don't accidentally highlight regular strings that happen to contain """ + var text = @"var description = ""This has \""\"" quotes but isn't a raw string""; +logger.LogInformation(""Normal template {Value}"", description);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Value} + var cursorPosition = text.IndexOf("{Value}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should still highlight normally + Assert.Equal(2, tags.Count); + } + + [Fact] + public void HighlightLogErrorWithException_FirstProperty() + { + // Test LogError with exception parameter - first property should map to second argument + var text = @"try +{ + // Some operation +} +catch (FileNotFoundException ex) +{ + logger.LogError(ex, ""File not found: {FileName} in directory {Directory}"", + ex.FileName, Path.GetDirectoryName(ex.FileName)); +}"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {FileName} + var cursorPosition = text.IndexOf("{FileName}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {FileName} is highlighted + var expectedPropStart = text.IndexOf("{FileName}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + + // Verify ex.FileName (not Path.GetDirectoryName) is highlighted + // The first argument after the exception is ex.FileName + var expectedArgStart = text.IndexOf("ex.FileName, Path"); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(11, argTag.Span.Length); // Length of "ex.FileName" + } + + [Fact] + public void HighlightLogErrorWithException_SecondProperty() + { + // Test LogError with exception parameter - second property should map to third argument + var text = @"logger.LogError(ex, ""File not found: {FileName} in directory {Directory}"", + ex.FileName, Path.GetDirectoryName(ex.FileName));"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {Directory} + var cursorPosition = text.IndexOf("{Directory}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {Directory} is highlighted + var expectedPropStart = text.IndexOf("{Directory}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + + // Verify Path.GetDirectoryName(ex.FileName) is highlighted + var expectedArgStart = text.IndexOf("Path.GetDirectoryName"); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + // The argument is the full method call + Assert.True(argTag.Span.Length > 20); // Should be longer than just "Path.GetDirectoryName" + } + + [Fact] + public void HighlightLogErrorWithException_DifferentLogError() + { + // Test second LogError in a different catch block + var text = @"catch (Exception ex) +{ + logger.LogError(ex, ""Unexpected error during operation with file {FileName}"", ""important-file.txt""); +}"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {FileName} + var cursorPosition = text.IndexOf("{FileName}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {FileName} is highlighted + var expectedPropStart = text.IndexOf("{FileName}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + + // Verify "important-file.txt" is highlighted + var expectedArgStart = text.IndexOf(@"""important-file.txt"""); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(20, argTag.Span.Length); // Length of "important-file.txt" with quotes (1 + 18 + 1 = 20) + } + + [Fact] + public void HighlightLogErrorWithException_ClickOnArgument() + { + // Test clicking on the argument in LogError with exception + var text = @"logger.LogError(ex, ""File not found: {FileName}"", ex.FileName);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on ex.FileName argument + var cursorPosition = text.IndexOf("ex.FileName"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify ex.FileName is highlighted + var expectedArgStart = text.IndexOf("ex.FileName"); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + + // Verify {FileName} is highlighted + var expectedPropStart = text.IndexOf("{FileName}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + } + + [Fact] + public void HighlightForContextWithCollectionExpression() + { + // Test ForContext().Information with collection expression argument + var text = @"log.ForContext() + .Information(""Cart contains {@Items}"", [""Tea"", ""Coffee""]);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on {@Items} + var cursorPosition = text.IndexOf("{@Items}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {@Items} is highlighted + var expectedPropStart = text.IndexOf("{@Items}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + Assert.Equal(8, propertyTag.Span.Length); // Length of "{@Items}" + + // Verify ["Tea", "Coffee"] is highlighted + var expectedArgStart = text.IndexOf(@"[""Tea"", ""Coffee""]"); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(17, argTag.Span.Length); // Length of ["Tea", "Coffee"] + } + + [Fact] + public void HighlightForContextWithCollectionExpression_ClickOnArgument() + { + // Test clicking on the collection expression argument + var text = @"log.ForContext() + .Information(""Cart contains {@Items}"", [""Apricots""]);"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor inside ["Apricots"] + var cursorPosition = text.IndexOf(@"[""Apricots""]") + 5; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); + + // Verify {@Items} is highlighted + var expectedPropStart = text.IndexOf("{@Items}"); + var propertyTag = tags.FirstOrDefault(t => t.Span.Start == expectedPropStart); + Assert.NotNull(propertyTag); + + // Verify ["Apricots"] is highlighted + var expectedArgStart = text.IndexOf(@"[""Apricots""]"); + var argTag = tags.FirstOrDefault(t => t.Span.Start == expectedArgStart); + Assert.NotNull(argTag); + Assert.Equal(12, argTag.Span.Length); // Length of ["Apricots"] + } + + [Fact] + public void HighlightComplexNestedArguments() + { + // Arrange + var text = @"logger.LogInformation(""Processing {Count} items for user {UserId}"", items.Count(), GetUserId());"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - cursor on first argument + var cursorPosition = text.IndexOf("items.Count()"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert + Assert.Equal(2, tags.Count); // Should highlight items.Count() and {Count} + } +} \ No newline at end of file diff --git a/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs b/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs index 1d8ca4f..9ca267a 100644 --- a/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs +++ b/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs @@ -29,6 +29,54 @@ public void BraceMatching_SimpleProperty_MatchesCorrectly() Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } } + [Fact] + public void BraceMatching_WriteTo_Console_OutputTemplate() + { + // Test brace matching in WriteTo.Console outputTemplate parameter + var text = @".WriteTo.Console(outputTemplate: + ""[{Timestamp:HH:mm:ss} {Level:u3} ({SourceContext})] {Message:lj} (first item is {FirstItem}){NewLine}{Exception}"")"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var matcher = new SerilogBraceMatcher(view, buffer); + + // Position on opening brace of {Timestamp:HH:mm:ss} + var timestampOpenPos = text.IndexOf("{Timestamp"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, timestampOpenPos)); + + var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight the opening and closing brace of {Timestamp:HH:mm:ss} + Assert.Equal(2, tags.Count); + var timestampClosePos = text.IndexOf("}", timestampOpenPos); + Assert.Contains(tags, t => t.Span.Start.Position == timestampOpenPos); + Assert.Contains(tags, t => t.Span.Start.Position == timestampClosePos); + } + + [Fact] + public void BraceMatching_WriteTo_Console_OutputTemplate_ClosingBrace() + { + // Test brace matching when cursor is on closing brace + var text = @".WriteTo.Console(outputTemplate: + ""[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}"")"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var matcher = new SerilogBraceMatcher(view, buffer); + + // Position on closing brace of {Level:u3} + var levelClosePos = text.IndexOf("{Level:u3}") + "{Level:u3}".Length - 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, levelClosePos)); + + var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight the opening and closing brace of {Level:u3} + Assert.Equal(2, tags.Count); + var levelOpenPos = text.IndexOf("{Level"); + Assert.Contains(tags, t => t.Span.Start.Position == levelOpenPos); + Assert.Contains(tags, t => t.Span.Start.Position == levelClosePos); + } + [Fact] public void BraceMatching_MultipleProperties_OnlyHighlightsCurrentPair() { @@ -40,7 +88,7 @@ public void BraceMatching_MultipleProperties_OnlyHighlightsCurrentPair() // Position on first property's opening brace var firstOpenPos = text.IndexOf("{Name"); view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, firstOpenPos)); - + var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); diff --git a/SerilogSyntax/Classification/SerilogClassificationFormats.cs b/SerilogSyntax/Classification/SerilogClassificationFormats.cs index 8f91623..aa8d6c4 100644 --- a/SerilogSyntax/Classification/SerilogClassificationFormats.cs +++ b/SerilogSyntax/Classification/SerilogClassificationFormats.cs @@ -123,6 +123,26 @@ public SerilogBraceHighlightFormat() } } +/// +/// Defines the visual format for property-argument connection highlights in Serilog templates. +/// +[Export(typeof(EditorFormatDefinition))] +[Name("serilog.property.argument.highlight")] +[UserVisible(true)] +internal sealed class SerilogPropertyArgumentHighlightFormat : MarkerFormatDefinition +{ + public SerilogPropertyArgumentHighlightFormat() + { + DisplayName = "Serilog Property-Argument Highlight"; + // Use a subtle background fill to highlight property-argument connections + // Colors chosen to work well with both light and dark themes + BackgroundColor = Color.FromArgb(40, 0x00, 0x7A, 0xCC); // Semi-transparent blue + BackgroundCustomizable = true; + ForegroundCustomizable = false; + ZOrder = 4; // Lower than brace highlighting to avoid conflicts + } +} + // Expression syntax format definitions /// diff --git a/SerilogSyntax/Classification/SerilogClassificationTypes.cs b/SerilogSyntax/Classification/SerilogClassificationTypes.cs index 845bd63..ff2e75b 100644 --- a/SerilogSyntax/Classification/SerilogClassificationTypes.cs +++ b/SerilogSyntax/Classification/SerilogClassificationTypes.cs @@ -80,6 +80,11 @@ internal static class SerilogClassificationTypes /// public const string ExpressionBuiltin = "serilog.expression.builtin"; + /// + /// Classification type name for property-argument highlights. + /// + public const string PropertyArgumentHighlight = "serilog.property.argument.highlight"; + /// /// Classification type definition for property names. /// @@ -178,4 +183,11 @@ internal static class SerilogClassificationTypes [Export(typeof(ClassificationTypeDefinition))] [Name(ExpressionBuiltin)] internal static ClassificationTypeDefinition ExpressionBuiltinType = null; + + /// + /// Classification type definition for property-argument highlights. + /// + [Export(typeof(ClassificationTypeDefinition))] + [Name(PropertyArgumentHighlight)] + internal static ClassificationTypeDefinition PropertyArgumentHighlightType = null; } \ No newline at end of file diff --git a/SerilogSyntax/SerilogSyntax.csproj b/SerilogSyntax/SerilogSyntax.csproj index a14ff6c..75660be 100644 --- a/SerilogSyntax/SerilogSyntax.csproj +++ b/SerilogSyntax/SerilogSyntax.csproj @@ -72,6 +72,8 @@ + + diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs new file mode 100644 index 0000000..de58b20 --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs @@ -0,0 +1,589 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using SerilogSyntax.Parsing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace SerilogSyntax.Tagging; + +/// +/// Provides property-argument highlighting for Serilog templates. +/// +internal sealed class PropertyArgumentHighlighter : ITagger, IDisposable +{ + private readonly ITextView _view; + private readonly ITextBuffer _buffer; + private readonly TemplateParser _parser = new(); + private SnapshotPoint? _currentChar; + private readonly List> _currentTags = []; + private bool _disposed; + + public event EventHandler TagsChanged; + + public PropertyArgumentHighlighter(ITextView view, ITextBuffer buffer) + { + _view = view ?? throw new ArgumentNullException(nameof(view)); + _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + + _currentChar = view.Caret.Position.Point.GetPoint(buffer, view.Caret.Position.Affinity); + + _view.Caret.PositionChanged += CaretPositionChanged; + _view.LayoutChanged += ViewLayoutChanged; + } + + private void CaretPositionChanged(object sender, CaretPositionChangedEventArgs e) + => UpdateAtCaretPosition(e.NewPosition); + + private void ViewLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + if (e.NewSnapshot != e.OldSnapshot) + UpdateAtCaretPosition(_view.Caret.Position); + } + + private void UpdateAtCaretPosition(CaretPosition caretPosition) + { + _currentChar = caretPosition.Point.GetPoint(_buffer, caretPosition.Affinity); + if (_currentChar.HasValue) + { + var newTags = GetHighlightTags(_currentChar.Value).ToList(); + + // Only raise event if tags actually changed + if (!TagsEqual(newTags, _currentTags)) + { + _currentTags.Clear(); + _currentTags.AddRange(newTags); + + var snapshot = _currentChar.Value.Snapshot; + var fullSpan = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(fullSpan)); + } + } + } + + private bool TagsEqual(List> tags1, List> tags2) + { + if (tags1.Count != tags2.Count) return false; + for (int i = 0; i < tags1.Count; i++) + { + if (tags1[i].Span != tags2[i].Span) return false; + } + return true; + } + + private IEnumerable> GetHighlightTags(SnapshotPoint caretPoint) + { + // Find the complete Serilog call, which may span multiple lines + var callInfo = FindSerilogCall(caretPoint); + if (callInfo == null) + yield break; + + // Parse the template to get all properties + var allProperties = _parser.Parse(callInfo.Template).ToList(); + if (!allProperties.Any()) + yield break; + + // Separate positional and named properties + var positionalProperties = allProperties.Where(p => p.Type == PropertyType.Positional).ToList(); + var namedProperties = allProperties.Where(p => p.Type != PropertyType.Positional).ToList(); + + // Check if cursor is on a property or argument + var caretPosition = caretPoint.Position; + + // Check if cursor is on a positional property + // For duplicate positional parameters, we need to find which occurrence the cursor is on + for (int propIndex = 0; propIndex < allProperties.Count; propIndex++) + { + var property = allProperties[propIndex]; + if (property.Type != PropertyType.Positional) continue; + + // Adjust property positions if we have a verbatim string with escaped quotes + var propStart = callInfo.TemplateStart + property.BraceStartIndex; + var propEnd = callInfo.TemplateStart + property.BraceEndIndex + 1; + + // For verbatim strings, we need to adjust positions to account for escaped quotes + if (callInfo.IsVerbatimString && callInfo.OriginalTemplate != null) + { + // Map position from cleaned template to original template with escaped quotes + propStart = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceStartIndex); + propEnd = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceEndIndex + 1); + } + + if (caretPosition >= propStart && caretPosition <= propEnd) + { + // Highlight the positional property + var propertySpan = new SnapshotSpan(caretPoint.Snapshot, propStart, propEnd - propStart); + yield return new TagSpan(propertySpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + + // For positional parameters, count how many positional properties come before this one + // Each positional property consumes one argument in order + var positionalsBefore = allProperties.Take(propIndex).Count(p => p.Type == PropertyType.Positional); + + // The argument index is the count of positional properties before this one + if (positionalsBefore < callInfo.Arguments.Count) + { + var arg = callInfo.Arguments[positionalsBefore]; + var argSpan = new SnapshotSpan(caretPoint.Snapshot, arg.Start, arg.Length); + yield return new TagSpan(argSpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + } + yield break; + } + } + + // Check if cursor is on a named property + for (int i = 0; i < namedProperties.Count; i++) + { + var property = namedProperties[i]; + var propStart = callInfo.TemplateStart + property.BraceStartIndex; + var propEnd = callInfo.TemplateStart + property.BraceEndIndex + 1; + + // For verbatim strings, adjust positions to account for escaped quotes + if (callInfo.IsVerbatimString && callInfo.OriginalTemplate != null) + { + propStart = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceStartIndex); + propEnd = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceEndIndex + 1); + } + + if (caretPosition >= propStart && caretPosition <= propEnd) + { + // Highlight the property + var propertySpan = new SnapshotSpan(caretPoint.Snapshot, propStart, propEnd - propStart); + yield return new TagSpan(propertySpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + + // For named properties, find the argument index + // Named properties map to arguments after the highest positional index + var maxPositionalIndex = positionalProperties + .Select(p => int.TryParse(p.Name, out var idx) ? idx : -1) + .DefaultIfEmpty(-1) + .Max(); + var argIndex = maxPositionalIndex + 1 + i; + if (argIndex < callInfo.Arguments.Count) + { + var arg = callInfo.Arguments[argIndex]; + var argSpan = new SnapshotSpan(caretPoint.Snapshot, arg.Start, arg.Length); + yield return new TagSpan(argSpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + } + yield break; + } + } + + // Check if cursor is on an argument + for (int i = 0; i < callInfo.Arguments.Count; i++) + { + var arg = callInfo.Arguments[i]; + if (caretPosition >= arg.Start && caretPosition <= arg.Start + arg.Length) + { + // Highlight the argument + var argSpan = new SnapshotSpan(caretPoint.Snapshot, arg.Start, arg.Length); + yield return new TagSpan(argSpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + + // Check if this argument corresponds to a positional property + // Count how many positional properties we've seen so far + var positionalsSeen = 0; + var foundPositional = false; + foreach (var property in allProperties) + { + if (property.Type == PropertyType.Positional) + { + if (positionalsSeen == i) + { + // This is the property that corresponds to this argument + var propStart = callInfo.TemplateStart + property.BraceStartIndex; + var propEnd = callInfo.TemplateStart + property.BraceEndIndex + 1; + + // Adjust for verbatim strings + if (callInfo.IsVerbatimString && callInfo.OriginalTemplate != null) + { + propStart = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceStartIndex); + propEnd = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceEndIndex + 1); + } + + var propertySpan = new SnapshotSpan(caretPoint.Snapshot, propStart, propEnd - propStart); + yield return new TagSpan(propertySpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + foundPositional = true; + break; + } + positionalsSeen++; + } + } + + // Otherwise check if it corresponds to a named property + if (!foundPositional && namedProperties.Any()) + { + // Calculate which named property this argument maps to + var maxPositionalIndex = positionalProperties + .Select(p => int.TryParse(p.Name, out var idx) ? idx : -1) + .DefaultIfEmpty(-1) + .Max(); + var namedPropIndex = i - (maxPositionalIndex + 1); + if (namedPropIndex >= 0 && namedPropIndex < namedProperties.Count) + { + var property = namedProperties[namedPropIndex]; + var propStart = callInfo.TemplateStart + property.BraceStartIndex; + var propEnd = callInfo.TemplateStart + property.BraceEndIndex + 1; + + // Adjust for verbatim strings + if (callInfo.IsVerbatimString && callInfo.OriginalTemplate != null) + { + propStart = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceStartIndex); + propEnd = callInfo.TemplateStart + MapCleanedPositionToOriginal(callInfo.OriginalTemplate, property.BraceEndIndex + 1); + } + + var propertySpan = new SnapshotSpan(caretPoint.Snapshot, propStart, propEnd - propStart); + yield return new TagSpan(propertySpan, new TextMarkerTag("MarkerFormatDefinition/HighlightedReference")); + } + } + yield break; + } + } + } + + private SerilogCallInfo FindSerilogCall(SnapshotPoint caretPoint) + { + var snapshot = caretPoint.Snapshot; + var text = snapshot.GetText(); + + // Use regex to find all Serilog calls + // This matches both direct calls (logger.Information) and chained calls (.Information after ForContext) + var pattern = @"(?:(?:_?logger|Log|log)\.(?:LogVerbose|LogDebug|LogInformation|LogWarning|LogError|LogCritical|LogFatal|Verbose|Debug|Information|Warning|Error|Critical|Fatal|Write|BeginScope)|\.(?:LogVerbose|LogDebug|LogInformation|LogWarning|LogError|LogCritical|LogFatal|Verbose|Debug|Information|Warning|Error|Critical|Fatal|Write))\s*\("; + var methodMatches = Regex.Matches(text, pattern, RegexOptions.IgnoreCase); + + foreach (Match methodMatch in methodMatches) + { + var methodStart = methodMatch.Index; + var methodEnd = methodStart + methodMatch.Length; + + // Find the closing parenthesis for this method call + var parenDepth = 1; + var pos = methodEnd; + var callEnd = -1; + + while (pos < text.Length && parenDepth > 0) + { + if (text[pos] == '(') parenDepth++; + else if (text[pos] == ')') + { + parenDepth--; + if (parenDepth == 0) + { + callEnd = pos; + break; + } + } + pos++; + } + + if (callEnd < 0) continue; + + // Check if caret is within this call + if (caretPoint.Position < methodStart || caretPoint.Position > callEnd) + continue; + + // Extract the call content + var callContent = text.Substring(methodEnd, callEnd - methodEnd); + + // Handle LogError special case (first parameter might be exception) + var hasException = methodMatch.Value.Contains("LogError") && HasExceptionFirstParam(callContent); + string adjustedCallContent = callContent; + + if (hasException) + { + // Skip the exception parameter to find the template + // Find the first comma after the exception parameter + var firstCommaIndex = callContent.IndexOf(','); + if (firstCommaIndex > 0) + { + // Create adjusted content that starts after the exception parameter + adjustedCallContent = callContent.Substring(firstCommaIndex + 1); + } + } + + // Find template string (handles various formats) + var templateInfo = ExtractTemplate(adjustedCallContent); + if (templateInfo == null) continue; + + // If we had an exception, adjust template positions + if (hasException) + { + var firstCommaIndex = callContent.IndexOf(','); + if (firstCommaIndex > 0) + { + // Adjust positions back to original callContent coordinates + templateInfo.RelativeStart += firstCommaIndex + 1; + templateInfo.RelativeEnd += firstCommaIndex + 1; + } + } + + // Find arguments after the template (use original callContent for correct positions) + var arguments = ExtractArguments(callContent, templateInfo.RelativeEnd, methodEnd); + + return new SerilogCallInfo + { + Template = templateInfo.Template, + TemplateStart = methodEnd + templateInfo.RelativeStart, + Arguments = arguments, + OriginalTemplate = templateInfo.OriginalTemplate, + IsVerbatimString = templateInfo.IsVerbatimString + }; + } + + return null; + } + + private bool HasExceptionFirstParam(string callContent) + { + // For LogError, the first parameter might be an exception + // callContent is already the content between parentheses (doesn't include the parens) + // We need to check if the first parameter is not a string literal + + var trimmedContent = callContent.TrimStart(); + + // Check if it starts with a string literal (template) + if (trimmedContent.StartsWith("\"") || + trimmedContent.StartsWith("@\"") || + trimmedContent.StartsWith("$\"") || + Regex.IsMatch(trimmedContent, @"^@?(""{3,})")) // Raw string literals + { + // First parameter is a string template, no exception + return false; + } + + // If the first parameter is not a string, it's likely an exception + return true; + } + + private TemplateStringInfo ExtractTemplate(string callContent) + { + // Handle different string literal formats + int start = -1; + int end = -1; + string template = null; + + // Try raw string literal (""" or more quotes for custom delimiters) + // Match 3 or more quotes at the start and end + var rawMatch = Regex.Match(callContent, @"@?(""{3,})([\s\S]*?)\1"); + if (rawMatch.Success) + { + // For raw string literals, the template content starts after the opening quotes + var quoteDelimiter = rawMatch.Groups[1].Value; + var quoteCount = quoteDelimiter.Length; + start = rawMatch.Index + quoteCount; + template = rawMatch.Groups[2].Value; + end = rawMatch.Index + rawMatch.Length; + } + else + { + // Try verbatim string (@") + var verbatimMatch = Regex.Match(callContent, @"@""([^""]*(?:""""[^""]*)*)"""); + if (verbatimMatch.Success) + { + start = verbatimMatch.Index + 2; // Skip @" + template = verbatimMatch.Groups[1].Value.Replace("\"\"", "\""); + end = verbatimMatch.Index + verbatimMatch.Length; + + // For verbatim strings, store the original for position adjustment + return new TemplateStringInfo + { + Template = template, + RelativeStart = start, + RelativeEnd = end, + OriginalTemplate = verbatimMatch.Groups[1].Value, + IsVerbatimString = true + }; + } + else + { + // Try regular string + var regularMatch = Regex.Match(callContent, @"""([^""\\]*(?:\\.[^""\\]*)*)"""); + if (regularMatch.Success) + { + start = regularMatch.Index + 1; // Skip opening quote + template = Regex.Unescape(regularMatch.Groups[1].Value); + end = regularMatch.Index + regularMatch.Length; + } + } + } + + if (template != null) + { + return new TemplateStringInfo + { + Template = template, + RelativeStart = start, + RelativeEnd = end + }; + } + + return null; + } + + private List ExtractArguments(string callContent, int templateEnd, int absoluteOffset) + { + var arguments = new List(); + var pos = templateEnd; + + // Skip to first comma after template + while (pos < callContent.Length && callContent[pos] != ',') + pos++; + + while (pos < callContent.Length) + { + if (callContent[pos] == ',') + { + pos++; // Skip comma + + // Skip whitespace + while (pos < callContent.Length && char.IsWhiteSpace(callContent[pos])) + pos++; + + if (pos >= callContent.Length) break; + + // Find argument boundaries + var argStart = pos; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; // Track braces for anonymous objects + var inString = false; + var stringChar = '\0'; + + while (pos < callContent.Length) + { + var ch = callContent[pos]; + + // Handle string literals + if (!inString && (ch == '"' || ch == '\'')) + { + inString = true; + stringChar = ch; + } + else if (inString && ch == stringChar && (pos == 0 || callContent[pos - 1] != '\\')) + { + inString = false; + } + else if (!inString) + { + if (ch == '(') parenDepth++; + else if (ch == ')') + { + if (parenDepth == 0) break; // End of call + parenDepth--; + } + else if (ch == '[') bracketDepth++; + else if (ch == ']') bracketDepth--; + else if (ch == '{') braceDepth++; // Track opening braces + else if (ch == '}') braceDepth--; // Track closing braces + else if (ch == ',' && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + break; // Next argument only when all depths are 0 + } + + pos++; + } + + // Get the full argument text and find the trimmed bounds + var fullArgText = callContent.Substring(argStart, pos - argStart); + var trimmedArgText = fullArgText.Trim(); + if (!string.IsNullOrEmpty(trimmedArgText)) + { + // Find where the trimmed text starts in the full text + var trimStart = fullArgText.IndexOf(trimmedArgText); + arguments.Add(new ArgumentInfo + { + Start = absoluteOffset + argStart + trimStart, + Length = trimmedArgText.Length + }); + } + } + else + { + pos++; + } + } + + return arguments; + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + if (_disposed || spans.Count == 0) + return []; + + return _currentTags.Where(tag => spans.Any(span => span.IntersectsWith(tag.Span))); + } + + private int MapCleanedPositionToOriginal(string originalTemplate, int cleanedPosition) + { + // Map a position from the cleaned template (where "" is replaced with ") + // back to the original template with escaped quotes + var originalPos = 0; + var cleanedPos = 0; + + while (originalPos < originalTemplate.Length && cleanedPos < cleanedPosition) + { + if (originalPos < originalTemplate.Length - 1 && + originalTemplate[originalPos] == '"' && + originalTemplate[originalPos + 1] == '"') + { + // Found an escaped quote in original + originalPos += 2; // Skip both quotes in original + cleanedPos++; // Only one quote in cleaned version + } + else + { + // Regular character + originalPos++; + cleanedPos++; + } + } + + return originalPos; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_view != null) + { + _view.Caret.PositionChanged -= CaretPositionChanged; + _view.LayoutChanged -= ViewLayoutChanged; + } + + _currentTags.Clear(); + } + + private class SerilogCallInfo + { + public string Template { get; set; } + + public int TemplateStart { get; set; } + + public List Arguments { get; set; } = []; + + public string OriginalTemplate { get; set; } + + public bool IsVerbatimString { get; set; } + } + + private class TemplateStringInfo + { + public string Template { get; set; } + + public int RelativeStart { get; set; } + + public int RelativeEnd { get; set; } + + public string OriginalTemplate { get; set; } + + public bool IsVerbatimString { get; set; } + } + + private class ArgumentInfo + { + public int Start { get; set; } + + public int Length { get; set; } + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs new file mode 100644 index 0000000..3b8fee1 --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using System.ComponentModel.Composition; + +namespace SerilogSyntax.Tagging; + +/// +/// Provides property-argument highlighting taggers for Serilog template properties. +/// +[Export(typeof(IViewTaggerProvider))] +[ContentType("CSharp")] +[TagType(typeof(TextMarkerTag))] +internal sealed class PropertyArgumentHighlighterProvider : IViewTaggerProvider +{ + /// + /// Creates a tagger for property-argument highlighting in Serilog templates. + /// + /// The type of tag. + /// The text view. + /// The text buffer. + /// A property-argument highlighting tagger, or null if the parameters are invalid. + public ITagger CreateTagger(ITextView textView, ITextBuffer buffer) where T : ITag + { + if (textView == null || buffer == null || buffer != textView.TextBuffer) + return null; + + return new PropertyArgumentHighlighter(textView, buffer) as ITagger; + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs b/SerilogSyntax/Tagging/SerilogBraceMatcher.cs index 0cfd1dd..4a6783a 100644 --- a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs +++ b/SerilogSyntax/Tagging/SerilogBraceMatcher.cs @@ -416,8 +416,24 @@ public IEnumerable> GetTags(NormalizedSnapshotSpanCollec // Check if we're in an expression template context bool inExpressionTemplate = IsInsideExpressionTemplate(currentChar); - // For single-line strings, use existing logic - if (!inMultiLineString && !inExpressionTemplate && !IsSerilogCall(lineText)) + // For single-line strings, check if we're in a Serilog context + // This includes checking the previous line for configuration methods like outputTemplate + bool inSerilogContext = IsSerilogCall(lineText); + + // If not found on current line, check previous line for configuration patterns + if (!inSerilogContext && currentLine.LineNumber > 0) + { + var prevLine = snapshot.GetLineFromLineNumber(currentLine.LineNumber - 1); + var prevText = prevLine.GetText(); + // Check if previous line has outputTemplate or other configuration methods + if (prevText.Contains("outputTemplate") || prevText.Contains("WriteTo.") || + prevText.Contains("Enrich.") || prevText.Contains("Filter.")) + { + inSerilogContext = true; + } + } + + if (!inMultiLineString && !inExpressionTemplate && !inSerilogContext) yield break; var charAtCaret = currentChar.GetChar(); From ff3795ff19ca5884e0ad037799a676277c1e4470 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 21 Sep 2025 19:31:01 -0700 Subject: [PATCH 2/3] docs: Update documentation for property-argument highlighting feature --- CLAUDE.md | 14 ++++++++------ Example/README.md | 1 + README.md | 8 ++++++++ .../Tagging/PropertyArgumentHighlighterTests.cs | 1 - 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8a715e..bace261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,7 @@ This extension provides syntax highlighting and navigation for Serilog message t - **Syntax highlighting** for Serilog.Expressions filter syntax and expression templates - **Navigation** support (Go to Definition) between template properties and arguments - **Brace matching** for template property delimiters and expression templates +- **Property-argument highlighting** shows connections between properties and arguments on cursor position ### Technical Stack - **Roslyn Classification API** - For syntax highlighting via `IClassifier` @@ -194,14 +195,15 @@ The extension includes these components: 3. **Classification/SerilogClassifierProvider.cs** - MEF export for classifier 4. **Navigation/SerilogNavigationProvider.cs** - Navigation from properties to arguments via light bulb 5. **Tagging/SerilogBraceMatcher.cs** - Implements `ITagger` for brace matching -6. **Utilities/SerilogCallDetector.cs** - Centralized Serilog call detection logic -7. **Utilities/LruCache.cs** - Thread-safe LRU cache for parsed templates +6. **Tagging/PropertyArgumentHighlighter.cs** - Implements `ITagger` for property-argument highlighting +7. **Utilities/SerilogCallDetector.cs** - Centralized Serilog call detection logic +8. **Utilities/LruCache.cs** - Thread-safe LRU cache for parsed templates #### Serilog.Expressions Support -8. **Expressions/ExpressionTokenizer.cs** - Tokenizes Serilog.Expressions syntax -9. **Expressions/ExpressionParser.cs** - Parses expressions and templates into classified regions -10. **Expressions/ExpressionDetector.cs** - Detects expression contexts (filter, template, etc.) -11. **Classification/SyntaxTreeAnalyzer.cs** - Roslyn-based analysis for expression contexts +9. **Expressions/ExpressionTokenizer.cs** - Tokenizes Serilog.Expressions syntax +10. **Expressions/ExpressionParser.cs** - Parses expressions and templates into classified regions +11. **Expressions/ExpressionDetector.cs** - Detects expression contexts (filter, template, etc.) +12. **Classification/SyntaxTreeAnalyzer.cs** - Roslyn-based analysis for expression contexts ### Performance Considerations - LRU cache for parsed templates (10x improvement for repeated templates) diff --git a/Example/README.md b/Example/README.md index a2912b9..93cdada 100644 --- a/Example/README.md +++ b/Example/README.md @@ -91,6 +91,7 @@ Open `Program.cs` in Visual Studio with the Serilog Syntax extension installed t ### Interactive Features - **Brace matching** when cursor is on `{` or `}` - **Light bulb navigation** from properties to arguments +- **Property-argument highlighting** when cursor is on either a property or argument - **Immediate highlighting** as you type (before closing quotes) - **Multi-line support** for verbatim and raw strings diff --git a/README.md b/README.md index 1f44f91..964bfd4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ A Visual Studio 2022 extension that provides syntax highlighting, brace matching - **Navigate to argument** - jump from template properties to their corresponding arguments - Click the light bulb and select "Navigate to 'PropertyName' argument" +### 🔦 Property-Argument Highlighting +- **Interactive highlighting** of property-argument connections +- When cursor is on a template property (e.g., `{UserId}`), both the property and its corresponding argument are highlighted +- When cursor is on an argument, both the argument and its template property are highlighted +- Works across all string literal types (regular, verbatim `@"..."`, raw `"""..."""`) +- Supports complex scenarios including multi-line templates, collection expressions, and LogError with exception parameters + ### 🔍 Brace Matching - Highlight matching braces when cursor is positioned on `{` or `}` - Visual indication of brace pairs in complex templates @@ -246,6 +253,7 @@ Key components: - `SerilogClassifier` - Handles syntax highlighting with smart cache invalidation - `SerilogBraceMatcher` - Provides brace matching - `SerilogNavigationProvider` - Enables property-to-argument navigation +- `PropertyArgumentHighlighter` - Highlights property-argument connections on cursor position - `SerilogCallDetector` - Optimized Serilog call detection with pre-check optimization - `SerilogThemeColors` - Theme-aware color management with WCAG AA compliance - `TemplateParser` - Parses Serilog message templates diff --git a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs index 4d634e6..f32cab6 100644 --- a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs +++ b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs @@ -62,7 +62,6 @@ public void HighlightMultipleProperties_SingleLine() // Test each property var properties = new[] { "{UserId}", "{UserName}", "{OrderCount}", "{TotalAmount:C}" }; - var arguments = new[] { "userId", "userName", "orderCount", "totalAmount" }; for (int i = 0; i < properties.Length; i++) { From 65fbe7ca9d0130a5299c293b8b650bbd1bafe58b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 21 Sep 2025 20:05:26 -0700 Subject: [PATCH 3/3] fix: Exclude ExpressionTemplate contexts from property-argument highlighting Property-argument highlighting no longer triggers on ExpressionTemplate built-in properties like {@t}, {@l}, {@m}, {@x} since these don't have corresponding arguments to highlight. Adds test to verify the fix. Also refactored to use SerilogCallDetector and StringLiteralParser utilities to eliminate duplicate regex patterns --- .../PropertyArgumentHighlighterTests.cs | 39 ++++++ .../Tagging/PropertyArgumentHighlighter.cs | 121 +++++++++++------- SerilogSyntax/Tagging/SerilogBraceMatcher.cs | 10 +- 3 files changed, 116 insertions(+), 54 deletions(-) diff --git a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs index f32cab6..650fc2e 100644 --- a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs +++ b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs @@ -991,4 +991,43 @@ public void HighlightComplexNestedArguments() // Assert Assert.Equal(2, tags.Count); // Should highlight items.Count() and {Count} } + + [Fact] + public void ExpressionTemplate_BuiltInProperties_ShouldNotBeHighlighted() + { + // Arrange - ExpressionTemplate with built-in properties that don't have arguments + var text = @".WriteTo.Console(new ExpressionTemplate( + ""[{@t:HH:mm:ss} {@l:u3}] {#if SourceContext is not null}[{Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1)}]{#end} {@m}\n{@x}""))"; + var buffer = MockTextBuffer.Create(text); + var view = new MockTextView(buffer); + var highlighter = new PropertyArgumentHighlighter(view, buffer); + + // Act - position cursor on the opening brace of {@t:HH:mm:ss} + var cursorPosition = text.IndexOf("{@t:"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Assert - No tags should be created since {@t:HH:mm:ss} is not a property with an argument + Assert.Empty(tags); + + // Also test cursor on {@l:u3} + cursorPosition = text.IndexOf("{@l:"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Empty(tags); + + // Also test cursor on {@m} + cursorPosition = text.IndexOf("{@m}"); + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, cursorPosition)); + + tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Empty(tags); + } } \ No newline at end of file diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs index de58b20..d05f5ad 100644 --- a/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs @@ -2,6 +2,7 @@ using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Tagging; using SerilogSyntax.Parsing; +using SerilogSyntax.Utilities; using System; using System.Collections.Generic; using System.Linq; @@ -14,9 +15,13 @@ namespace SerilogSyntax.Tagging; /// internal sealed class PropertyArgumentHighlighter : ITagger, IDisposable { + // Note: We use SerilogCallDetector for finding Serilog calls and StringLiteralParser for string extraction + // to avoid duplicating regex patterns and parsing logic + private readonly ITextView _view; private readonly ITextBuffer _buffer; private readonly TemplateParser _parser = new(); + private readonly StringLiteralParser _stringParser = new(); private SnapshotPoint? _currentChar; private readonly List> _currentTags = []; private bool _disposed; @@ -245,10 +250,8 @@ private SerilogCallInfo FindSerilogCall(SnapshotPoint caretPoint) var snapshot = caretPoint.Snapshot; var text = snapshot.GetText(); - // Use regex to find all Serilog calls - // This matches both direct calls (logger.Information) and chained calls (.Information after ForContext) - var pattern = @"(?:(?:_?logger|Log|log)\.(?:LogVerbose|LogDebug|LogInformation|LogWarning|LogError|LogCritical|LogFatal|Verbose|Debug|Information|Warning|Error|Critical|Fatal|Write|BeginScope)|\.(?:LogVerbose|LogDebug|LogInformation|LogWarning|LogError|LogCritical|LogFatal|Verbose|Debug|Information|Warning|Error|Critical|Fatal|Write))\s*\("; - var methodMatches = Regex.Matches(text, pattern, RegexOptions.IgnoreCase); + // Use SerilogCallDetector to find all Serilog calls to avoid duplicating regex patterns + var methodMatches = SerilogCallDetector.FindAllSerilogCalls(text); foreach (Match methodMatch in methodMatches) { @@ -284,6 +287,10 @@ private SerilogCallInfo FindSerilogCall(SnapshotPoint caretPoint) // Extract the call content var callContent = text.Substring(methodEnd, callEnd - methodEnd); + // Skip ExpressionTemplate calls - they don't have argument mapping + if (methodMatch.Value.Contains("ExpressionTemplate")) + continue; + // Handle LogError special case (first parameter might be exception) var hasException = methodMatch.Value.Contains("LogError") && HasExceptionFirstParam(callContent); string adjustedCallContent = callContent; @@ -344,7 +351,7 @@ private bool HasExceptionFirstParam(string callContent) if (trimmedContent.StartsWith("\"") || trimmedContent.StartsWith("@\"") || trimmedContent.StartsWith("$\"") || - Regex.IsMatch(trimmedContent, @"^@?(""{3,})")) // Raw string literals + (trimmedContent.Length > 2 && trimmedContent[0] == '\"' && trimmedContent[1] == '\"' && trimmedContent[2] == '\"')) // Raw string literals { // First parameter is a string template, no exception return false; @@ -356,63 +363,83 @@ private bool HasExceptionFirstParam(string callContent) private TemplateStringInfo ExtractTemplate(string callContent) { - // Handle different string literal formats - int start = -1; - int end = -1; - string template = null; - - // Try raw string literal (""" or more quotes for custom delimiters) - // Match 3 or more quotes at the start and end - var rawMatch = Regex.Match(callContent, @"@?(""{3,})([\s\S]*?)\1"); - if (rawMatch.Success) - { - // For raw string literals, the template content starts after the opening quotes - var quoteDelimiter = rawMatch.Groups[1].Value; - var quoteCount = quoteDelimiter.Length; - start = rawMatch.Index + quoteCount; - template = rawMatch.Groups[2].Value; - end = rawMatch.Index + rawMatch.Length; - } - else + // Find the first string literal in the call content + int searchPos = 0; + + // Skip whitespace at the beginning + while (searchPos < callContent.Length && char.IsWhiteSpace(callContent[searchPos])) + searchPos++; + + if (searchPos >= callContent.Length) + return null; + + // Try to parse a string literal using StringLiteralParser + if (_stringParser.TryParseStringLiteral(callContent, searchPos, out var result)) { - // Try verbatim string (@") - var verbatimMatch = Regex.Match(callContent, @"@""([^""]*(?:""""[^""]*)*)"""); - if (verbatimMatch.Success) + // Determine if it's a verbatim string + bool isVerbatim = searchPos < callContent.Length - 1 && + callContent[searchPos] == '@' && + callContent[searchPos + 1] == '"'; + + // For verbatim strings, we need to store the original content with "" for position mapping + if (isVerbatim) { - start = verbatimMatch.Index + 2; // Skip @" - template = verbatimMatch.Groups[1].Value.Replace("\"\"", "\""); - end = verbatimMatch.Index + verbatimMatch.Length; + // Extract the original content with escaped quotes intact + var originalContent = callContent.Substring(result.Start + 2, result.End - result.Start - 2); + var cleanedContent = originalContent.Replace("\"\"", "\""); - // For verbatim strings, store the original for position adjustment return new TemplateStringInfo { - Template = template, - RelativeStart = start, - RelativeEnd = end, - OriginalTemplate = verbatimMatch.Groups[1].Value, + Template = cleanedContent, + RelativeStart = result.Start + 2, // Skip @" + RelativeEnd = result.End + 1, + OriginalTemplate = originalContent, IsVerbatimString = true }; } - else + + // For raw strings, check quote count + int quoteCount = 0; + if (callContent[searchPos] == '"') { - // Try regular string - var regularMatch = Regex.Match(callContent, @"""([^""\\]*(?:\\.[^""\\]*)*)"""); - if (regularMatch.Success) + int pos = searchPos; + while (pos < callContent.Length && callContent[pos] == '"') { - start = regularMatch.Index + 1; // Skip opening quote - template = Regex.Unescape(regularMatch.Groups[1].Value); - end = regularMatch.Index + regularMatch.Length; + quoteCount++; + pos++; + } + } + + // For raw strings (3+ quotes), adjust start position + if (quoteCount >= 3) + { + return new TemplateStringInfo + { + Template = result.Content, + RelativeStart = result.Start + quoteCount, + RelativeEnd = result.End + 1 + }; + } + + // For regular strings, unescape the content + string unescapedContent = result.Content; + if (quoteCount == 1) // Regular string with escape sequences + { + try + { + unescapedContent = Regex.Unescape(result.Content); + } + catch + { + // If unescaping fails, use content as-is } } - } - if (template != null) - { return new TemplateStringInfo { - Template = template, - RelativeStart = start, - RelativeEnd = end + Template = unescapedContent, + RelativeStart = result.Start + 1, // Skip opening quote + RelativeEnd = result.End + 1 }; } diff --git a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs b/SerilogSyntax/Tagging/SerilogBraceMatcher.cs index 4a6783a..9484551 100644 --- a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs +++ b/SerilogSyntax/Tagging/SerilogBraceMatcher.cs @@ -417,20 +417,16 @@ public IEnumerable> GetTags(NormalizedSnapshotSpanCollec bool inExpressionTemplate = IsInsideExpressionTemplate(currentChar); // For single-line strings, check if we're in a Serilog context - // This includes checking the previous line for configuration methods like outputTemplate bool inSerilogContext = IsSerilogCall(lineText); // If not found on current line, check previous line for configuration patterns + // Use SerilogCallDetector for more robust detection if (!inSerilogContext && currentLine.LineNumber > 0) { var prevLine = snapshot.GetLineFromLineNumber(currentLine.LineNumber - 1); var prevText = prevLine.GetText(); - // Check if previous line has outputTemplate or other configuration methods - if (prevText.Contains("outputTemplate") || prevText.Contains("WriteTo.") || - prevText.Contains("Enrich.") || prevText.Contains("Filter.")) - { - inSerilogContext = true; - } + // Use the centralized detector which has more precise pattern matching + inSerilogContext = SerilogCallDetector.IsSerilogCall(prevText); } if (!inMultiLineString && !inExpressionTemplate && !inSerilogContext)