diff --git a/CLAUDE.md b/CLAUDE.md index c8a715e..2df0f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,7 @@ The deploy script updates your extension in-place without uninstalling, which is - The extension targets .NET Framework 4.7.2 - Uses Visual Studio SDK v17.0.32112.339 - Configured for Visual Studio Community 2022 (17.0-18.0) -- Fully functional with syntax highlighting, navigation, and brace matching +- Fully functional with syntax highlighting, navigation, and property-argument highlighting - Supports C# 11 raw string literals ("""...""") ## Implementation Overview @@ -156,11 +156,11 @@ This extension provides syntax highlighting and navigation for Serilog message t - **Syntax highlighting** of properties within Serilog message template strings - **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** that visually connects template properties with their corresponding arguments ### Technical Stack - **Roslyn Classification API** - For syntax highlighting via `IClassifier` -- **Roslyn Tagging API** - For brace matching via `ITagger` +- **Roslyn Tagging API** - For property-argument highlighting via `ITagger` - **Visual Studio Editor API** - For navigation features - **MEF (Managed Extensibility Framework)** - For VS integration @@ -193,7 +193,7 @@ The extension includes these components: 2. **Classification/SerilogClassifier.cs** - Implements `IClassifier` for syntax highlighting 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 +5. **Tagging/PropertyArgumentHighlighter.cs** - Implements `ITagger` for property-argument highlighting 6. **Utilities/SerilogCallDetector.cs** - Centralized Serilog call detection logic 7. **Utilities/LruCache.cs** - Thread-safe LRU cache for parsed templates diff --git a/Example/README.md b/Example/README.md index a2912b9..e0c39f4 100644 --- a/Example/README.md +++ b/Example/README.md @@ -89,7 +89,7 @@ Open `Program.cs` in Visual Studio with the Serilog Syntax extension installed t - **Teal** for property paths in expressions ### Interactive Features -- **Brace matching** when cursor is on `{` or `}` +- **Property-argument highlighting** when cursor is on a property or argument - **Light bulb navigation** from properties to arguments - **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..7d19a5e 100644 --- a/README.md +++ b/README.md @@ -49,12 +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" -### 🔍 Brace Matching -- Highlight matching braces when cursor is positioned on `{` or `}` -- Visual indication of brace pairs in complex templates -- **Multi-line support** - matches braces across line boundaries in verbatim and raw strings +### 🔍 Property-Argument Highlighting +- **Smart highlighting** that connects template properties with their corresponding arguments +- When cursor is on a property like `{UserId}`, both the property and its argument are highlighted +- When cursor is on an argument, both the argument and its corresponding property are highlighted +- **Multi-line support** - works across line boundaries in verbatim and raw strings - Press **ESC** to temporarily dismiss highlights -- Helps identify mismatched or nested braces +- Helps visualize the connection between template properties and their values ## Installation @@ -118,7 +119,7 @@ After installation, the extension works automatically - no configuration require ``` 3. **See instant highlighting** as you type - properties turn blue, operators yellow, etc. 4. **Try navigation**: Hover over a property like `{UserId}` and click the light bulb to jump to its argument -5. **Test brace matching**: Place your cursor on any `{` or `}` to see its matching pair highlighted +5. **Test property-argument highlighting**: Place your cursor on a property or argument to see both highlighted ### Quick Test Create a new C# file and paste this to see all features: @@ -131,9 +132,9 @@ Log.Information("User {UserId} logged in with {@Details} at {Timestamp:HH:mm:ss} You should see: - `UserId` in blue (adapts to your theme) -- `@` in warm orange/red, `Details` in blue +- `@` in warm orange/red, `Details` in blue - `Timestamp` in blue, `:HH:mm:ss` in green -- Matching braces highlighted when cursor is on them +- Property-argument pairs highlighted when cursor is on them - Colors automatically match your Light/Dark theme preference ## Supported Serilog Syntax @@ -238,13 +239,13 @@ log.LogError("Error with {Context}", context); The extension uses Visual Studio's extensibility APIs: - **Roslyn Classification API** - For syntax highlighting via `IClassifier` -- **Roslyn Tagging API** - For brace matching via `ITagger` +- **Roslyn Tagging API** - For property-argument highlighting via `ITagger` - **Suggested Actions API** - For navigation features via `ISuggestedActionsSourceProvider` - **MEF (Managed Extensibility Framework)** - For Visual Studio integration Key components: - `SerilogClassifier` - Handles syntax highlighting with smart cache invalidation -- `SerilogBraceMatcher` - Provides brace matching +- `PropertyArgumentHighlighter` - Provides property-argument connection highlighting - `SerilogNavigationProvider` - Enables property-to-argument navigation - `SerilogCallDetector` - Optimized Serilog call detection with pre-check optimization - `SerilogThemeColors` - Theme-aware color management with WCAG AA compliance diff --git a/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs b/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs index 24837c7..646a379 100644 --- a/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs +++ b/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs @@ -527,7 +527,79 @@ public void GetSuggestedActions_RawStringMultiLine_EarlyProperties_ShouldProvide // All should work, but currently only later ones do Assert.NotEmpty(appNameActions); - Assert.NotEmpty(versionActions); + Assert.NotEmpty(versionActions); Assert.NotEmpty(environmentActions); } + + [Fact] + public void GetSuggestedActions_WideRangeFromVS_AllPropertiesShowUserNameBug() + { + // This test captures the EXACT bug: "all of the properties say the exact same thing -> Navigate to UserName argument" + // When VS passes a wide range, midpoint calculation always lands on UserName + + // Arrange - exact scenario from user + var code = @"logger.LogInformation(""User {UserId} ({UserName}) placed {OrderCount} orders totaling {TotalAmount:C}"", + userId, userName, orderCount, totalAmount);"; + + var mockBuffer = MockTextBuffer.Create(code); + var mockTextView = new MockTextView(mockBuffer); + var provider = new SerilogSuggestedActionsSource(mockTextView); + + // Simulate VS passing a wide range that covers multiple properties + // The midpoint of this range will land on UserName property + var rangeStart = code.IndexOf("{UserId}"); + var rangeEnd = code.IndexOf("{OrderCount}") + 5; // Goes partway through OrderCount + var wideRange = new SnapshotSpan(mockBuffer.CurrentSnapshot, rangeStart, rangeEnd - rangeStart); + + // Act - Get suggested actions with VS's wide range + var actions = provider.GetSuggestedActions(null, wideRange, CancellationToken.None); + + // Assert + Assert.NotEmpty(actions); + var navigateAction = actions.First().Actions.First() as NavigateToArgumentAction; + Assert.NotNull(navigateAction); + + // The bug: Because midpoint lands on UserName, it shows "Navigate to 'UserName' argument" + // even though the range covers multiple properties (UserId, UserName, OrderCount) + // This test should initially FAIL because it shows UserName instead of the property cursor is actually on + Assert.NotEqual("Navigate to 'UserName' argument", navigateAction.DisplayText); + } + + [Fact] + public void GetSuggestedActions_VSPassesWideRangeFromLineStart_ShowsWrongProperty() + { + // This reproduces the EXACT bug: VS passes range from line start to cursor position + // When cursor is on {OrderCount}, the midpoint lands on {UserId} + + var code = @"// Standard properties with multiple types +logger.LogInformation(""User {UserId} ({UserName}) placed {OrderCount} orders totaling {TotalAmount:C}"", + userId, userName, orderCount, totalAmount);"; + + var mockBuffer = MockTextBuffer.Create(code); + var mockTextView = new MockTextView(mockBuffer); + var provider = new SerilogSuggestedActionsSource(mockTextView); + + // Find positions exactly as they appear in the screenshot scenario + var lineStart = code.IndexOf("logger.LogInformation"); // Position 44 + var orderCountPos = code.IndexOf("OrderCount"); // Position 101 (inside the property name) + + // VS passes a range from line start to cursor position when showing light bulb + // This wide range causes midpoint to land on UserId instead of OrderCount + // Range ends at position 101, which is inside "OrderCount" + var range = new SnapshotSpan(mockBuffer.CurrentSnapshot, lineStart, orderCountPos - lineStart); + + // Act + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + // Assert + Assert.NotEmpty(actions); + var actionSet = actions.First(); + Assert.NotEmpty(actionSet.Actions); + var navigateAction = actionSet.Actions.First() as NavigateToArgumentAction; + Assert.NotNull(navigateAction); + + // BUG: Shows "Navigate to 'UserId' argument" instead of "Navigate to 'OrderCount' argument" + // because midpoint calculation lands on UserId + Assert.Equal("Navigate to 'OrderCount' argument", navigateAction.DisplayText); + } } \ No newline at end of file diff --git a/SerilogSyntax.Tests/Tagging/ExpressionBraceMatcherTests.cs b/SerilogSyntax.Tests/Tagging/ExpressionBraceMatcherTests.cs deleted file mode 100644 index 2c64ad8..0000000 --- a/SerilogSyntax.Tests/Tagging/ExpressionBraceMatcherTests.cs +++ /dev/null @@ -1,505 +0,0 @@ -using Microsoft.VisualStudio.Text; -using SerilogSyntax.Tagging; -using SerilogSyntax.Tests.TestHelpers; -using System.Linq; -using Xunit; - -namespace SerilogSyntax.Tests.Tagging; - -public class ExpressionBraceMatcherTests -{ - [Fact] - public void ExpressionBraceMatching_SimpleBuiltinProperty_MatchesCorrectly() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {@t:HH:mm:ss} - var openBracePos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); // Opening and closing brace - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); // Opening { - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 12); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_IfDirective_MatchesCorrectly() - { - var text = "new ExpressionTemplate(\"{#if Level = 'Error'}ERROR{#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {#if...} - var openBracePos = text.IndexOf("{#if"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); // Opening { - var closingBracePos = text.IndexOf("}ERROR"); - Assert.Contains(tags, t => t.Span.Start.Position == closingBracePos); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_NestedBraces_MatchesOuterPair() - { - var text = "new ExpressionTemplate(\"{#if @p['RequestId'] is not null}[{@p['RequestId']}]{#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of outer {#if...} - var outerOpenPos = text.IndexOf("{#if"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, outerOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == outerOpenPos); - - // Should match to the } before [ - var outerClosePos = text.IndexOf("}["); - Assert.Contains(tags, t => t.Span.Start.Position == outerClosePos); - } - - [Fact] - public void ExpressionBraceMatching_NestedBraces_MatchesInnerPair() - { - var text = "new ExpressionTemplate(\"{#if @p['RequestId'] is not null}[{@p['RequestId']}]{#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of inner {@p['RequestId']} - var innerOpenPos = text.IndexOf("{@p['RequestId']"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, innerOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == innerOpenPos); - - // Should match to the } after 'RequestId'] - var innerClosePos = text.IndexOf("}]"); - Assert.Contains(tags, t => t.Span.Start.Position == innerClosePos); - } - - [Fact] - public void ExpressionBraceMatching_EachLoop_MatchesCorrectly() - { - var text = "new ExpressionTemplate(\"{#each name, value in @p} | {name}={value}{#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {#each...} - var eachOpenPos = text.IndexOf("{#each"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, eachOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == eachOpenPos); - - // Should match to the } after @p - var eachClosePos = text.IndexOf("} |"); - Assert.Contains(tags, t => t.Span.Start.Position == eachClosePos); - } - - [Fact] - public void ExpressionBraceMatching_EachLoopVariable_MatchesCorrectly() - { - var text = "new ExpressionTemplate(\"{#each name, value in @p} | {name}={value}{#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {name} - var nameOpenPos = text.IndexOf("{name}"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, nameOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == nameOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == nameOpenPos + 5); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_ComplexNesting_HandlesCorrectly() - { - var text = "new ExpressionTemplate(\"{#if Level = 'Error'}[ERROR]{#else if Level = 'Warning'}[WARN]{#else}[INFO]{#end} {@m}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {#else if...} - var elseIfPos = text.IndexOf("{#else if"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, elseIfPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == elseIfPos); - - // Should match to the } after 'Warning' - var elseIfClosePos = text.IndexOf("}[WARN]"); - Assert.Contains(tags, t => t.Span.Start.Position == elseIfClosePos); - } - - [Fact] - public void ExpressionBraceMatching_IndexerSyntax_MatchesCorrectly() - { - var text = "new ExpressionTemplate(\"{@p['RequestId']}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{@p"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - var closeBracePos = text.IndexOf("}\""); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void ExpressionBraceMatching_CursorAfterOpeningBrace_NoMatch() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position cursor just after opening brace - per VS standard, should NOT match - var openBracePos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not match when cursor is after opening brace (VS standard) - Assert.Empty(tags); - } - - [Fact] - public void ExpressionBraceMatching_CursorAfterClosingBrace_Matches() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position cursor just after closing brace - per VS standard, SHOULD match - var closeBracePos = text.IndexOf("}\""); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should match when cursor is after closing brace (VS standard) - Assert.Equal(2, tags.Count); - var openBracePos = text.IndexOf("{@t"); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void ExpressionBraceMatching_CursorBeforeClosingBrace_NoMatch() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position cursor ON closing brace (to its left) - per VS standard, should NOT match - var closeBracePos = text.IndexOf("}\""); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not match when cursor is to the left of closing brace (VS standard) - Assert.Empty(tags); - } - - [Fact] - public void ExpressionBraceMatching_RegularTemplate_AlsoMatchesCorrectly() - { - var text = "Log.Information(\"Hello {Name}\")"; // Regular template, not expression - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on brace in regular template - var bracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, bracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Combined matcher should match regular Serilog template braces too - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == bracePos); // Opening { - Assert.Contains(tags, t => t.Span.Start.Position == bracePos + 5); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_EscapedBraces_IgnoresEscaped() - { - var text = "new ExpressionTemplate(\"{{escaped}} {@t}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {@t} - var realBracePos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, realBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == realBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == realBracePos + 3); // Closing } - - // Should not match escaped braces - var escapedPos = text.IndexOf("{{"); - Assert.DoesNotContain(tags, t => t.Span.Start.Position == escapedPos); - } - - [Fact] - public void ExpressionBraceMatching_UnmatchedBrace_NoMatching() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss\")"; // Missing closing brace - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // Should not match unmatched braces - } - - [Fact] - public void ExpressionBraceMatching_MultipleProperties_OnlyHighlightsCurrentPair() - { - var text = "new ExpressionTemplate(\"{@t:HH:mm:ss} {Level} {@m}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on first property's opening brace - var firstOpenPos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, firstOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); // Only the current pair - Assert.Contains(tags, t => t.Span.Start.Position == firstOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == firstOpenPos + 12); - - // Move to AFTER second property's closing brace (VS standard) - var secondClosePos = text.IndexOf("} {@m"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, secondClosePos + 1)); - - tags = [.. matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length)))]; - - Assert.Equal(2, tags.Count); // Only the second pair - var secondOpenPos = text.IndexOf("{Level"); - Assert.Contains(tags, t => t.Span.Start.Position == secondOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == secondClosePos); - } - - #region Expression Method Coverage Tests - - [Fact] - public void ExpressionBraceMatching_MaxExpressionLengthExceeded_NoMatch() - { - // Create a very long expression that exceeds MaxExpressionLength (500 chars) - var longExpression = new string('A', 600); - var text = $"new ExpressionTemplate(\"{{{longExpression}}}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{A"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not find match due to exceeding MaxExpressionLength - Assert.Empty(tags); - } - - [Fact] - public void ExpressionBraceMatching_DeepNestedBraces() - { - var text = "new ExpressionTemplate(\"{#if Level = 'Error'} then {Value} else {Default} {#end}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the {Value} opening brace (nested inside the #if) - var valueOpenPos = text.IndexOf("{Value"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, valueOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // The implementation should support nested braces in expressions - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == valueOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == valueOpenPos + 6); // Closing } after "Value" - } - - [Fact] - public void ExpressionBraceMatching_StringBoundaryStopsSearch() - { - var text = "new ExpressionTemplate(\"{@t\" + \"different string {Name}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace that should not match across string boundaries - var openBracePos = text.IndexOf("{@t"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not find match due to string boundary (quote before {Name}) - Assert.Empty(tags); - } - - [Fact] - public void ExpressionBraceMatching_EscapedQuoteInString() - { - var text = "new ExpressionTemplate(\"{@t} with \\\"escaped quote\\\" and {Name}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {Name} - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_EscapedBracesInExpression() - { - var text = "new ExpressionTemplate(\"before {{{{ and {RealProp} after\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {RealProp} - var openBracePos = text.IndexOf("{RealProp"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 9); // Closing } - } - - [Fact] - public void ExpressionBraceMatching_BackwardSearch_EscapedBraces() - { - var text = "new ExpressionTemplate(\"{{{{ {RealProp} }}}}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position AFTER the closing brace of {RealProp} (VS standard) - var closeBracePos = text.IndexOf("} }}}}"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openBracePos = text.IndexOf("{RealProp"); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void ExpressionBraceMatching_BackwardSearch_StringBoundary() - { - var text = "var x = \"other {thing}\" + new ExpressionTemplate(\"dangling}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position AFTER the dangling closing brace - var danglingClosePos = text.IndexOf("dangling}") + 8; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, danglingClosePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not match due to string boundary (quote after "other {thing}") - Assert.Empty(tags); - } - - [Fact] - public void ExpressionBraceMatching_MaxLengthBackwardSearch() - { - // Create a large gap between braces that exceeds MaxExpressionLength (500) - var longContent = new string('A', 600); - var text = $"new ExpressionTemplate(\"{{{longContent}}}\")"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position AFTER the closing brace - var closeBracePos = text.LastIndexOf("}"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not find match due to exceeding MaxExpressionLength in backward search - Assert.Empty(tags); - } - - #endregion -} \ No newline at end of file diff --git a/SerilogSyntax.Tests/Tagging/MultiLineBraceMatcherTests.cs b/SerilogSyntax.Tests/Tagging/MultiLineBraceMatcherTests.cs deleted file mode 100644 index 9ff0594..0000000 --- a/SerilogSyntax.Tests/Tagging/MultiLineBraceMatcherTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Microsoft.VisualStudio.Text; -using SerilogSyntax.Tagging; -using SerilogSyntax.Tests.TestHelpers; -using System.Linq; -using Xunit; - -namespace SerilogSyntax.Tests.Tagging; - -public class MultiLineBraceMatcherTests -{ - [Fact] - public void BraceMatching_InRawStringLiteral_MatchesAcrossLines() - { - var text = @" -logger.LogInformation("""""" - Processing record: - ID: {RecordId} - Status: {Status} - """""", recordId, status);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Test opening brace of {RecordId} - var openBracePos = text.IndexOf("{RecordId"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); // Opening and closing brace - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); // Opening { - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 9); // Closing } - } - - [Fact] - public void BraceMatching_InVerbatimString_MatchesAcrossLines() - { - var text = @" -logger.LogInformation(@""Processing: - User: {UserName} - ID: {UserId} - Time: {Timestamp}"", userName, userId, timestamp);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Test closing brace of {UserName} - var closeBracePos = text.IndexOf("UserName}") + 8; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openPos = text.IndexOf("{UserName"); - Assert.Contains(tags, t => t.Span.Start.Position == openPos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_PropertySpansMultipleLines_MatchesCorrectly() - { - // This is an edge case where a property with formatting spans lines - var text = @" -logger.LogInformation("""""" - Value: {Amount, - 10:C2} - """""", amount);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{Amount"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var closePos = text.IndexOf(":C2}") + 3; - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closePos); - } - - [Fact] - public void BraceMatching_EscapedBracesInMultiLine_IgnoresEscaped() - { - var text = @" -logger.LogInformation("""""" - Use {{double}} for literal - Property: {Value} - """""", value);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on {Value} - var openBracePos = text.IndexOf("{Value"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 6); - } - - [Fact] - public void BraceMatching_NotInMultiLineString_UsesSingleLineLogic() - { - var text = @"logger.LogInformation(""User {Name} logged in"", name);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); - } - - [Fact] - public void BraceMatching_CursorAfterClosingBrace_StillMatches() - { - var text = @" -logger.LogInformation("""""" - ID: {RecordId} - """""", recordId);"; - - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position cursor right after the closing } - var closeBracePos = text.IndexOf("RecordId}") + 8; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openPos = text.IndexOf("{RecordId"); - Assert.Contains(tags, t => t.Span.Start.Position == openPos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } -} \ No newline at end of file diff --git a/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs new file mode 100644 index 0000000..452c6cd --- /dev/null +++ b/SerilogSyntax.Tests/Tagging/PropertyArgumentHighlighterTests.cs @@ -0,0 +1,612 @@ +using Microsoft.VisualStudio.Text; +using SerilogSyntax.Tagging; +using SerilogSyntax.Tests.TestHelpers; +using System.Linq; +using Xunit; + +namespace SerilogSyntax.Tests.Tagging; + +public class PropertyArgumentHighlighterTests +{ + [Fact] + public void PropertyHighlighting_CursorOnProperty_HighlightsBothPropertyAndArgument() + { + var text = "Log.Information(\"User {UserId} logged in\", userId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {UserId} property (position 24 is within {UserId}) + var propertyPos = text.IndexOf("{UserId}") + 2; // Position inside the property + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); // Should highlight both property and argument + + // Check property highlight + var propertyStartPos = text.IndexOf("{UserId}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 8); // Length of {UserId} + + // Check argument highlight + var argumentStartPos = text.IndexOf("userId)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 6); // Length of userId + } + + [Fact] + public void PropertyHighlighting_CursorOnArgument_HighlightsBothArgumentAndProperty() + { + var text = "Log.Information(\"User {UserId} logged in\", userId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on userId argument + var argumentPos = text.IndexOf("userId)") + 2; // Position inside the argument + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, argumentPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); // Should highlight both argument and property + + // Check property highlight + var propertyStartPos = text.IndexOf("{UserId}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 8); + + // Check argument highlight + var argumentStartPos = text.IndexOf("userId)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 6); + } + + [Fact] + public void PropertyHighlighting_MultipleProperties_HighlightsCorrectPair() + { + var text = "Log.Information(\"User {UserId} logged in at {Time}\", userId, DateTime.Now);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {Time} property + var timePos = text.IndexOf("{Time}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, timePos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{Time}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 6); // Length of {Time} + + // Check argument highlight (DateTime.Now) + var argumentStartPos = text.IndexOf("DateTime.Now"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos); + } + + [Fact] + public void PropertyHighlighting_PositionalProperty_HighlightsCorrectArgument() + { + var text = "Log.Information(\"Value {0} and {1}\", first, second);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {0} property + var pos0 = text.IndexOf("{0}") + 1; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, pos0)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{0}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 3); // Length of {0} + + // Check argument highlight (first) + var argumentStartPos = text.IndexOf("first"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 5); // Length of first + } + + [Fact] + public void PropertyHighlighting_DestructuredProperty_HighlightsCorrectly() + { + var text = "Log.Information(\"User {@User} logged in\", user);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {@User} property + var userPos = text.IndexOf("{@User}") + 3; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, userPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight (including @ symbol) + var propertyStartPos = text.IndexOf("{@User}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 7); // Length of {@User} + + // Check argument highlight + var argumentStartPos = text.IndexOf("user)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 4); // Length of user + } + + [Fact] + public void PropertyHighlighting_CursorOutsideTemplate_NoHighlights() + { + var text = "Log.Information(\"User {UserId} logged in\", userId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor outside template and arguments (in "Log.Information") + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, 5)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Empty(tags); // No highlights when cursor is not on property or argument + } + + [Fact] + public void PropertyHighlighting_MultiLineTemplate_HighlightsCorrectly() + { + var lines = new[] + { + "Log.Information(", + " \"User {UserId} logged in\",", + " userId);" + }; + var text = string.Join("\n", lines); + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {UserId} property + var propertyPos = text.IndexOf("{UserId}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); // Should highlight both property and argument + } + + [Fact] + public void PropertyHighlighting_VerbatimString_HighlightsCorrectly() + { + var text = "Log.Information(@\"User {UserId} logged in\", userId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {UserId} property + var propertyPos = text.IndexOf("{UserId}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + } + + [Fact] + public void PropertyHighlighting_AfterEscKeyDismissal_NoHighlights() + { + var text = "Log.Information(\"User {UserId} logged in\", userId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on property and get initial highlights + var propertyPos = text.IndexOf("{UserId}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var initialTags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + Assert.Equal(2, initialTags.Count); + + // Simulate ESC key dismissal + state.DisableHighlights(); + + var tagsAfterEsc = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Empty(tagsAfterEsc); // No highlights after ESC dismissal + } + + [Fact] + public void PropertyHighlighting_LogErrorWithException_HighlightsCorrectArgument() + { + var text = "logger.LogError(ex, \"Failed to process {ItemId}\", itemId);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {ItemId} property + var propertyPos = text.IndexOf("{ItemId}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check that it highlights itemId (not ex) + var argumentStartPos = text.IndexOf("itemId)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos); + } + + [Fact] + public void PropertyHighlighting_NotSerilogCall_NoHighlights() + { + var text = "Console.WriteLine(\"User {Name} logged in\");"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on what looks like a property but isn't in a Serilog call + var propertyPos = text.IndexOf("{Name}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Empty(tags); // No highlights for non-Serilog calls + } + + [Fact] + public void PropertyHighlighting_MultiLineCall_CursorOnArgumentLine_HighlightsBothPropertyAndArgument() + { + // This test captures the real Visual Studio behavior where arguments are on a different line + var lines = new[] + { + "logger.LogInformation(\"User {UserId} logged in\",", + " userId);" // Cursor will be on this line, on 'userId' + }; + var text = string.Join("\r\n", lines); + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on 'userId' which is on line 2 + var userIdPos = text.IndexOf("userId") + 2; // Position inside 'userId' + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, userIdPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // THIS SHOULD HIGHLIGHT BOTH BUT CURRENTLY DOESN'T + Assert.Equal(2, tags.Count); // Should highlight both property and argument + + // Check property highlight + var propertyStartPos = text.IndexOf("{UserId}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 8); + + // Check argument highlight + var argumentStartPos = text.IndexOf("userId)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 6); + } + + [Fact] + public void PropertyHighlighting_VerbatimStringMultiLine_CursorOnArgumentLine_HighlightsBothPropertyAndArgument() + { + // Test with verbatim string spanning multiple lines + var lines = new[] + { + "logger.LogInformation(@\"Processing files in path: {FilePath}", + "With properties like {UserId}\",", + " filePath, userId);" // Cursor will be on this line + }; + var text = string.Join("\r\n", lines); + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on 'filePath' which is on line 3 + var filePathPos = text.IndexOf("filePath,") + 4; // Position inside 'filePath' + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, filePathPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight both filePath argument and {FilePath} property + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{FilePath}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos); + + // Check argument highlight + var argumentStartPos = text.IndexOf("filePath,"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos); + } + + [Fact] + public void PropertyHighlighting_VeryLongMultiLineVerbatimString_CursorOnProperty_HighlightsBothPropertyAndArgument() + { + // EXACT scenario from Visual Studio + var text = @" // 5. Very long multi-line verbatim string + var version = ""1.0.0""; + var env = ""Production""; + var sessionId = Guid.NewGuid(); + logger.LogInformation(@"" +=============================================== +Application: {AppName} +Version: {Version} +Environment: {Environment} +=============================================== +User: {UserName} (ID: {UserId}) +Session: {SessionId} +Timestamp: {Timestamp:yyyy-MM-dd HH:mm:ss} +=============================================== +"", appName, version, env, userName, userId, sessionId, DateTime.Now);"; + + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {AppName} property + var propertyPos = text.IndexOf("{AppName}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight both property and argument + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{AppName}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 9); // Length of {AppName} + + // Check argument highlight (appName) + var argumentStartPos = text.IndexOf("appName,"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 7); // Length of appName + } + + [Fact] + public void PropertyHighlighting_RawStringWithCustomDelimiter_CursorOnProperty_HighlightsBothPropertyAndArgument() + { + // EXACT scenario - Raw string with custom delimiter (4+ quotes) + var text = @"// 4. Raw string with custom delimiter (4+ quotes) +var data = ""test-data""; +logger.LogInformation("""""""" + Template with """""" inside: {Data} + This allows literal triple quotes in the string + """""""", data);"; + + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {Data} property + var propertyPos = text.IndexOf("{Data}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight both property and argument + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{Data}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 6); // Length of {Data} + + // Check argument highlight + var argumentStartPos = text.IndexOf("data);"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 4); // Length of data + } + + [Fact] + public void PropertyHighlighting_MultiLineLogErrorWithException_CursorOnProperty_HighlightsBothPropertyAndArgument() + { + // EXACT scenario - Multi-line LogError call with exception parameter + var text = @"// Example 3: Multi-line LogError call (for testing navigation) +logger.LogError(new Exception(""Connection timeout""), + ""Processing failed for {UserId} with {ErrorCode}"", + userId, + errorCode);"; + + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {UserId} property + var propertyPos = text.IndexOf("{UserId}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight both property and argument + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{UserId}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 8); // Length of {UserId} + + // Check argument highlight + var argumentStartPos = text.IndexOf("userId,"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 6); // Length of userId + } + + [Fact] + public void PropertyHighlighting_MultiLineLogErrorWithException_CursorOnArgument_HighlightsBothArgumentAndProperty() + { + // EXACT scenario - Cursor on argument in multi-line LogError call + var text = @"// Example 3: Multi-line LogError call (for testing navigation) +logger.LogError(new Exception(""Connection timeout""), + ""Processing failed for {UserId} with {ErrorCode}"", + userId, + errorCode);"; + + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on errorCode argument + var argumentPos = text.IndexOf("errorCode") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, argumentPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + // Should highlight both argument and property + Assert.Equal(2, tags.Count); + + // Check argument highlight + var argumentStartPos = text.IndexOf("errorCode"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 9); // Length of errorCode + + // Check property highlight + var propertyStartPos = text.IndexOf("{ErrorCode}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 11); // Length of {ErrorCode} + } + + [Fact] + public void PropertyHighlighting_StringifiedProperty_HighlightsCorrectly() + { + var text = "Log.Information(\"Value: {$Value}\", value);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {$Value} property + var propertyPos = text.IndexOf("{$Value}") + 3; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{$Value}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 8); // Length of {$Value} + + // Check argument highlight + var argumentStartPos = text.IndexOf("value)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos && t.Span.Length == 5); // Length of value + } + + [Fact] + public void PropertyHighlighting_ComplexPropertyWithFormatting_HighlightsCorrectly() + { + var text = "Log.Information(\"Price: {Price,10:C2}\", price);"; + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {Price,10:C2} property + var propertyPos = text.IndexOf("{Price") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyPos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight (entire {Price,10:C2}) + var propertyStartPos = text.IndexOf("{Price"); + var propertyLength = "{Price,10:C2}".Length; + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == propertyLength); + + // Check argument highlight + var argumentStartPos = text.IndexOf("price)"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos); + } + + [Fact] + public void PropertyHighlighting_RawStringLiteral_HighlightsCorrectly() + { + var lines = new[] + { + "Log.Information(\"\"\"User {Name}", + " logged in at {Timestamp}", + " \"\"\", name, DateTime.Now);" + }; + var text = string.Join("\n", lines); + var buffer = new MockTextBuffer(text); + var view = new MockTextView(buffer); + var state = new PropertyArgumentHighlightState(view); + var highlighter = new PropertyArgumentHighlighter(view, buffer, state); + + // Position cursor on {Name} property + var namePos = text.IndexOf("{Name}") + 2; + view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, namePos)); + + var tags = highlighter.GetTags(new NormalizedSnapshotSpanCollection( + new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); + + Assert.Equal(2, tags.Count); + + // Check property highlight + var propertyStartPos = text.IndexOf("{Name}"); + Assert.Contains(tags, t => t.Span.Start.Position == propertyStartPos && t.Span.Length == 6); + + // Check argument highlight + var argumentStartPos = text.IndexOf("name,"); + Assert.Contains(tags, t => t.Span.Start.Position == argumentStartPos); + } + + [Fact] + public void PropertyHighlightState_StateChangedEvent_FiresOnStateChange() + { + var view = new MockTextView(MockTextBuffer.Create("test")); + var state = new PropertyArgumentHighlightState(view); + bool eventFired = false; + + state.StateChanged += (sender, e) => eventFired = true; + state.ClearHighlights(); + + Assert.True(eventFired); + } + + [Fact] + public void PropertyHighlightState_HasHighlights_ReturnsFalseWhenEmpty() + { + var view = new MockTextView(MockTextBuffer.Create("test")); + var state = new PropertyArgumentHighlightState(view); + + Assert.False(state.HasHighlights()); + } + + [Fact] + public void PropertyHighlightState_EnableHighlights_ClearsDisabledFlag() + { + var view = new MockTextView(MockTextBuffer.Create("test")); + var state = new PropertyArgumentHighlightState(view); + + state.EnableHighlights(); + + Assert.False(state.IsDisabled); + } +} \ No newline at end of file diff --git a/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs b/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs deleted file mode 100644 index 1d8ca4f..0000000 --- a/SerilogSyntax.Tests/Tagging/SerilogBraceMatcherTests.cs +++ /dev/null @@ -1,820 +0,0 @@ -using Microsoft.VisualStudio.Text; -using SerilogSyntax.Tagging; -using SerilogSyntax.Tests.TestHelpers; -using System.Collections.Generic; -using System.Linq; -using Xunit; - -namespace SerilogSyntax.Tests.Tagging; - -public class SerilogBraceMatcherTests -{ - [Fact] - public void BraceMatching_SimpleProperty_MatchesCorrectly() - { - var text = "Log.Information(\"Hello {Name}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); // Opening and closing brace - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); // Opening { - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } - } - - [Fact] - public void BraceMatching_MultipleProperties_OnlyHighlightsCurrentPair() - { - var text = "Log.Information(\"User {Name} logged in at {Timestamp}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // 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(); - - Assert.Equal(2, tags.Count); // Only the current pair - Assert.Contains(tags, t => t.Span.Start.Position == firstOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == firstOpenPos + 5); - - // Move to second property's closing brace - var secondClosePos = text.IndexOf("Timestamp}") + 9; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, secondClosePos)); - - tags = [.. matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length)))]; - - Assert.Equal(2, tags.Count); - var secondOpenPos = text.IndexOf("{Timestamp"); - Assert.Contains(tags, t => t.Span.Start.Position == secondOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == secondClosePos); - } - - [Fact] - public void BraceMatching_EscapedBraces_IgnoresEscaped() - { - var text = "Log.Information(\"Use {{braces}} for {Property}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the actual property brace - var propertyOpenPos = text.IndexOf("{Property"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, propertyOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == propertyOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == propertyOpenPos + 9); - } - - [Fact] - public void BraceMatching_ComplexProperty_WithFormatting_MatchesCorrectly() - { - var text = "Log.Information(\"Price: {Price,10:C2}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{Price"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var closePos = text.IndexOf(":C2}") + 3; - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closePos); - } - - [Fact] - public void BraceMatching_NotSerilogCall_NoMatches() - { - var text = "Console.WriteLine(\"Hello {World}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on brace in non-Serilog call - var openBracePos = text.IndexOf("{World"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // No matches for non-Serilog calls - } - - [Fact] - public void BraceMatching_CursorAfterClosingBrace_StillMatches() - { - var text = "Log.Information(\"Value: {Amount}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position cursor right after the closing } - var closeBracePos = text.IndexOf("Amount}") + 6; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos + 1)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openPos = text.IndexOf("{Amount"); - Assert.Contains(tags, t => t.Span.Start.Position == openPos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_DestructuredProperty_MatchesCorrectly() - { - var text = "Log.Information(\"User {@User} logged in\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{@User"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 6); // Closing } - } - - [Fact] - public void BraceMatching_StringifiedProperty_MatchesCorrectly() - { - var text = "Log.Information(\"Value: {$Value}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on closing brace - var closeBracePos = text.IndexOf("$Value}") + 6; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openPos = text.IndexOf("{$Value"); - Assert.Contains(tags, t => t.Span.Start.Position == openPos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_PositionalProperty_MatchesCorrectly() - { - var text = "Log.Information(\"Item {0} processed\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{0"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 2); // Closing } - } - - #region IsInsideMultiLineString Tests - - [Fact] - public void BraceMatching_VerbatimString_SingleLine_ClosedSameLine() - { - var text = "Log.Information(@\"User {Name} logged in\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } - } - - [Fact] - public void BraceMatching_VerbatimString_MultiLine_WithEscapedQuotes() - { - var lines = new[] - { - "Log.Information(@\"User {Name}", - "said \"\"Hello\"\" to {Friend}\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace on second line - var openBracePos = text.IndexOf("{Friend"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 7); // Closing } - } - - [Fact] - public void BraceMatching_VerbatimString_MultiLine_UnclosedString() - { - var lines = new[] - { - "Log.Information(@\"User {Name}", - "logged in at {Timestamp}" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace on second line (inside unclosed verbatim string) - var openBracePos = text.IndexOf("{Timestamp"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 10); // Closing } - } - - [Fact] - public void BraceMatching_RawString_MultiLine() - { - var lines = new[] - { - "Log.Information(\"\"\"User {Name}", - "logged in at {Timestamp}", - "\"\"\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace on second line - var openBracePos = text.IndexOf("{Timestamp"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 10); // Closing } - } - - [Fact] - public void BraceMatching_RawString_UnclosedString() - { - var lines = new[] - { - "Log.Information(\"\"\"User {Name}", - "logged in at {Timestamp}" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace of {Name} (which should work in multi-line context) - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } - } - - [Fact] - public void BraceMatching_VerbatimString_WithPreviousLineSerilogCall() - { - var lines = new[] - { - "_logger.LogInformation(@\"User {Name}", - " logged in\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace on first line - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos + 5); // Closing } - } - - [Fact] - public void BraceMatching_NonSerilogVerbatimString_NoMatching() - { - var lines = new[] - { - "Console.WriteLine(@\"User {Name}", - "logged in\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on opening brace on first line (non-Serilog call) - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // No matching for non-Serilog calls - } - - [Fact] - public void BraceMatching_VerbatimString_MaxLookbackExceeded() - { - // Create a text with more than MaxLookbackLines (20) lines before the Serilog call - var lines = new List(); - for (int i = 0; i < 25; i++) - { - lines.Add($"// Comment line {i}"); - } - lines.Add("Log.Information(@\"User {Name}"); - lines.Add("logged in\");"); - - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the last line (should be treated as regular string, not multi-line) - var lastLineStart = text.LastIndexOf("logged in"); - var caretPos = lastLineStart + 5; // Position somewhere in "logged in" - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, caretPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // Should not find multi-line string context due to lookback limit - } - - #endregion - - #region FindMultiLineBraceMatch Tests - - [Fact] - public void BraceMatching_MultiLine_SimpleCase() - { - var lines = new[] - { - "Log.Information(@\"User {Name}", - " is active\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace - var openBracePos = text.IndexOf("{Name"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var closeBracePos = text.IndexOf("Name}") + 4; // Position on the } after "Name" - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_MultiLine_InvalidSyntax_NoMatching() - { - // Test an invalid Serilog syntax scenario - braces with nested content spanning lines - // This is NOT valid Serilog syntax and should not be matched - var lines = new[] - { - "Log.Information(@\"User {User} with data {", - " Data: {NestedProperty}", - "} processed\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace of invalid syntax - var dataBracePos = text.IndexOf("data {") + 5; // Position on the { after "data " - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, dataBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // This should NOT work - the syntax is invalid Serilog template syntax - // The implementation correctly does not match malformed templates - Assert.Empty(tags); - } - - [Fact] - public void BraceMatching_MultiLine_ValidNestedProperty() - { - // Test a valid nested property in multi-line verbatim string - var lines = new[] - { - "_logger.LogInformation(", - " @\"Processing {EntityId}", - " with nested {NestedProperty} value\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the nested property brace (in a valid multi-line context) - var nestedBracePos = text.IndexOf("{NestedProperty"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, nestedBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // This should work - valid Serilog property in multi-line string - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == nestedBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == nestedBracePos + 15); // Closing } after "NestedProperty" - } - - [Fact] - public void BraceMatching_MultiLine_SameLine_ValidProperty() - { - // Test that properties on the same line as Log call work correctly - var text = "Log.Information(@\"User {User} logged in successfully\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the {User} brace - var userBracePos = text.IndexOf("{User"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, userBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // This should work fine - valid Serilog property - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == userBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == userBracePos + 5); // Closing } - } - - [Fact] - public void BraceMatching_MultiLine_ProperMultiLineCase() - { - // Test a case where the verbatim string truly spans multiple lines - // starting from a line that doesn't contain the Serilog call - var lines = new[] - { - "_logger.LogInformation(", - " @\"Processing {EntityId}", - " with details {Details}\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the {Details} brace on line 2 - var detailsBracePos = text.IndexOf("{Details"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, detailsBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // This should work because IsInsideMultiLineString should detect the multi-line context - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == detailsBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == detailsBracePos + 8); // Closing } - } - - [Fact] - public void BraceMatching_MultiLine_EscapedBraces() - { - // Test that escaped braces don't interfere with real brace matching in single-line context - var text = "Log.Information(@\"User {{escaped}} {RealProperty} end\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace of the real property (after escaped braces) - var realOpenBracePos = text.IndexOf("{RealProperty"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, realOpenBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // The implementation should handle escaped braces correctly - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == realOpenBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == realOpenBracePos + 13); // Closing } after "RealProperty" - } - - [Fact] - public void BraceMatching_MultiLine_EscapedBraces_TrueMultiLine() - { - // Test escaped braces in a true multi-line context - var lines = new[] - { - "_logger.LogInformation(", - " @\"User {{escaped}} here", - " Real property: {RealProperty}\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the real property brace on line 2 (in true multi-line context) - var realOpenBracePos = text.IndexOf("{RealProperty"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, realOpenBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // This should work in true multi-line context - Assert.Equal(2, tags.Count); - Assert.Contains(tags, t => t.Span.Start.Position == realOpenBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == realOpenBracePos + 13); // Closing } after "RealProperty" - } - - [Fact] - public void BraceMatching_MultiLine_MaxPropertyLengthExceeded() - { - var lines = new[] - { - "Log.Information(@\"User {", - }; - - // Add a very long property name that exceeds MaxPropertyLength (200 chars) - var longPropertyName = new string('A', 250); - lines = [.. lines, longPropertyName]; - lines = [.. lines, "} processed\")"]; - - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace - var openBracePos = text.IndexOf("User {") + 5; // Position on the { after "User " - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - // Should not find match due to exceeding MaxPropertyLength - Assert.Empty(tags); - } - - [Fact] - public void BraceMatching_MultiLine_ClosingBrace_FindsOpening() - { - var lines = new[] - { - "Log.Information(@\"User {User} with nested {", - " Property: {NestedProp}", - "} done\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the closing brace of the nested structure - var closeBracePos = text.IndexOf("} done"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openBracePos = text.IndexOf("nested {") + 7; // Position on the { after "nested " - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_MultiLine_EscapedClosingBrace() - { - var lines = new[] - { - "Log.Information(@\"User {", - " Property}} {InnerProp}", - "} done\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the closing brace (should skip the escaped }}) - var closeBracePos = text.IndexOf("} done"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openBracePos = text.IndexOf("User {") + 5; // Position on the { after "User " - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - [Fact] - public void BraceMatching_MultiLine_EscapedOpeningBrace() - { - var lines = new[] - { - "Log.Information(@\"User {{ {", - " RealProperty", - "} done\")" - }; - var text = string.Join("\n", lines); - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the closing brace - var closeBracePos = text.IndexOf("} done"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var openBracePos = text.IndexOf("{{ {") + 3; // Position on the { after "{{ " - Assert.Contains(tags, t => t.Span.Start.Position == openBracePos); - Assert.Contains(tags, t => t.Span.Start.Position == closeBracePos); - } - - #endregion - - #region FindMatchingCloseBrace and FindMatchingOpenBrace Tests - - [Fact] - public void BraceMatching_SingleLine_EscapedOpeningBrace_NoMatch() - { - var text = "Log.Information(\"Use {{braces for {Property}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the escaped opening brace (first {) - var escapedBracePos = text.IndexOf("{{"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, escapedBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // Escaped braces should not be matched - } - - [Fact] - public void BraceMatching_SingleLine_EscapedClosingBrace_NoMatch() - { - var text = "Log.Information(\"{Property} use braces}}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the escaped closing brace (second }) - var escapedBracePos = text.IndexOf("}}") + 1; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, escapedBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // Escaped braces should not be matched - } - - [Fact] - public void BraceMatching_SingleLine_NestedBraces() - { - var text = "Log.Information(\"Outer {Property} with {Inner {Nested} Property} done\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace of the outer "Inner" property - var innerOpenPos = text.IndexOf("{Inner"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, innerOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - // The actual positions from the test output: 39 and 63 - Assert.Contains(tags, t => t.Span.Start.Position == 39); - Assert.Contains(tags, t => t.Span.Start.Position == 63); - } - - [Fact] - public void BraceMatching_SingleLine_UnmatchedOpeningBrace() - { - var text = "Log.Information(\"Unclosed {Property\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the opening brace that has no matching closing brace - var openBracePos = text.IndexOf("{Property"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, openBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // No match for unmatched braces - } - - [Fact] - public void BraceMatching_SingleLine_UnmatchedClosingBrace() - { - var text = "Log.Information(\"Unmatched Property}\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the closing brace that has no matching opening brace - var closeBracePos = text.IndexOf("Property}") + 8; - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, closeBracePos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Empty(tags); // No match for unmatched braces - } - - [Fact] - public void BraceMatching_SingleLine_MixedEscapedAndReal() - { - var text = "Log.Information(\"{{escaped}} and {Real} and }}escaped{{\");"; - var buffer = new MockTextBuffer(text); - var view = new MockTextView(buffer); - var matcher = new SerilogBraceMatcher(view, buffer); - - // Position on the real property's opening brace - var realOpenPos = text.IndexOf("{Real"); - view.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, realOpenPos)); - - var tags = matcher.GetTags(new NormalizedSnapshotSpanCollection( - new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length))).ToList(); - - Assert.Equal(2, tags.Count); - var realClosePos = text.IndexOf("Real}") + 4; - Assert.Contains(tags, t => t.Span.Start.Position == realOpenPos); - Assert.Contains(tags, t => t.Span.Start.Position == realClosePos); - } - - #endregion -} \ No newline at end of file diff --git a/SerilogSyntax.Tests/TestHelpers/MockTextSnapshot.cs b/SerilogSyntax.Tests/TestHelpers/MockTextSnapshot.cs index c5cd834..21829a8 100644 --- a/SerilogSyntax.Tests/TestHelpers/MockTextSnapshot.cs +++ b/SerilogSyntax.Tests/TestHelpers/MockTextSnapshot.cs @@ -151,16 +151,16 @@ public ITextSnapshotLine GetLineFromLineNumber(int lineNumber) public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingPoint(this, position, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode) => new MockTrackingSpan(this, span, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingSpan(this, span, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => new MockTrackingSpan(this, new Span(start, length), trackingMode); /// - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingSpan(this, new Span(start, length), trackingMode); /// public void Write(TextWriter writer, Span span) => writer.Write(GetText(span)); @@ -330,16 +330,16 @@ internal class MockTextVersion(int versionNumber, INormalizedTextChangeCollectio public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingPoint(null, position, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode) => new MockTrackingSpan(null, span, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingSpan(null, span, trackingMode); /// - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => new MockTrackingSpan(null, new Span(start, length), trackingMode); /// - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => new MockTrackingSpan(null, new Span(start, length), trackingMode); /// public ITrackingSpan CreateCustomTrackingSpan(Span span, TrackingFidelityMode trackingFidelity, object customState, CustomTrackToVersion behavior) => throw new NotImplementedException(); @@ -396,4 +396,63 @@ public class MockTrackingPoint(ITextSnapshot snapshot, int position, PointTracki /// public int GetPosition(ITextVersion version) => position; +} + +/// +/// Mock implementation of ITrackingSpan for testing. +/// +/// +/// Initializes a new instance of the class. +/// +/// The text snapshot. +/// The span to track. +/// The tracking mode. +internal class MockTrackingSpan(ITextSnapshot snapshot, Span span, SpanTrackingMode trackingMode) : ITrackingSpan +{ + + /// + public ITextBuffer TextBuffer => snapshot?.TextBuffer; + + /// + public SpanTrackingMode TrackingMode => trackingMode; + + /// + public TrackingFidelityMode TrackingFidelity => TrackingFidelityMode.Forward; + + /// + public SnapshotSpan GetSpan(ITextSnapshot snapshot) + { + // For testing purposes, return the original span + // In a real implementation, this would track changes across snapshots + var start = Math.Min(span.Start, snapshot.Length); + var end = Math.Min(span.End, snapshot.Length); + var length = Math.Max(0, end - start); + return new SnapshotSpan(snapshot, start, length); + } + + /// + public Span GetSpan(ITextVersion version) + { + // For testing purposes, return the original span + return span; + } + + /// + public SnapshotPoint GetStartPoint(ITextSnapshot snapshot) + { + return GetSpan(snapshot).Start; + } + + /// + public SnapshotPoint GetEndPoint(ITextSnapshot snapshot) + { + return GetSpan(snapshot).End; + } + + /// + public string GetText(ITextSnapshot snapshot) + { + var span = GetSpan(snapshot); + return snapshot.GetText(span); + } } \ No newline at end of file diff --git a/SerilogSyntax/Classification/SerilogClassificationFormats.cs b/SerilogSyntax/Classification/SerilogClassificationFormats.cs index 8f91623..bab1157 100644 --- a/SerilogSyntax/Classification/SerilogClassificationFormats.cs +++ b/SerilogSyntax/Classification/SerilogClassificationFormats.cs @@ -1,4 +1,5 @@ using Microsoft.VisualStudio.Text.Classification; +using Microsoft.VisualStudio.Text.Formatting; using Microsoft.VisualStudio.Utilities; using System.ComponentModel.Composition; using System.Windows.Media; @@ -104,21 +105,22 @@ internal sealed class SerilogAlignmentFormat(SerilogThemeColors themeColors) } /// -/// Defines the visual format for brace matching highlight in Serilog templates. +/// Defines the visual format for property-argument highlighting in Serilog templates. /// [Export(typeof(EditorFormatDefinition))] -[Name("bracehighlight")] +[Name("PropertyArgumentHighlight")] [UserVisible(true)] -internal sealed class SerilogBraceHighlightFormat : MarkerFormatDefinition +internal sealed class PropertyArgumentHighlightFormat : MarkerFormatDefinition { - public SerilogBraceHighlightFormat() + public PropertyArgumentHighlightFormat() { - DisplayName = "Serilog Brace Highlight"; - // Don't use a solid background - just use a border for subtle highlighting - // This follows VS's built-in brace matching approach - ForegroundColor = Color.FromRgb(0x66, 0x66, 0x66); // Dark gray border + DisplayName = "Serilog Property-Argument Highlight"; + // Match VS Code's editor.wordHighlightBackground style + // In VS dark theme, word highlights typically use a subtle blue-gray + BackgroundColor = Color.FromArgb(40, 90, 90, 90); // Subtle gray background similar to word highlight + Border = null; // No border, using background fill like VS Code BackgroundCustomizable = true; - ForegroundCustomizable = true; + ForegroundCustomizable = false; ZOrder = 5; } } diff --git a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs index 86c755b..513e4d8 100644 --- a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs +++ b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs @@ -41,7 +41,7 @@ public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, { if (textBuffer == null || textView == null) return null; - + return new SerilogSuggestedActionsSource(textView); } } @@ -51,10 +51,17 @@ public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, /// internal class SerilogSuggestedActionsSource(ITextView textView) : ISuggestedActionsSource { - public event EventHandler SuggestedActionsChanged { add { } remove { } } + private EventHandler _suggestedActionsChanged; + + public event EventHandler SuggestedActionsChanged + { + add { _suggestedActionsChanged += value; } + remove { _suggestedActionsChanged -= value; } + } private readonly TemplateParser _parser = new(); + /// /// Determines whether suggested actions are available at the given location. /// @@ -70,7 +77,13 @@ public async Task HasSuggestedActionsAsync( return await Task.Run(() => { DiagnosticLogger.Log("=== HasSuggestedActionsAsync called ==="); - var triggerPoint = range.Start; + DiagnosticLogger.Log($"Range: {range.Start.Position} to {range.End.Position}"); + + // Use the midpoint of the range if it has a span, otherwise use the start + var triggerPoint = range.Length > 0 + ? new SnapshotPoint(range.Snapshot, range.Start.Position + range.Length / 2) + : range.Start; + var line = triggerPoint.GetContainingLine(); var lineText = line.GetText(); var lineStart = line.Start.Position; @@ -155,11 +168,19 @@ public async Task HasSuggestedActionsAsync( // Parse template to find properties var properties = _parser.Parse(template).ToList(); - // Find which property the cursor is on - var cursorPosInTemplate = triggerPoint.Position - templateStartPosition; - var property = properties.FirstOrDefault(p => - cursorPosInTemplate >= p.BraceStartIndex && - cursorPosInTemplate <= p.BraceEndIndex); + // Find which property to navigate to based on range, not just cursor position + // If range spans multiple properties, use the first property that intersects with the range + var rangeStartInTemplate = range.Start.Position - templateStartPosition; + var rangeEndInTemplate = range.End.Position - templateStartPosition; + + // First try to find a property that the range start intersects with + var property = properties.FirstOrDefault(p => + rangeStartInTemplate >= p.BraceStartIndex && + rangeStartInTemplate <= p.BraceEndIndex); + + // If range start doesn't hit a property, find any property that intersects with the range + property ??= properties.FirstOrDefault(p => + !(rangeEndInTemplate <= p.BraceStartIndex || rangeStartInTemplate > p.BraceEndIndex)); return property != null; }, cancellationToken); @@ -177,11 +198,44 @@ public IEnumerable GetSuggestedActions( SnapshotSpan range, CancellationToken cancellationToken) { + DiagnosticLogger.Log("=== GetSuggestedActions called ==="); + DiagnosticLogger.Log($"Range: {range.Start.Position} to {range.End.Position}"); + + // Use the actual caret position if available, otherwise use range end + // This fixes the bug where VS passes a wide range from line start and midpoint lands on wrong property var triggerPoint = range.Start; + + // Try to use the caret position if available + if (textView?.Caret != null && textView.Caret.Position.BufferPosition.Snapshot == range.Snapshot) + { + var caretPos = textView.Caret.Position.BufferPosition; + // Make sure caret is within the provided range + if (caretPos.Position >= range.Start.Position && caretPos.Position <= range.End.Position) + { + triggerPoint = caretPos; + DiagnosticLogger.Log($"Using caret position: {caretPos.Position}"); + } + else + { + // Caret is outside range, use range end as that's likely where cursor is + triggerPoint = range.End; + DiagnosticLogger.Log($"Caret outside range, using range end: {range.End.Position}"); + } + } + else + { + // No caret available (e.g., in tests), use range end as best guess + triggerPoint = range.End; + DiagnosticLogger.Log($"No caret available, using range end: {range.End.Position}"); + } + var line = triggerPoint.GetContainingLine(); var lineText = line.GetText(); var lineStart = line.Start.Position; + DiagnosticLogger.Log($"Line text: '{lineText}'"); + DiagnosticLogger.Log($"Trigger position: {triggerPoint.Position}"); + // Check if we're in a Serilog call var serilogMatch = SerilogCallDetector.FindSerilogCall(lineText); ITextSnapshotLine serilogCallLine = line; @@ -253,12 +307,27 @@ public IEnumerable GetSuggestedActions( // Parse template to find properties var properties = _parser.Parse(template).ToList(); - - // Find which property the cursor is on - var cursorPosInTemplate = triggerPoint.Position - templateStartPosition; - var property = properties.FirstOrDefault(p => - cursorPosInTemplate >= p.BraceStartIndex && - cursorPosInTemplate <= p.BraceEndIndex); + + // Find which property to navigate to based on the trigger point (cursor position) + // This fixes the bug where wide ranges would select the wrong property + var triggerPointInTemplate = triggerPoint.Position - templateStartPosition; + + DiagnosticLogger.Log($"Trigger point in template: {triggerPointInTemplate}"); + DiagnosticLogger.Log($"Template: '{template}'"); + + // Find the property that contains the trigger point + var property = properties.FirstOrDefault(p => + triggerPointInTemplate >= p.BraceStartIndex && + triggerPointInTemplate <= p.BraceEndIndex); + + if (property != null) + { + DiagnosticLogger.Log($"Found property at trigger point: {property.Name} ({property.BraceStartIndex}-{property.BraceEndIndex})"); + } + else + { + DiagnosticLogger.Log($"No property found at trigger point {triggerPointInTemplate}"); + } if (property == null) yield break; @@ -306,7 +375,17 @@ private int GetArgumentIndex(List properties, TemplateProperty { // For named properties, find their position among all named properties var namedProperties = properties.Where(p => p.Type != PropertyType.Positional).ToList(); - return namedProperties.IndexOf(targetProperty); + + // Find the index by matching the property position and name, not object reference + for (int i = 0; i < namedProperties.Count; i++) + { + if (namedProperties[i].BraceStartIndex == targetProperty.BraceStartIndex && + namedProperties[i].Name == targetProperty.Name) + { + return i; + } + } + return -1; } } @@ -934,8 +1013,9 @@ public void Dispose() public bool TryGetTelemetryId(out Guid telemetryId) { - telemetryId = Guid.Empty; - return false; + // Provide a unique telemetry ID for this actions source + telemetryId = new Guid("8F3E7A2B-9C4D-5E6F-A7B8-C9D0E1F2A3B4"); + return true; } } @@ -951,9 +1031,9 @@ internal class NavigateToArgumentAction( { internal int ArgumentStart => position; internal int ArgumentLength => length; - - public string DisplayText => propertyType == PropertyType.Positional - ? $"Navigate to argument at position {propertyName}" + + public string DisplayText => propertyType == PropertyType.Positional + ? $"Navigate to argument at position {propertyName}" : $"Navigate to '{propertyName}' argument"; public string IconAutomationText => null; @@ -980,7 +1060,7 @@ public void Invoke(CancellationToken cancellationToken) { var snapshot = textView.TextBuffer.CurrentSnapshot; var span = new SnapshotSpan(snapshot, position, length); - + textView.Caret.MoveTo(span.Start); textView.ViewScroller.EnsureSpanVisible(span); textView.Selection.Select(span, false); diff --git a/SerilogSyntax/SerilogSyntax.csproj b/SerilogSyntax/SerilogSyntax.csproj index a14ff6c..9c7bd86 100644 --- a/SerilogSyntax/SerilogSyntax.csproj +++ b/SerilogSyntax/SerilogSyntax.csproj @@ -72,10 +72,10 @@ - - - - + + + + diff --git a/SerilogSyntax/Tagging/PropertyArgumentEscCommandHandler.cs b/SerilogSyntax/Tagging/PropertyArgumentEscCommandHandler.cs new file mode 100644 index 0000000..c157d35 --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentEscCommandHandler.cs @@ -0,0 +1,56 @@ +using Microsoft.VisualStudio.Commanding; +using Microsoft.VisualStudio.Text.Editor.Commanding.Commands; +using Microsoft.VisualStudio.Utilities; +using System; +using System.ComponentModel.Composition; + +namespace SerilogSyntax.Tagging; + +/// +/// Command handler for dismissing property-argument highlights with the ESC key. +/// +[Export(typeof(ICommandHandler))] +[ContentType("CSharp")] +[Name(nameof(PropertyArgumentEscCommandHandler))] +internal sealed class PropertyArgumentEscCommandHandler : IChainedCommandHandler +{ + /// + /// Gets the display name of this command handler. + /// + public string DisplayName => "Dismiss Property-Argument Highlights"; + + /// + /// Gets the command state for the ESC key. + /// + /// The command arguments. + /// The next handler in the chain. + /// The command state. + public CommandState GetCommandState(EscapeKeyCommandArgs args, Func nextHandler) + { + // Always available - we decide whether to handle at ExecuteCommand time + return CommandState.Available; + } + + /// + /// Handles the ESC key command to dismiss property-argument highlights. + /// + /// The command arguments. + /// The next command handler in the chain. + /// The command execution context. + public void ExecuteCommand(EscapeKeyCommandArgs args, Action nextHandler, CommandExecutionContext context) + { + // Get the highlight state for this view + if (args.TextView.Properties.TryGetProperty(typeof(PropertyArgumentHighlightState), out PropertyArgumentHighlightState highlightState)) + { + // Try to dismiss highlights + if (highlightState.DisableHighlights()) + { + // We handled the ESC key by dismissing highlights + return; + } + } + + // No highlights to dismiss, pass the ESC key to the next handler + nextHandler(); + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlightState.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlightState.cs new file mode 100644 index 0000000..9037976 --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlightState.cs @@ -0,0 +1,157 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using SerilogSyntax.Diagnostics; +using System; + +namespace SerilogSyntax.Tagging; + +/// +/// Manages the state of property-argument highlights for a text view. +/// Tracks the currently highlighted property and argument spans and handles ESC key dismissal. +/// +/// +/// Initializes a new instance of the class. +/// +/// The text view this state is associated with. +internal sealed class PropertyArgumentHighlightState(ITextView textView) +{ + private ITrackingSpan _propertyTrackingSpan; + private ITrackingSpan _argumentTrackingSpan; + private bool _isDisabledByEsc; + + /// + /// Occurs when the highlight state changes. + /// + public event EventHandler StateChanged; + + /// + /// Gets a value indicating whether highlighting is currently disabled (e.g., by ESC key). + /// + public bool IsDisabled => _isDisabledByEsc; + + /// + /// Gets the current highlight spans. + /// + /// A tuple of (property span, argument span), either of which may be null. + public (SnapshotSpan? PropertySpan, SnapshotSpan? ArgumentSpan) GetHighlightSpans() + { + if (_isDisabledByEsc) + return (null, null); + + // If text view is null (in tests), we can't get a snapshot + if (textView == null) + return (null, null); + + var snapshot = textView.TextSnapshot; + SnapshotSpan? propertySpan = null; + SnapshotSpan? argumentSpan = null; + + if (_propertyTrackingSpan != null) + { + try + { + propertySpan = _propertyTrackingSpan.GetSpan(snapshot); + } + catch + { + // Tracking span is no longer valid + _propertyTrackingSpan = null; + } + } + + if (_argumentTrackingSpan != null) + { + try + { + argumentSpan = _argumentTrackingSpan.GetSpan(snapshot); + } + catch + { + // Tracking span is no longer valid + _argumentTrackingSpan = null; + } + } + + return (propertySpan, argumentSpan); + } + + /// + /// Sets the highlights for a property and its corresponding argument. + /// + /// The span of the property to highlight (optional). + /// The span of the argument to highlight (optional). + public void SetHighlights(SnapshotSpan? propertySpan, SnapshotSpan? argumentSpan) + { + DiagnosticLogger.Log($"PropertyArgumentHighlightState.SetHighlights: Property={propertySpan?.Start}-{propertySpan?.End}, Argument={argumentSpan?.Start}-{argumentSpan?.End}"); + + // Clear disabled state when setting new highlights + _isDisabledByEsc = false; + + // Create tracking spans for the new highlights + _propertyTrackingSpan = propertySpan.HasValue + ? propertySpan.Value.Snapshot.CreateTrackingSpan(propertySpan.Value, SpanTrackingMode.EdgeExclusive) + : null; + + _argumentTrackingSpan = argumentSpan.HasValue + ? argumentSpan.Value.Snapshot.CreateTrackingSpan(argumentSpan.Value, SpanTrackingMode.EdgeExclusive) + : null; + + DiagnosticLogger.Log($"PropertyArgumentHighlightState.SetHighlights: Created tracking spans, firing StateChanged event"); + OnStateChanged(); + } + + /// + /// Clears all highlights. + /// + public void ClearHighlights() + { + DiagnosticLogger.Log("PropertyArgumentHighlightState.ClearHighlights: Clearing all highlights"); + _propertyTrackingSpan = null; + _argumentTrackingSpan = null; + _isDisabledByEsc = false; + + OnStateChanged(); + } + + /// + /// Temporarily disables highlighting (called when ESC is pressed). + /// + /// True if highlights were disabled, false if there were no highlights to disable. + public bool DisableHighlights() + { + if (_propertyTrackingSpan != null || _argumentTrackingSpan != null) + { + _isDisabledByEsc = true; + OnStateChanged(); + return true; + } + + return false; + } + + /// + /// Re-enables highlighting after ESC dismissal. + /// + public void EnableHighlights() + { + if (_isDisabledByEsc) + { + _isDisabledByEsc = false; + OnStateChanged(); + } + } + + /// + /// Checks if there are currently any active highlights. + /// + /// True if there are highlights set (even if disabled), false otherwise. + public bool HasHighlights() + { + return _propertyTrackingSpan != null || _argumentTrackingSpan != null; + } + + private void OnStateChanged() + { + StateChanged?.Invoke(this, EventArgs.Empty); + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs new file mode 100644 index 0000000..a69399d --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlighter.cs @@ -0,0 +1,1372 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using SerilogSyntax.Diagnostics; +using SerilogSyntax.Parsing; +using SerilogSyntax.Utilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace SerilogSyntax.Tagging; + +/// +/// Provides property-argument highlighting for Serilog message templates. +/// Highlights both the template property and its corresponding argument when the cursor is positioned on either. +/// +internal sealed class PropertyArgumentHighlighter : ITagger +{ + private readonly ITextView _textView; + private readonly ITextBuffer _buffer; + private readonly PropertyArgumentHighlightState _highlightState; + private readonly TemplateParser _parser = new(); + private readonly LruCache> _templateCache = new(100); + + /// + /// Occurs when tags have changed. + /// + public event EventHandler TagsChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The text view. + /// The text buffer. + /// The state manager for property-argument highlights. + public PropertyArgumentHighlighter(ITextView textView, ITextBuffer buffer, PropertyArgumentHighlightState highlightState) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter: Constructor called"); + + _textView = textView ?? throw new ArgumentNullException(nameof(textView)); + _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + _highlightState = highlightState ?? throw new ArgumentNullException(nameof(highlightState)); + + // Subscribe to caret position changes + DiagnosticLogger.Log("PropertyArgumentHighlighter: Subscribing to events"); + _textView.Caret.PositionChanged += OnCaretPositionChanged; + _textView.LayoutChanged += OnLayoutChanged; + _highlightState.StateChanged += OnHighlightStateChanged; + + // Process initial caret position + DiagnosticLogger.Log($"PropertyArgumentHighlighter: Processing initial caret position {_textView.Caret.Position.BufferPosition.Position}"); + UpdateHighlights(_textView.Caret.Position); + } + + /// + /// Gets the tags that intersect the specified spans. + /// + /// The spans to get tags for. + /// The tags within the spans. + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.GetTags: Called with {spans.Count} spans"); + + if (spans.Count == 0) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.GetTags: No spans, returning empty"); + yield break; + } + + // Check if highlighting is disabled (e.g., by ESC key) + if (_highlightState.IsDisabled) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.GetTags: Highlighting disabled, returning empty"); + yield break; + } + + var snapshot = spans[0].Snapshot; + + // Get the current highlights from the state + var (propertySpan, argumentSpan) = _highlightState.GetHighlightSpans(); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.GetTags: Got highlights - Property: {(propertySpan.HasValue ? $"{propertySpan.Value.Start}-{propertySpan.Value.End}" : "null")}, Argument: {(argumentSpan.HasValue ? $"{argumentSpan.Value.Start}-{argumentSpan.Value.End}" : "null")}"); + + if (propertySpan.HasValue && propertySpan.Value.Snapshot == snapshot) + { + // Translate to current snapshot if needed + var currentPropertySpan = propertySpan.Value.TranslateTo(snapshot, SpanTrackingMode.EdgeExclusive); + if (spans.Any(s => s.OverlapsWith(currentPropertySpan))) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.GetTags: Returning property tag at {currentPropertySpan.Start}-{currentPropertySpan.End}"); + yield return new TagSpan(currentPropertySpan, new TextMarkerTag("PropertyArgumentHighlight")); + } + } + + if (argumentSpan.HasValue && argumentSpan.Value.Snapshot == snapshot) + { + // Translate to current snapshot if needed + var currentArgumentSpan = argumentSpan.Value.TranslateTo(snapshot, SpanTrackingMode.EdgeExclusive); + if (spans.Any(s => s.OverlapsWith(currentArgumentSpan))) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.GetTags: Returning argument tag at {currentArgumentSpan.Start}-{currentArgumentSpan.End}"); + yield return new TagSpan(currentArgumentSpan, new TextMarkerTag("PropertyArgumentHighlight")); + } + } + + DiagnosticLogger.Log("PropertyArgumentHighlighter.GetTags: Finished"); + } + + private void OnCaretPositionChanged(object sender, CaretPositionChangedEventArgs e) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.OnCaretPositionChanged: Caret moved to position {e.NewPosition.BufferPosition.Position}"); + + // If the caret actually moved to a different position, re-enable highlights + if (_highlightState.IsDisabled && e.OldPosition.BufferPosition != e.NewPosition.BufferPosition) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.OnCaretPositionChanged: Re-enabling highlights after caret movement"); + _highlightState.EnableHighlights(); + } + + UpdateHighlights(e.NewPosition); + } + + private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + if (e.NewSnapshot != e.OldSnapshot) + { + // Clear cache for changed lines + _templateCache.Clear(); + } + + // Update highlights with current caret position + UpdateHighlights(_textView.Caret.Position); + } + + private void OnHighlightStateChanged(object sender, EventArgs e) + { + // Refresh all tags when the state changes + var snapshot = _buffer.CurrentSnapshot; + var span = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(span)); + } + + private void UpdateHighlights(CaretPosition caretPosition) + { + try + { + // If highlights are disabled by ESC, don't update them + if (_highlightState.IsDisabled) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.UpdateHighlights: Highlights disabled by ESC, skipping update"); + return; + } + + var position = caretPosition.BufferPosition; + var line = position.GetContainingLine(); + var lineText = line.GetText(); + var lineStart = line.Start.Position; + var positionInLine = position.Position - lineStart; + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Position {position.Position}, Line {line.LineNumber}"); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Line text: '{lineText}'"); + + // Check if we're in a Serilog call + var serilogMatch = SerilogCallDetector.FindSerilogCall(lineText); + ITextSnapshotLine serilogCallLine = line; + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Serilog match found: {serilogMatch != null}"); + + if (serilogMatch == null) + { + // Check if we're inside a multi-line template + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: No match on current line, searching previous lines"); + var multiLineResult = FindSerilogCallInPreviousLines(position.Snapshot, line); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Multi-line search result: {multiLineResult != null}"); + if (multiLineResult == null) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.UpdateHighlights: No Serilog call found, clearing highlights"); + _highlightState.ClearHighlights(); + return; + } + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Found Serilog call on line {multiLineResult.Value.Line.LineNumber}"); + serilogMatch = multiLineResult.Value.Match; + serilogCallLine = multiLineResult.Value.Line; + } + + // Find the template string + if (!ExtractTemplate(position.Snapshot, serilogCallLine, line, serilogMatch, out string template, out int templateStartPosition, out int templateEndPosition, out bool hasExceptionParameter)) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.UpdateHighlights: Failed to extract template"); + _highlightState.ClearHighlights(); + return; + } + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Extracted template: '{template}' at positions {templateStartPosition}-{templateEndPosition}, hasException={hasExceptionParameter}"); + + // Parse template properties + var properties = GetParsedTemplate(template); + if (properties == null || properties.Count == 0) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.UpdateHighlights: No properties found in template"); + _highlightState.ClearHighlights(); + return; + } + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Found {properties.Count} properties in template"); + + // Check if cursor is on a property in the template + var cursorPosInTemplate = position.Position - templateStartPosition; + var propertyAtCursor = properties.FirstOrDefault(p => + cursorPosInTemplate >= p.BraceStartIndex && + cursorPosInTemplate <= p.BraceEndIndex); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Cursor position in template: {cursorPosInTemplate}, Property at cursor: {propertyAtCursor?.Name ?? "null"}"); + + if (propertyAtCursor != null) + { + // Cursor is on a property - highlight it and its argument + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Highlighting property '{propertyAtCursor.Name}' and its argument"); + HighlightPropertyAndArgument(position.Snapshot, propertyAtCursor, properties, templateStartPosition, templateEndPosition, hasExceptionParameter); + return; + } + + // Check if cursor is on an argument + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Checking for argument at position {position.Position}, template ends at {templateEndPosition}, hasException={hasExceptionParameter}"); + var argumentInfo = FindArgumentAtPosition(position.Snapshot, templateEndPosition, position.Position, properties.Count); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Argument search result: {argumentInfo.HasValue}"); + + if (argumentInfo.HasValue) + { + var (argumentIndex, argumentStart, argumentLength) = argumentInfo.Value; + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Found argument at index {argumentIndex}, position {argumentStart}, length {argumentLength}"); + if (argumentIndex < properties.Count) + { + // Cursor is on an argument - highlight it and its property + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Highlighting argument at index {argumentIndex} and its property"); + HighlightArgumentAndProperty(position.Snapshot, argumentIndex, argumentStart, argumentLength, properties, templateStartPosition, hasExceptionParameter); + return; + } + else + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: Argument index {argumentIndex} >= properties count {properties.Count}"); + } + } + else + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.UpdateHighlights: No argument found at cursor position {position.Position}"); + } + + DiagnosticLogger.Log("PropertyArgumentHighlighter.UpdateHighlights: No property or argument at cursor, clearing highlights"); + _highlightState.ClearHighlights(); + } + catch (Exception ex) + { + DiagnosticLogger.Log($"Error in UpdateHighlights: {ex}"); + _highlightState.ClearHighlights(); + } + } + + private void HighlightPropertyAndArgument(ITextSnapshot snapshot, TemplateProperty property, List allProperties, int templateStartPosition, int templateEndPosition, bool hasExceptionParameter = false) + { + // Calculate property span + var propertyStart = templateStartPosition + property.BraceStartIndex; + var propertyEnd = templateStartPosition + property.BraceEndIndex + 1; // Include closing brace + var propertySpan = new SnapshotSpan(snapshot, propertyStart, propertyEnd - propertyStart); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightPropertyAndArgument: Property span: {propertyStart}-{propertyEnd}"); + + // Find the corresponding argument + var argumentIndex = GetArgumentIndex(allProperties, property); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightPropertyAndArgument: Argument index: {argumentIndex}, hasException: {hasExceptionParameter}"); + + if (argumentIndex >= 0) + { + var argumentLocation = FindArgumentInMultiLineCall(snapshot, templateEndPosition, argumentIndex); + if (argumentLocation.HasValue) + { + var (argStart, argLength) = argumentLocation.Value; + var argumentSpan = new SnapshotSpan(snapshot, argStart, argLength); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightPropertyAndArgument: Setting highlights - Property: {propertyStart}-{propertyEnd}, Argument: {argStart}-{argStart + argLength}"); + _highlightState.SetHighlights(propertySpan, argumentSpan); + return; + } + else + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightPropertyAndArgument: Could not find argument at index {argumentIndex}"); + } + } + + // Only highlight the property if we can't find the argument + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightPropertyAndArgument: Setting property-only highlight: {propertyStart}-{propertyEnd}"); + _highlightState.SetHighlights(propertySpan, null); + } + + private void HighlightArgumentAndProperty(ITextSnapshot snapshot, int argumentIndex, int argumentStart, int argumentLength, List properties, int templateStartPosition, bool hasExceptionParameter = false) + { + // Calculate argument span + var argumentSpan = new SnapshotSpan(snapshot, argumentStart, argumentLength); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.HighlightArgumentAndProperty: Argument at index {argumentIndex}, hasException: {hasExceptionParameter}"); + + // Find the corresponding property + TemplateProperty property = null; + + // Check if this is a positional argument + var positionalProperties = properties.Where(p => p.Type == PropertyType.Positional).ToList(); + if (positionalProperties.Any()) + { + // Try to find a positional property with matching index + property = positionalProperties.FirstOrDefault(p => int.TryParse(p.Name, out int idx) && idx == argumentIndex); + } + + if (property == null) + { + // For named properties, find by position among non-positional properties + var namedProperties = properties.Where(p => p.Type != PropertyType.Positional).ToList(); + if (argumentIndex < namedProperties.Count) + { + property = namedProperties[argumentIndex]; + } + } + + if (property != null) + { + var propertyStart = templateStartPosition + property.BraceStartIndex; + var propertyEnd = templateStartPosition + property.BraceEndIndex + 1; // Include closing brace + var propertySpan = new SnapshotSpan(snapshot, propertyStart, propertyEnd - propertyStart); + + _highlightState.SetHighlights(propertySpan, argumentSpan); + } + else + { + // Only highlight the argument if we can't find the property + _highlightState.SetHighlights(null, argumentSpan); + } + } + + private bool ExtractTemplate(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine, Match serilogMatch, out string template, out int templateStartPosition, out int templateEndPosition, out bool hasExceptionParameter) + { + template = null; + templateStartPosition = 0; + templateEndPosition = 0; + hasExceptionParameter = false; + + if (serilogCallLine == currentLine) + { + var lineText = serilogCallLine.GetText(); + var lineStart = serilogCallLine.Start.Position; + var templateMatch = FindTemplateString(lineText, serilogMatch.Index + serilogMatch.Length); + + if (!templateMatch.HasValue) + { + // Check for multi-line template + var multiLineTemplate = ReconstructMultiLineTemplate(snapshot, serilogCallLine, currentLine); + if (multiLineTemplate == null) + return false; + + template = multiLineTemplate.Value.Template; + templateStartPosition = multiLineTemplate.Value.StartPosition; + templateEndPosition = multiLineTemplate.Value.EndPosition; + hasExceptionParameter = multiLineTemplate.Value.HasException; + return true; + } + + var (templateStart, templateEnd) = templateMatch.Value; + template = lineText.Substring(templateStart, templateEnd - templateStart); + templateStartPosition = lineStart + templateStart; + templateEndPosition = lineStart + templateEnd; + return true; + } + else + { + // Multi-line scenario + var multiLineTemplate = ReconstructMultiLineTemplate(snapshot, serilogCallLine, currentLine); + if (multiLineTemplate == null) + return false; + + template = multiLineTemplate.Value.Template; + templateStartPosition = multiLineTemplate.Value.StartPosition; + templateEndPosition = multiLineTemplate.Value.EndPosition; + hasExceptionParameter = multiLineTemplate.Value.HasException; + return true; + } + } + + private List GetParsedTemplate(string template) + { + if (string.IsNullOrEmpty(template)) + return null; + + // Try to get from cache first + if (_templateCache.TryGetValue(template, out var cached)) + return cached; + + // Parse and cache + var properties = _parser.Parse(template).ToList(); + _templateCache.Add(template, properties); + return properties; + } + + private int GetArgumentIndex(List properties, TemplateProperty targetProperty) + { + if (targetProperty.Type == PropertyType.Positional) + { + // For positional properties, parse the index from the property name + if (int.TryParse(targetProperty.Name, out int index)) + return index; + return -1; + } + else + { + // For named properties, find their position among all named properties + var namedProperties = properties.Where(p => p.Type != PropertyType.Positional).ToList(); + return namedProperties.IndexOf(targetProperty); + } + } + + private (int Index, int Start, int Length)? FindArgumentAtPosition(ITextSnapshot snapshot, int templateEndPosition, int cursorPosition, int propertyCount) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindArgumentAtPosition: Looking for argument at cursor position {cursorPosition}, template ends at {templateEndPosition}"); + + // Start searching from the template end position + var templateEndLine = snapshot.GetLineFromPosition(templateEndPosition); + var allArguments = new List<(int absolutePosition, int length)>(); + + // Parse arguments starting from the template end line + var templateEndLineText = templateEndLine.GetText(); + var templateEndInLine = templateEndPosition - templateEndLine.Start.Position; + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindArgumentAtPosition: Template end line text: '{templateEndLineText}', end position in line: {templateEndInLine}"); + + if (templateEndInLine < templateEndLineText.Length) + { + var commaIndex = templateEndLineText.IndexOf(',', templateEndInLine); + if (commaIndex >= 0) + { + var endLineArguments = ParseArguments(templateEndLineText, commaIndex + 1); + foreach (var (start, length) in endLineArguments) + { + var absStart = templateEndLine.Start.Position + start; + var absEnd = absStart + length; + + if (cursorPosition >= absStart && cursorPosition <= absEnd) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindArgumentAtPosition: Found cursor in argument {allArguments.Count} at {absStart}-{absEnd}"); + // Found the argument containing the cursor + return (allArguments.Count, absStart, length); + } + + allArguments.Add((absStart, length)); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindArgumentAtPosition: Added argument {allArguments.Count - 1} at {absStart}, length {length}"); + } + } + } + + // Continue searching subsequent lines + for (int lineNum = templateEndLine.LineNumber + 1; lineNum < snapshot.LineCount && allArguments.Count < propertyCount; lineNum++) + { + var nextLine = snapshot.GetLineFromLineNumber(lineNum); + var originalLineText = nextLine.GetText(); + var nextLineText = originalLineText.TrimStart(); + + if (string.IsNullOrEmpty(nextLineText)) + continue; + + var trimOffset = originalLineText.Length - nextLineText.Length; + + var closingParenIndex = nextLineText.IndexOf(");"); + if (closingParenIndex >= 0) + { + var finalLineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in finalLineArguments) + { + if (start < closingParenIndex) + { + var absStart = nextLine.Start.Position + trimOffset + start; + var absEnd = absStart + length; + + if (cursorPosition >= absStart && cursorPosition <= absEnd) + { + return (allArguments.Count, absStart, length); + } + + allArguments.Add((absStart, length)); + } + } + break; + } + else + { + var lineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in lineArguments) + { + var absStart = nextLine.Start.Position + trimOffset + start; + var absEnd = absStart + length; + + if (cursorPosition >= absStart && cursorPosition <= absEnd) + { + return (allArguments.Count, absStart, length); + } + + allArguments.Add((absStart, length)); + } + } + } + + return null; + } + + // Helper methods borrowed from SerilogNavigationProvider + private (Match Match, ITextSnapshotLine Line)? FindSerilogCallInPreviousLines(ITextSnapshot snapshot, ITextSnapshotLine currentLine) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: Starting search from line {currentLine.LineNumber}"); + + // Increase search range to 20 lines for long multi-line strings + for (int i = currentLine.LineNumber - 1; i >= Math.Max(0, currentLine.LineNumber - 20); i--) + { + var checkLine = snapshot.GetLineFromLineNumber(i); + var checkText = checkLine.GetText(); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: Checking line {i}: '{checkText.Substring(0, Math.Min(50, checkText.Length))}'..."); + + var match = SerilogCallDetector.FindSerilogCall(checkText); + if (match != null) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: Found Serilog call on line {i}"); + + // Check if we're either inside the template string OR in the arguments section + var isInside = IsInsideMultiLineTemplate(snapshot, checkLine, currentLine); + var isInArguments = !isInside && IsInArgumentsSection(snapshot, checkLine, currentLine); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: IsInsideMultiLineTemplate = {isInside}, IsInArgumentsSection = {isInArguments}"); + + if (isInside || isInArguments) + { + return (match, checkLine); + } + else + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: Not inside template or arguments, continuing search"); + } + } + } + + DiagnosticLogger.Log("PropertyArgumentHighlighter.FindSerilogCallInPreviousLines: No Serilog call found in range"); + return null; + } + + private bool IsInArgumentsSection(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine) + { + // This method checks if we're in the arguments section of a multi-line Serilog call + // (after the template string but before the closing parenthesis) + + int parenDepth = 0; + bool passedTemplate = false; + bool inString = false; + bool inVerbatimString = false; + bool inRawString = false; + int rawStringQuoteCount = 0; + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInArgumentsSection: Checking from line {serilogCallLine.LineNumber} to {currentLine.LineNumber}"); + + for (int lineNum = serilogCallLine.LineNumber; lineNum <= currentLine.LineNumber; lineNum++) + { + var line = snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + + int startIndex = 0; + if (lineNum == serilogCallLine.LineNumber) + { + var match = SerilogCallDetector.FindSerilogCall(lineText); + if (match != null) + { + // The match includes the opening paren + parenDepth = 1; + startIndex = match.Index + match.Length; + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInArgumentsSection: Starting after Serilog call at index {startIndex}"); + } + } + + for (int i = startIndex; i < lineText.Length; i++) + { + char c = lineText[i]; + + // Track string states + if (!inString && !inVerbatimString && !inRawString) + { + if (c == '(') + { + parenDepth++; + } + else if (c == ')') + { + parenDepth--; + if (parenDepth == 0) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInArgumentsSection: Method call closed at line {lineNum}"); + return lineNum == currentLine.LineNumber && i > 0; // We're in arguments if we're on the closing line before the paren + } + } + else if (c == ',') + { + // We've seen a comma outside of strings - we've passed the template + passedTemplate = true; + } + else if (c == '"' && !inString && !inVerbatimString && !inRawString) + { + // Count consecutive quotes for raw string literals + int quoteCount = 1; + while (i + quoteCount < lineText.Length && lineText[i + quoteCount] == '"') + { + quoteCount++; + } + + if (quoteCount >= 3) + { + inRawString = true; + rawStringQuoteCount = quoteCount; + i += quoteCount - 1; // -1 because loop will increment + } + else + { + inString = true; + } + } + else if (i + 1 < lineText.Length && c == '@' && lineText[i + 1] == '"') + { + inVerbatimString = true; + i++; + } + } + else if (inString) + { + if (c == '\\' && i + 1 < lineText.Length) + { + i++; // Skip escaped character + } + else if (c == '"') + { + inString = false; + if (!passedTemplate && lineNum == serilogCallLine.LineNumber) + { + // We just closed the template string on the Serilog call line + passedTemplate = true; + } + } + } + else if (inVerbatimString) + { + if (c == '"') + { + if (i + 1 < lineText.Length && lineText[i + 1] == '"') + { + i++; // Skip escaped quote + } + else + { + inVerbatimString = false; + if (!passedTemplate) + { + passedTemplate = true; + } + } + } + } + else if (inRawString) + { + // Check for closing raw string quotes + bool foundClosing = true; + for (int j = 0; j < rawStringQuoteCount && i + j < lineText.Length; j++) + { + if (lineText[i + j] != '"') + { + foundClosing = false; + break; + } + } + if (foundClosing) + { + inRawString = false; + i += rawStringQuoteCount - 1; + if (!passedTemplate) + { + passedTemplate = true; + } + } + } + } + + // If we're at the current line and we've passed the template and are still in the method call + if (lineNum == currentLine.LineNumber && passedTemplate && parenDepth > 0) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInArgumentsSection: At current line, passed template, in method call"); + return true; + } + } + + return false; + } + + private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine) + { + bool inString = false; + bool inVerbatimString = false; + bool inRawString = false; + int rawStringQuoteCount = 0; + bool foundOpenParen = false; + int parenDepth = 0; + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: Checking from line {serilogCallLine.LineNumber} to {currentLine.LineNumber}"); + + // Extended range to handle cases where verbatim string continues beyond current line + int endLine = Math.Min(currentLine.LineNumber + 5, snapshot.LineCount - 1); + for (int lineNum = serilogCallLine.LineNumber; lineNum <= endLine; lineNum++) + { + var line = snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: Line {lineNum}: '{lineText}'"); + + // If we're on the Serilog call line, process the entire line but track that we found the opening paren + int startIndex = 0; + if (lineNum == serilogCallLine.LineNumber) + { + var match = SerilogCallDetector.FindSerilogCall(lineText); + if (match != null) + { + // The match includes the opening paren, so we know we're in a method call + foundOpenParen = true; + parenDepth = 1; // We've seen the opening paren in the match + startIndex = match.Index + match.Length; + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: On Serilog line, match includes opening paren, starting at index {startIndex} of '{lineText}'"); + if (startIndex < lineText.Length) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: First char after match: '{lineText[startIndex]}'"); + } + } + } + + for (int i = startIndex; i < lineText.Length; i++) + { + char c = lineText[i]; + + if (!inString && !inVerbatimString && !inRawString) + { + // Track parentheses to know we're in a method call + if (c == '(') + { + parenDepth++; + } + else if (c == ')') + { + parenDepth--; + if (parenDepth == 0) + { + // We've closed the method call, not in template anymore + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: Closed method call at line {lineNum}"); + return false; + } + } + else if (c == '"') + { + // Count consecutive quotes for raw string literals + int quoteCount = 1; + while (i + quoteCount < lineText.Length && lineText[i + quoteCount] == '"') + { + quoteCount++; + } + + if (quoteCount >= 3) + { + inRawString = true; + rawStringQuoteCount = quoteCount; + i += quoteCount - 1; // -1 because loop will increment + continue; + } + else + { + inString = true; + continue; + } + } + else if (i + 1 < lineText.Length && c == '@' && lineText[i + 1] == '"') + { + inVerbatimString = true; + i++; + continue; + } + } + else if (inRawString) + { + if (c == '"') + { + int consecutiveQuotes = 1; + while (i + consecutiveQuotes < lineText.Length && lineText[i + consecutiveQuotes] == '"') + consecutiveQuotes++; + + if (consecutiveQuotes >= rawStringQuoteCount) + { + inRawString = false; + i += consecutiveQuotes - 1; + } + } + } + else if (inVerbatimString) + { + if (c == '"') + { + if (i + 1 < lineText.Length && lineText[i + 1] == '"') + { + i++; + } + else + { + inVerbatimString = false; + } + } + } + else if (inString) + { + if (c == '\\' && i + 1 < lineText.Length) + { + i++; + } + else if (c == '"') + { + inString = false; + } + } + } + + // After processing the line, check if we're at the current line + if (lineNum == currentLine.LineNumber) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: At current line {lineNum}, inString={inString}, inVerbatim={inVerbatimString}, inRaw={inRawString}, foundParen={foundOpenParen}, parenDepth={parenDepth}"); + + // We're at the current line - check if we're in a string or if we have an open paren and could be in a template + if (inString || inVerbatimString || inRawString) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: At current line, in string: {inString}, in verbatim: {inVerbatimString}, in raw: {inRawString}"); + return true; + } + else if (foundOpenParen && parenDepth > 0) + { + // We're still inside the method call, could be on a template line + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: At current line, inside method call with paren depth: {parenDepth}"); + return true; + } + } + } + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.IsInsideMultiLineTemplate: Reached end, not inside template"); + return false; + } + + private (string Template, int StartPosition, int EndPosition, bool HasException)? ReconstructMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Starting from line {serilogCallLine.LineNumber} to {currentLine.LineNumber}"); + + var templateBuilder = new System.Text.StringBuilder(); + int templateStartPosition = -1; + + bool foundTemplateStart = false; + bool inString = false; + bool inVerbatimString = false; + bool inRawString = false; + int rawStringQuoteCount = 0; + bool hasExceptionParameter = false; + + // Start looking from the Serilog call line and go beyond current line to find template end + for (int lineNum = serilogCallLine.LineNumber; lineNum <= Math.Min(currentLine.LineNumber + 20, snapshot.LineCount - 1); lineNum++) + { + var line = snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Processing line {lineNum}: '{lineText}'"); + + // Special handling for Serilog call line - skip to after the method name and opening paren + int startIndex = 0; + if (lineNum == serilogCallLine.LineNumber && !foundTemplateStart) + { + var match = SerilogCallDetector.FindSerilogCall(lineText); + if (match != null) + { + startIndex = match.Index + match.Length; + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Serilog call line, starting at index {startIndex}"); + + // Check if this is LogError with an exception parameter + if (lineText.Contains("LogError") && HasExceptionParameterBeforeTemplate(lineText, startIndex)) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.ReconstructMultiLineTemplate: LogError with exception parameter detected, skipping to next parameter"); + hasExceptionParameter = true; + + // Skip past the exception parameter to find the template + int parenDepth = 1; + while (startIndex < lineText.Length) + { + char c = lineText[startIndex]; + if (c == '(') parenDepth++; + else if (c == ')') + { + parenDepth--; + if (parenDepth == 0) break; // End of call + } + else if (c == ',' && parenDepth == 1) + { + startIndex++; // Move past the comma + // Skip whitespace after comma + while (startIndex < lineText.Length && char.IsWhiteSpace(lineText[startIndex])) + { + startIndex++; + } + break; + } + startIndex++; + } + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: After skipping exception, starting at index {startIndex}"); + + // If we reached end of line after skipping exception, continue to next line + if (startIndex >= lineText.Length) + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Reached end of line after exception, continuing to next line"); + continue; + } + } + } + } + + for (int i = startIndex; i < lineText.Length; i++) + { + char c = lineText[i]; + int absolutePosition = line.Start.Position + i; + + if (!foundTemplateStart && !inString && !inVerbatimString && !inRawString) + { + // Check for raw string literals (3+ quotes) + if (c == '"') + { + int quoteCount = 1; + while (i + quoteCount < lineText.Length && lineText[i + quoteCount] == '"') + { + quoteCount++; + } + + if (quoteCount >= 3) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Found raw string start with {quoteCount} quotes at position {i}"); + foundTemplateStart = true; + inRawString = true; + rawStringQuoteCount = quoteCount; + templateStartPosition = absolutePosition + quoteCount; + i += quoteCount - 1; // -1 because loop will increment + continue; + } + else if (quoteCount == 1) + { + // Single quote - regular string + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Found regular string start at position {i}"); + foundTemplateStart = true; + inString = true; + templateStartPosition = absolutePosition + 1; + continue; + } + } + + if (i + 1 < lineText.Length && c == '@' && lineText[i + 1] == '"') + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Found verbatim string start at position {i}"); + foundTemplateStart = true; + inVerbatimString = true; + templateStartPosition = absolutePosition + 2; + i++; + continue; + } + } + else if (foundTemplateStart) + { + if (inRawString) + { + if (c == '"') + { + int consecutiveQuotes = 1; + while (i + consecutiveQuotes < lineText.Length && lineText[i + consecutiveQuotes] == '"') + consecutiveQuotes++; + + if (consecutiveQuotes >= rawStringQuoteCount) + { + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Found raw string end with {consecutiveQuotes} quotes (needed {rawStringQuoteCount})"); + return (templateBuilder.ToString(), templateStartPosition, absolutePosition, hasExceptionParameter); + } + else + { + // Add all the quotes we found to the template + for (int q = 0; q < consecutiveQuotes; q++) + { + templateBuilder.Append('"'); + } + i += consecutiveQuotes - 1; // Skip the quotes we already processed + } + } + else + { + templateBuilder.Append(c); + } + } + else if (inVerbatimString) + { + if (c == '"') + { + if (i + 1 < lineText.Length && lineText[i + 1] == '"') + { + templateBuilder.Append("\"\""); + i++; + } + else + { + return (templateBuilder.ToString(), templateStartPosition, absolutePosition, hasExceptionParameter); + } + } + else + { + templateBuilder.Append(c); + } + } + else if (inString) + { + if (c == '\\' && i + 1 < lineText.Length) + { + templateBuilder.Append(c); + i++; + if (i < lineText.Length) + templateBuilder.Append(lineText[i]); + } + else if (c == '"') + { + return (templateBuilder.ToString(), templateStartPosition, absolutePosition, hasExceptionParameter); + } + else + { + templateBuilder.Append(c); + } + } + } + } + + if (foundTemplateStart && (inVerbatimString || inRawString) && lineNum < snapshot.LineCount - 1) + { + var nextLine = snapshot.GetLineFromLineNumber(lineNum + 1); + if (lineNum + 1 <= Math.Min(snapshot.LineCount - 1, serilogCallLine.LineNumber + 20)) + { + var lineBreakStart = line.End.Position; + var lineBreakEnd = nextLine.Start.Position; + if (lineBreakEnd > lineBreakStart) + { + var lineBreakText = snapshot.GetText(lineBreakStart, lineBreakEnd - lineBreakStart); + templateBuilder.Append(lineBreakText); + } + } + } + } + + DiagnosticLogger.Log($"PropertyArgumentHighlighter.ReconstructMultiLineTemplate: Reached end without finding template close, foundStart={foundTemplateStart}, template length={templateBuilder.Length}"); + return null; + } + + private (int, int)? FindTemplateString(string line, int startIndex) + { + bool hasExceptionParam = line.Contains("LogError") && HasExceptionParameterBeforeTemplate(line, startIndex); + + int searchPos = startIndex; + if (hasExceptionParam) + { + int parenDepth = 1; + while (searchPos < line.Length && parenDepth > 0) + { + char c = line[searchPos]; + if (c == '(') parenDepth++; + else if (c == ')') parenDepth--; + else if (c == ',' && parenDepth == 1) + { + searchPos++; + break; + } + searchPos++; + } + } + + for (int i = searchPos; i < line.Length; i++) + { + if (char.IsWhiteSpace(line[i])) + continue; + + // Check for raw string literal (""") + if (i + 2 < line.Length && line.Substring(i, 3) == "\"\"\"") + { + // Raw string literal - need to handle multi-line + // For single line test, just return empty since it spans lines + // The ReconstructMultiLineTemplate will handle it + return null; + } + else if (line[i] == '"') + { + int end = i + 1; + while (end < line.Length && line[end] != '"') + { + if (line[end] == '\\') + end++; + end++; + } + + if (end < line.Length) + return (i + 1, end); + } + else if (i + 1 < line.Length && line[i] == '@' && line[i + 1] == '"') + { + int end = i + 2; + while (end < line.Length) + { + if (line[end] == '"') + { + if (end + 1 < line.Length && line[end + 1] == '"') + { + end += 2; + continue; + } + + return (i + 2, end); + } + + end++; + } + } + else + { + break; + } + } + return null; + } + + private bool HasExceptionParameterBeforeTemplate(string line, int searchStart) + { + // LogError can have (Exception, template, ...) or just (template, ...) + // We need to detect if the first parameter is an Exception object + // Common patterns: + // - new Exception(...) + // - ex (variable) + // - null + + int pos = searchStart; + + // Skip whitespace + while (pos < line.Length && char.IsWhiteSpace(line[pos])) + pos++; + + if (pos >= line.Length) + return false; + + // Check if the first parameter starts with "new " (exception constructor) + if (pos + 4 < line.Length && line.Substring(pos, 4) == "new ") + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.HasExceptionParameterBeforeTemplate: Found 'new' keyword, likely exception parameter"); + return true; + } + + // Check if it's not a string (template would start with " or @" or """) + if (line[pos] == '"') + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.HasExceptionParameterBeforeTemplate: First parameter is a string, not an exception"); + return false; + } + + if (pos + 1 < line.Length && line[pos] == '@' && line[pos + 1] == '"') + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.HasExceptionParameterBeforeTemplate: First parameter is a verbatim string, not an exception"); + return false; + } + + // If it's an identifier (like 'ex' or 'null'), it's likely an exception parameter + if (char.IsLetter(line[pos]) || line[pos] == 'n') + { + DiagnosticLogger.Log("PropertyArgumentHighlighter.HasExceptionParameterBeforeTemplate: First parameter is an identifier, likely exception parameter"); + return true; + } + + return false; + } + + private (int, int)? FindArgumentInMultiLineCall(ITextSnapshot snapshot, int templateEndPosition, int argumentIndex) + { + var allArguments = new List<(int absolutePosition, int length)>(); + + var templateEndLine = snapshot.GetLineFromPosition(templateEndPosition); + var templateEndLineText = templateEndLine.GetText(); + var templateEndInLine = templateEndPosition - templateEndLine.Start.Position; + + if (templateEndInLine < templateEndLineText.Length) + { + var commaIndex = templateEndLineText.IndexOf(',', templateEndInLine); + if (commaIndex >= 0) + { + var endLineArguments = ParseArguments(templateEndLineText, commaIndex + 1); + foreach (var (start, length) in endLineArguments) + { + allArguments.Add((templateEndLine.Start.Position + start, length)); + } + } + } + + for (int lineNum = templateEndLine.LineNumber + 1; lineNum < snapshot.LineCount; lineNum++) + { + var nextLine = snapshot.GetLineFromLineNumber(lineNum); + var originalLineText = nextLine.GetText(); + var nextLineText = originalLineText.TrimStart(); + + if (string.IsNullOrEmpty(nextLineText)) + continue; + + var trimOffset = originalLineText.Length - nextLineText.Length; + + var closingParenIndex = nextLineText.IndexOf(");"); + if (closingParenIndex >= 0) + { + var finalLineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in finalLineArguments) + { + if (start < closingParenIndex) + { + allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); + } + } + break; + } + else + { + var lineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in lineArguments) + { + allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); + } + } + } + + if (argumentIndex < allArguments.Count) + { + return allArguments[argumentIndex]; + } + + return null; + } + + private List<(int start, int length)> ParseArguments(string line, int startIndex) + { + var arguments = new List<(int start, int length)>(); + var current = startIndex; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inString = false; + var stringChar = '\0'; + + while (current < line.Length && char.IsWhiteSpace(line[current])) + current++; + var argumentStart = current; + + for (; current < line.Length; current++) + { + var c = line[current]; + + if (!inString && (c == '"' || c == '\'')) + { + inString = true; + stringChar = c; + continue; + } + else if (inString && c == stringChar) + { + if (current > 0 && line[current - 1] != '\\') + { + inString = false; + } + + continue; + } + else if (inString) + { + continue; + } + + switch (c) + { + case '(': + parenDepth++; + break; + + case ')': + parenDepth--; + + if (parenDepth < 0) + { + if (current > argumentStart) + { + var argText = line.Substring(argumentStart, current - argumentStart).Trim(); + if (!string.IsNullOrEmpty(argText)) + { + arguments.Add((argumentStart, argText.Length)); + } + } + + return arguments; + } + + break; + + case '[': + bracketDepth++; + break; + + case ']': + bracketDepth--; + break; + + case '{': + braceDepth++; + break; + + case '}': + braceDepth--; + break; + + case ',': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + { + var argText = line.Substring(argumentStart, current - argumentStart).Trim(); + if (!string.IsNullOrEmpty(argText)) + { + arguments.Add((argumentStart, argText.Length)); + } + + current++; + while (current < line.Length && char.IsWhiteSpace(line[current])) + current++; + argumentStart = current; + current--; + } + + break; + } + } + + if (argumentStart < current) + { + var argText = line.Substring(argumentStart, current - argumentStart).Trim(); + if (!string.IsNullOrEmpty(argText)) + { + arguments.Add((argumentStart, argText.Length)); + } + } + + return arguments; + } + + /// + /// Disposes the tagger. + /// + public void Dispose() + { + _textView.Caret.PositionChanged -= OnCaretPositionChanged; + _textView.LayoutChanged -= OnLayoutChanged; + _highlightState.StateChanged -= OnHighlightStateChanged; + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs b/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs new file mode 100644 index 0000000..b735abb --- /dev/null +++ b/SerilogSyntax/Tagging/PropertyArgumentHighlighterProvider.cs @@ -0,0 +1,50 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using SerilogSyntax.Diagnostics; +using System.ComponentModel.Composition; + +namespace SerilogSyntax.Tagging; + +/// +/// Provides property-argument highlighter taggers for C# text views. +/// +[Export(typeof(IViewTaggerProvider))] +[ContentType("CSharp")] +[TagType(typeof(TextMarkerTag))] +internal sealed class PropertyArgumentHighlighterProvider : IViewTaggerProvider +{ + /// + /// Creates a tagger for property-argument highlighting in the specified text view and buffer. + /// + /// The type of tag. + /// The text view. + /// The text buffer. + /// A property-argument highlighter tagger, or null if the tag type is not supported. + public ITagger CreateTagger(ITextView textView, ITextBuffer buffer) where T : ITag + { + DiagnosticLogger.Log($"PropertyArgumentHighlighterProvider.CreateTagger: Called for buffer type {buffer?.ContentType?.TypeName ?? "null"}"); + + if (buffer == null || textView == null) + { + DiagnosticLogger.Log("PropertyArgumentHighlighterProvider.CreateTagger: Buffer or view is null, returning null"); + return null; + } + + // Only provide highlighting for the top-level buffer + if (textView.TextBuffer != buffer) + { + DiagnosticLogger.Log("PropertyArgumentHighlighterProvider.CreateTagger: Not top-level buffer, returning null"); + return null; + } + + // Create or get the highlight state for this view + var highlightState = textView.Properties.GetOrCreateSingletonProperty( + typeof(PropertyArgumentHighlightState), + () => new PropertyArgumentHighlightState(textView)); + + DiagnosticLogger.Log("PropertyArgumentHighlighterProvider.CreateTagger: Creating PropertyArgumentHighlighter"); + return new PropertyArgumentHighlighter(textView, buffer, highlightState) as ITagger; + } +} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/SerilogBraceEscCommandHandler.cs b/SerilogSyntax/Tagging/SerilogBraceEscCommandHandler.cs deleted file mode 100644 index c935d90..0000000 --- a/SerilogSyntax/Tagging/SerilogBraceEscCommandHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.VisualStudio.Commanding; -using Microsoft.VisualStudio.Text.Editor; -using Microsoft.VisualStudio.Text.Editor.Commanding.Commands; -using Microsoft.VisualStudio.Utilities; -using System.ComponentModel.Composition; - -namespace SerilogSyntax.Tagging -{ - /// - /// Handles ESC to dismiss the current Serilog brace highlight. - /// Participates in the editor command chain. - /// - [Export(typeof(ICommandHandler))] - [Name("SerilogBraceEscHandler")] - [ContentType("CSharp")] - internal sealed class SerilogBraceEscCommandHandler - : IChainedCommandHandler - { - /// - /// Gets the display name of the command handler. - /// - public string DisplayName => "Serilog Brace ESC Dismissal"; - - /// - /// Executes the ESC command, dismissing brace highlights if applicable. - /// - /// The ESC key command arguments. - /// The next handler in the command chain. - /// The command execution context. - public void ExecuteCommand(EscapeKeyCommandArgs args, System.Action nextHandler, CommandExecutionContext context) - { - var view = args.TextView; - var state = SerilogBraceHighlightState.GetOrCreate(view); - - // Only handle ESC if we actually dismiss something; otherwise, pass through. - bool handled = state.DismissCurrentPair(); - if (!handled) - nextHandler(); - } - - /// - /// Gets the command state for the ESC key. - /// - /// The ESC key command arguments. - /// The next handler in the command chain. - /// The command state. - public CommandState GetCommandState(EscapeKeyCommandArgs args, System.Func nextHandler) - { - // Enabled whenever we can access the view; we decide to handle at ExecuteCommand time. - return CommandState.Available; - } - } -} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/SerilogBraceHighlightState.cs b/SerilogSyntax/Tagging/SerilogBraceHighlightState.cs deleted file mode 100644 index ea432d8..0000000 --- a/SerilogSyntax/Tagging/SerilogBraceHighlightState.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Editor; -using System; - -namespace SerilogSyntax.Tagging -{ - /// - /// Per-view state for Serilog brace highlighting, including ESC dismissal. - /// - internal sealed class SerilogBraceHighlightState : IDisposable - { - private readonly ITextView _view; - - // Track the current brace pair under the caret. - private ITrackingPoint _currentOpen; - private ITrackingPoint _currentClose; - - // Track the last pair dismissed with ESC. - private ITrackingPoint _dismissedOpen; - private ITrackingPoint _dismissedClose; - private bool _isDismissed; - - /// - /// Occurs when the highlight state changes (dismissal or restoration). - /// - public event EventHandler StateChanged; - - public SerilogBraceHighlightState(ITextView view) - { - _view = view ?? throw new ArgumentNullException(nameof(view)); - _view.Closed += OnViewClosed; - } - - /// - /// Gets a value indicating whether there is a current brace pair being tracked. - /// - public bool HasCurrentPair => _currentOpen != null && _currentClose != null; - - /// - /// Sets the current brace pair being highlighted. - /// Called by the tagger when it determines the current pair. - /// - /// The opening brace position. - /// The closing brace position. - public void SetCurrentPair(SnapshotPoint open, SnapshotPoint close) - { - var snapshot = open.Snapshot; - _currentOpen = snapshot.CreateTrackingPoint(open, PointTrackingMode.Positive); - _currentClose = snapshot.CreateTrackingPoint(close, PointTrackingMode.Positive); - - // If we moved to a different pair, clear any prior dismissal. - if (_isDismissed && !CurrentMatchesDismissed()) - { - _isDismissed = false; - _dismissedOpen = _dismissedClose = null; - OnStateChanged(); - } - } - - /// - /// Clears the current brace pair and any dismissal state. - /// Called by the tagger when cursor moves away from any brace. - /// - public void ClearCurrentPair() - { - _currentOpen = null; - _currentClose = null; - - // Clear dismissal when moving away from the brace - if (_isDismissed) - { - _isDismissed = false; - _dismissedOpen = null; - _dismissedClose = null; - OnStateChanged(); - } - } - - /// - /// Gets a value indicating whether the current brace pair has been dismissed via ESC. - /// - public bool IsDismissedForCurrentPair => _isDismissed && CurrentMatchesDismissed(); - - /// - /// Dismisses the current brace pair highlight. - /// Called by the ESC command handler. - /// - /// True if a pair was dismissed; false if there was nothing to dismiss. - public bool DismissCurrentPair() - { - if (!HasCurrentPair) - return false; - - if (_isDismissed && CurrentMatchesDismissed()) - return false; // already dismissed; let other ESC handlers run - - _dismissedOpen = _currentOpen; - _dismissedClose = _currentClose; - _isDismissed = true; - OnStateChanged(); - return true; - } - - private bool CurrentMatchesDismissed() - { - if (_currentOpen == null || _currentClose == null || _dismissedOpen == null || _dismissedClose == null) - return false; - - // Compare positions in the latest snapshot - try - { - var snapshot = _view.TextSnapshot; - int curOpen = _currentOpen.GetPosition(snapshot); - int curClose = _currentClose.GetPosition(snapshot); - int disOpen = _dismissedOpen.GetPosition(snapshot); - int disClose = _dismissedClose.GetPosition(snapshot); - return curOpen == disOpen && curClose == disClose; - } - catch - { - return false; - } - } - - private void OnStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty); - - private void OnViewClosed(object sender, EventArgs e) => Dispose(); - - public void Dispose() - { - _view.Closed -= OnViewClosed; - } - - /// - /// Gets or creates a singleton instance of the state for the specified view. - /// - /// The text view. - /// The state instance for the view. - public static SerilogBraceHighlightState GetOrCreate(ITextView view) - => view.Properties.GetOrCreateSingletonProperty(() => new SerilogBraceHighlightState(view)); - } -} \ No newline at end of file diff --git a/SerilogSyntax/Tagging/SerilogBraceMatchProvider.cs b/SerilogSyntax/Tagging/SerilogBraceMatchProvider.cs deleted file mode 100644 index d233e5f..0000000 --- a/SerilogSyntax/Tagging/SerilogBraceMatchProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 brace matching taggers for Serilog template properties. -/// -[Export(typeof(IViewTaggerProvider))] -[ContentType("CSharp")] -[TagType(typeof(TextMarkerTag))] -internal sealed class SerilogBraceMatcherProvider : IViewTaggerProvider -{ - /// - /// Creates a tagger for brace matching in Serilog templates. - /// - /// The type of tag. - /// The text view. - /// The text buffer. - /// A brace matching 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 SerilogBraceMatcher(textView, buffer) as ITagger; - } -} diff --git a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs b/SerilogSyntax/Tagging/SerilogBraceMatcher.cs deleted file mode 100644 index 0cfd1dd..0000000 --- a/SerilogSyntax/Tagging/SerilogBraceMatcher.cs +++ /dev/null @@ -1,770 +0,0 @@ -using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Editor; -using Microsoft.VisualStudio.Text.Tagging; -using SerilogSyntax.Classification; -using SerilogSyntax.Expressions; -using SerilogSyntax.Utilities; -using System; -using System.Collections.Generic; - -namespace SerilogSyntax.Tagging; - -/// -/// Provides brace matching highlights for Serilog template properties. -/// Highlights matching opening and closing braces when the caret is positioned on or near them. -/// -internal sealed class SerilogBraceMatcher : ITagger, IDisposable -{ - /// - /// Maximum number of lines to search backward when detecting multi-line string contexts. - /// - private const int MaxLookbackLines = 20; - - /// - /// Maximum number of lines to search forward when detecting unclosed strings. - /// - private const int MaxLookforwardLines = 50; - - /// - /// Maximum character distance to search for matching braces within a property. - /// - private const int MaxPropertyLength = 200; - - /// - /// Maximum character distance to search for matching braces within an expression. - /// - private const int MaxExpressionLength = 500; - - private readonly ITextView _view; - private readonly ITextBuffer _buffer; - private readonly SerilogBraceHighlightState _state; - - private SnapshotPoint? _currentChar; - private bool _disposed; - - /// - /// Event raised when tags have changed. - /// - public event EventHandler TagsChanged; - - /// - /// Initializes a new instance of the class. - /// - /// The text view. - /// The text buffer. - public SerilogBraceMatcher(ITextView view, ITextBuffer buffer) - { - _view = view; - _buffer = buffer; - _state = SerilogBraceHighlightState.GetOrCreate(view); - - // Initialize current position - _currentChar = view.Caret.Position.Point.GetPoint(buffer, view.Caret.Position.Affinity); - - _view.Caret.PositionChanged += CaretPositionChanged; - _view.LayoutChanged += ViewLayoutChanged; - _state.StateChanged += StateChanged; - _view.Closed += View_Closed; - } - - private void View_Closed(object sender, EventArgs e) => Dispose(); - - private void StateChanged(object sender, EventArgs e) => RaiseRefreshForEntireSnapshot(); - - /// - /// Handles view layout changes to update brace matching. - /// - /// The event sender. - /// The event arguments. - private void ViewLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) - { - if (e.NewSnapshot != e.OldSnapshot) - UpdateAtCaretPosition(_view.Caret.Position); - } - - /// - /// Handles caret position changes to update brace matching. - /// - /// The event sender. - /// The event arguments. - private void CaretPositionChanged(object sender, CaretPositionChangedEventArgs e) - => UpdateAtCaretPosition(e.NewPosition); - - /// - /// Updates brace matching tags based on the caret position. - /// - /// The caret position. - private void UpdateAtCaretPosition(CaretPosition caretPosition) - { - _currentChar = caretPosition.Point.GetPoint(_buffer, caretPosition.Affinity); - if (_currentChar.HasValue) - RaiseRefreshForEntireSnapshot(); - } - - private void RaiseRefreshForEntireSnapshot() - { - var snapshot = _buffer.CurrentSnapshot; - TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(new SnapshotSpan(snapshot, 0, snapshot.Length))); - } - - /// - /// Checks if the cursor is inside an expression template context. - /// - /// The snapshot point to check. - /// True if inside an expression template; otherwise, false. - private bool IsInsideExpressionTemplate(SnapshotPoint point) - { - try - { - var context = SyntaxTreeAnalyzer.GetExpressionContext(point.Snapshot, point.Position); - return context == ExpressionContext.ExpressionTemplate; - } - catch - { - // Fallback: check for ExpressionTemplate in the line text - var line = point.GetContainingLine(); - var lineText = line.GetText(); - return lineText.Contains("ExpressionTemplate") && SerilogCallDetector.IsSerilogCall(lineText); - } - } - - /// - /// Finds matching braces within expression template context, handling nested structures. - /// - /// The snapshot point at the brace. - /// A tuple of open and close brace points, or null if no match found. - private (SnapshotPoint? open, SnapshotPoint? close)? FindExpressionBraceMatch(SnapshotPoint point) - { - var snapshot = point.Snapshot; - var currentChar = point.GetChar(); - - if (currentChar == '{') - { - // Find closing brace, handling expression template nesting - return FindExpressionClosingBrace(snapshot, point, MaxExpressionLength); - } - else if (currentChar == '}') - { - // Find opening brace, handling expression template nesting - return FindExpressionOpeningBrace(snapshot, point, MaxExpressionLength); - } - - return null; - } - - /// - /// Checks if the cursor is inside a multi-line string (verbatim or raw). - /// - /// - /// This method searches backward up to lines (20 by default) - /// and forward up to lines (50 by default) to find - /// unclosed string delimiters, balancing performance with accuracy. - /// - /// The snapshot point to check. - /// True if inside a multi-line string; otherwise, false. - private bool IsInsideMultiLineString(SnapshotPoint point) - { - var line = point.GetContainingLine(); - var snapshot = point.Snapshot; - - // Look backwards for unclosed verbatim (@") or raw string (""") - for (int i = line.LineNumber; i >= Math.Max(0, line.LineNumber - MaxLookbackLines); i--) - { - var checkLine = snapshot.GetLineFromLineNumber(i); - var lineText = checkLine.GetText(); - - // Check for raw string opener - if (lineText.Contains("\"\"\"")) - { - // Check if it's a Serilog call - if (SerilogCallDetector.IsSerilogCall(lineText)) - { - // Look forward to see if it's closed - for (int j = i + 1; j < Math.Min(snapshot.LineCount, i + MaxLookforwardLines); j++) - { - var forwardLine = snapshot.GetLineFromLineNumber(j); - if (forwardLine.GetText().TrimStart().StartsWith("\"\"\"")) - { - // Found closing, check if we're between them - if (line.LineNumber > i && line.LineNumber < j) - return true; - break; - } - } - } - } - - // Check for verbatim string opener - if (lineText.Contains("@\"")) - { - var atIndex = lineText.IndexOf("@\""); - if (atIndex >= 0) - { - // Check if this line OR a previous line has a Serilog call - bool hasSerilogCall = SerilogCallDetector.IsSerilogCall(lineText); - if (!hasSerilogCall && i > 0) - { - // Check previous line for Serilog call - var prevLine = snapshot.GetLineFromLineNumber(i - 1); - hasSerilogCall = SerilogCallDetector.IsSerilogCall(prevLine.GetText()); - } - - if (hasSerilogCall) - { - // For verbatim strings, we need to track if it's closed - // by looking for an unescaped quote - bool stringClosed = false; - - // First check if it closes on the same line - var restOfLine = lineText.Substring(atIndex + 2); - int pos = 0; - while (pos < restOfLine.Length) - { - if (restOfLine[pos] == '"') - { - // Check if it's escaped (followed by another quote) - if (pos + 1 < restOfLine.Length && restOfLine[pos + 1] == '"') - { - pos += 2; // Skip escaped quote pair - } - else - { - // Found closing quote on same line - stringClosed = true; - break; - } - } - else - { - pos++; - } - } - - if (!stringClosed) - { - // String didn't close on this line, look forward for closing - for (int j = i + 1; j < Math.Min(snapshot.LineCount, i + MaxLookforwardLines); j++) - { - var forwardLine = snapshot.GetLineFromLineNumber(j); - var forwardText = forwardLine.GetText(); - - // Look for closing quote in this line - pos = 0; - while (pos < forwardText.Length) - { - if (forwardText[pos] == '"') - { - // Check if escaped - if (pos + 1 < forwardText.Length && forwardText[pos + 1] == '"') - { - pos += 2; // Skip escaped pair - } - else - { - // Found unescaped quote - this closes the string - stringClosed = true; - - // Check if we're between opening and closing - if (line.LineNumber > i && line.LineNumber <= j) - return true; - break; - } - } - else - { - pos++; - } - } - - if (stringClosed) - break; - } - - // If still not closed and we're after the opening line - if (!stringClosed && line.LineNumber > i) - return true; - } - } - } - } - } - - return false; - } - - /// - /// Finds matching braces across line boundaries. - /// - /// - /// Searches are limited to characters (200 by default) - /// in either direction from the starting brace to prevent performance issues with - /// malformed or extremely long properties. - /// - /// The snapshot point at the brace. - /// A tuple of open and close brace points, or null if no match found. - private (SnapshotPoint? open, SnapshotPoint? close)? FindMultiLineBraceMatch(SnapshotPoint point) - { - var snapshot = point.Snapshot; - var currentChar = point.GetChar(); - - if (currentChar == '{') - { - // Find closing brace, potentially on another line - int braceCount = 1; - for (int pos = point.Position + 1; pos < snapshot.Length; pos++) - { - char ch = snapshot[pos]; - - // Check for escaped braces - if (ch == '{' && pos + 1 < snapshot.Length && snapshot[pos + 1] == '{') - { - pos++; // Skip escaped - continue; - } - - if (ch == '}' && pos + 1 < snapshot.Length && snapshot[pos + 1] == '}') - { - pos++; // Skip escaped - continue; - } - - if (ch == '{') - braceCount++; - else if (ch == '}') - { - braceCount--; - if (braceCount == 0) - { - return (point, new SnapshotPoint(snapshot, pos)); - } - } - - // Don't search too far (e.g., max chars for a property) - if (pos - point.Position > MaxPropertyLength) - break; - } - } - else if (currentChar == '}') - { - // Find opening brace, potentially on another line - int braceCount = 1; - for (int pos = point.Position - 1; pos >= 0; pos--) - { - char ch = snapshot[pos]; - - // Check for escaped braces - if (ch == '}' && pos > 0 && snapshot[pos - 1] == '}') - { - pos--; // Skip escaped - continue; - } - - if (ch == '{' && pos > 0 && snapshot[pos - 1] == '{') - { - pos--; // Skip escaped - continue; - } - - if (ch == '}') - braceCount++; - else if (ch == '{') - { - braceCount--; - if (braceCount == 0) - { - return (new SnapshotPoint(snapshot, pos), point); - } - } - - // Don't search too far - if (point.Position - pos > MaxPropertyLength) - break; - } - } - - return null; - } - - /// - /// Gets the tags that intersect the given spans. - /// - /// The spans to get tags for. - /// Tags for matching braces if the caret is positioned on a brace in a Serilog template. - public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) - { - if (_disposed || !_currentChar.HasValue || spans.Count == 0) - yield break; - - var snapshot = spans[0].Snapshot; - - // Respect user setting: if automatic delimiter highlighting is off, do nothing. - // Note: In VS 2022, this option is accessed via the editor options system - // For now, we'll always enable brace matching as the option check requires - // additional references to access the option properly - - var currentChar = _currentChar.Value; - if (currentChar.Position >= snapshot.Length) - yield break; - - var currentLine = currentChar.GetContainingLine(); - var lineStart = currentLine.Start.Position; - var lineText = currentLine.GetText(); - - // First check if we're in a multi-line string context - bool inMultiLineString = IsInsideMultiLineString(currentChar); - - // 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)) - yield break; - - var charAtCaret = currentChar.GetChar(); - var positionInLine = currentChar.Position - lineStart; - - SnapshotPoint? openPt = null; - SnapshotPoint? closePt = null; - - if (inExpressionTemplate) - { - // Use expression template brace matching - // Following VS standard: highlight when cursor is to LEFT of { or RIGHT of } - if (charAtCaret == '{') - { - // Cursor is to the left of opening brace - should match - var match = FindExpressionBraceMatch(currentChar); - if (match.HasValue) - { - openPt = match.Value.open; - closePt = match.Value.close; - } - } - // Check if cursor is just after closing brace - else if (currentChar.Position > 0) - { - var prevPoint = new SnapshotPoint(snapshot, currentChar.Position - 1); - var prevChar = prevPoint.GetChar(); - // Only match when cursor is after closing brace (VS standard) - if (prevChar == '}') - { - var match = FindExpressionBraceMatch(prevPoint); - if (match.HasValue) - { - openPt = match.Value.open; - closePt = match.Value.close; - } - } - } - } - else if (inMultiLineString) - { - // Use multi-line brace matching - var match = FindMultiLineBraceMatch(currentChar); - if (match.HasValue) - { - openPt = match.Value.open; - closePt = match.Value.close; - } - else if (currentChar.Position > 0) - { - // Check if cursor is just after a brace - var prevPoint = new SnapshotPoint(snapshot, currentChar.Position - 1); - if (prevPoint.GetChar() == '}') - { - var prevMatch = FindMultiLineBraceMatch(prevPoint); - if (prevMatch.HasValue) - { - openPt = prevMatch.Value.open; - closePt = prevMatch.Value.close; - } - } - } - } - else - { - // Use existing single-line logic - int open = -1, close = -1; - - if (charAtCaret == '{') - { - open = positionInLine; - close = FindMatchingCloseBrace(lineText, positionInLine); - } - else if (charAtCaret == '}') - { - close = positionInLine; - open = FindMatchingOpenBrace(lineText, positionInLine); - } - else if (positionInLine > 0 && lineText[positionInLine - 1] == '}') - { - close = positionInLine - 1; - open = FindMatchingOpenBrace(lineText, close); - } - - if (open >= 0 && close >= 0) - { - openPt = new SnapshotPoint(snapshot, lineStart + open); - closePt = new SnapshotPoint(snapshot, lineStart + close); - } - } - - // Create tag spans if we found a match - if (openPt.HasValue && closePt.HasValue) - { - _state.SetCurrentPair(openPt.Value, closePt.Value); - - if (!_state.IsDismissedForCurrentPair) - { - yield return CreateTagSpan(snapshot, openPt.Value.Position, 1); - yield return CreateTagSpan(snapshot, closePt.Value.Position, 1); - } - } - else - { - _state.ClearCurrentPair(); - } - } - - /// - /// Determines whether the given line contains a Serilog call. - /// - /// The line to check. - /// True if the line contains a Serilog call; otherwise, false. - private bool IsSerilogCall(string line) - { - return SerilogCallDetector.IsSerilogCall(line); - } - - - /// - /// Finds the matching closing brace for an opening brace in expression context. - /// - /// The text snapshot to search in. - /// The position of the opening brace. - /// Maximum search distance. - /// A tuple of open and close points, or null if no match found. - private (SnapshotPoint? open, SnapshotPoint? close)? FindExpressionClosingBrace( - ITextSnapshot snapshot, - SnapshotPoint openPoint, - int maxLength) - { - int braceCount = 1; - - for (int pos = openPoint.Position + 1; pos < snapshot.Length && pos < openPoint.Position + maxLength; pos++) - { - char ch = snapshot[pos]; - - // Handle escaped braces - if (ch == '{' && pos + 1 < snapshot.Length && snapshot[pos + 1] == '{') - { - pos++; // Skip escaped pair - continue; - } - if (ch == '}' && pos + 1 < snapshot.Length && snapshot[pos + 1] == '}') - { - pos++; // Skip escaped pair - continue; - } - - if (ch == '{') - { - braceCount++; - } - else if (ch == '}') - { - braceCount--; - if (braceCount == 0) - { - return (openPoint, new SnapshotPoint(snapshot, pos)); - } - } - - // Stop at string boundaries to avoid matching across different string literals - if (ch == '"' && !IsEscapedQuote(snapshot, pos)) - { - break; - } - } - - return null; - } - - /// - /// Finds the matching opening brace for a closing brace in expression context. - /// - /// The text snapshot to search in. - /// The position of the closing brace. - /// Maximum search distance. - /// A tuple of open and close points, or null if no match found. - private (SnapshotPoint? open, SnapshotPoint? close)? FindExpressionOpeningBrace( - ITextSnapshot snapshot, - SnapshotPoint closePoint, - int maxLength) - { - int braceCount = 1; - - for (int pos = closePoint.Position - 1; pos >= 0 && pos > closePoint.Position - maxLength; pos--) - { - char ch = snapshot[pos]; - - // Handle escaped braces - if (ch == '}' && pos > 0 && snapshot[pos - 1] == '}') - { - pos--; // Skip escaped pair - continue; - } - if (ch == '{' && pos > 0 && snapshot[pos - 1] == '{') - { - pos--; // Skip escaped pair - continue; - } - - if (ch == '}') - { - braceCount++; - } - else if (ch == '{') - { - braceCount--; - if (braceCount == 0) - { - return (new SnapshotPoint(snapshot, pos), closePoint); - } - } - - // Stop at string boundaries to avoid matching across different string literals - if (ch == '"' && !IsEscapedQuote(snapshot, pos)) - { - break; - } - } - - return null; - } - - /// - /// Checks if a quote character at the given position is escaped. - /// - /// The text snapshot. - /// The position of the quote character. - /// True if the quote is escaped; otherwise, false. - private bool IsEscapedQuote(ITextSnapshot snapshot, int position) - { - if (position == 0) return false; - - int backslashCount = 0; - for (int i = position - 1; i >= 0 && snapshot[i] == '\\'; i--) - { - backslashCount++; - } - - // Odd number of backslashes means the quote is escaped - return backslashCount % 2 == 1; - } - - /// - /// Finds the matching closing brace for an opening brace. - /// - /// The text to search in. - /// The position of the opening brace. - /// The position of the matching closing brace, or -1 if not found. - private int FindMatchingCloseBrace(string text, int openBracePos) - { - if (openBracePos + 1 < text.Length && text[openBracePos + 1] == '{') - return -1; // Escaped brace - - int braceCount = 1; - for (int i = openBracePos + 1; i < text.Length; i++) - { - if (text[i] == '{') - { - if (i + 1 < text.Length && text[i + 1] == '{') - { - i++; // Skip escaped brace - continue; - } - - braceCount++; - } - else if (text[i] == '}') - { - if (i + 1 < text.Length && text[i + 1] == '}') - { - i++; // Skip escaped brace - continue; - } - - braceCount--; - if (braceCount == 0) - return i; - } - } - return -1; - } - - /// - /// Finds the matching opening brace for a closing brace. - /// - /// The text to search in. - /// The position of the closing brace. - /// The position of the matching opening brace, or -1 if not found. - private int FindMatchingOpenBrace(string text, int closeBracePos) - { - if (closeBracePos > 0 && text[closeBracePos - 1] == '}') - return -1; // Escaped brace - - int braceCount = 1; - for (int i = closeBracePos - 1; i >= 0; i--) - { - if (text[i] == '}') - { - if (i > 0 && text[i - 1] == '}') - { - i--; // Skip escaped brace - continue; - } - - braceCount++; - } - else if (text[i] == '{') - { - if (i > 0 && text[i - 1] == '{') - { - i--; // Skip escaped brace - continue; - } - - braceCount--; - if (braceCount == 0) - return i; - } - } - return -1; - } - - /// - /// Creates a tag span for highlighting a brace. - /// - /// The text snapshot. - /// The start position of the brace. - /// The length of the span (typically 1 for a single brace). - /// A tag span for the brace highlight. - private ITagSpan CreateTagSpan(ITextSnapshot snapshot, int start, int length) - { - var span = new SnapshotSpan(snapshot, start, length); - var tag = new TextMarkerTag("bracehighlight"); - return new TagSpan(span, tag); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _view.Caret.PositionChanged -= CaretPositionChanged; - _view.LayoutChanged -= ViewLayoutChanged; - _view.Closed -= View_Closed; - _state.StateChanged -= StateChanged; - } -} \ No newline at end of file