From 3264db95ed3f8601e6e1560e70542cdd3c3d160c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 23:42:36 -0700 Subject: [PATCH 1/2] fix: Support multi-line Serilog template navigation - Fix template detection for multi-line verbatim and raw string literals - Correct argument position calculation by starting after comma delimiter - Add multi-line navigation test coverage - Handle template end position calculation for proper argument parsing Fixes navigation from template properties to arguments when Serilog calls span multiple lines, including verbatim strings (@"...") and raw string literals ("""..."""). --- Example/ExampleService.cs | 6 + .../Classification/SerilogClassifierTests.cs | 31 + .../SerilogNavigationProviderTests.cs | 339 ++++++++++ .../Classification/SerilogClassifier.cs | 92 ++- .../Navigation/SerilogNavigationProvider.cs | 604 +++++++++++++++++- 5 files changed, 1049 insertions(+), 23 deletions(-) diff --git a/Example/ExampleService.cs b/Example/ExampleService.cs index 3ba0e6d..c25092f 100644 --- a/Example/ExampleService.cs +++ b/Example/ExampleService.cs @@ -392,6 +392,12 @@ private async Task ErrorHandlingExamples() logger.LogError(new Exception(errorMessage), "Failed to validate {UserId} with status {ErrorCode} and details {Message}", userId, errorCode, errorMessage); + // Example 3: Multi-line LogError call (for testing navigation) + logger.LogError(new Exception("Connection timeout"), + "Processing failed for {UserId} with {ErrorCode}", + userId, + errorCode); + await Task.Delay(100); } diff --git a/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs b/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs index 3ad4629..61db1c8 100644 --- a/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs +++ b/SerilogSyntax.Tests/Classification/SerilogClassifierTests.cs @@ -336,5 +336,36 @@ public void GetClassificationSpans_LogErrorWithRawStringContainingProperties_Doe c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); Assert.Equal(4, braces.Count); // 2 opening + 2 closing braces for message template only } + + [Fact] + public void GetClassificationSpans_MultiLineLogErrorWithException_HighlightsMessageTemplateProperties() + { + // Arrange - Multi-line LogError call with exception parameter spread across multiple lines + var code = "logger.LogError(new Exception(\"Connection timeout\"), \r\n \"Processing failed for {UserId} with {ErrorCode}\",\r\n userId, \r\n errorCode);"; + var textBuffer = MockTextBuffer.Create(code); + var classifier = CreateClassifier(textBuffer); + + // Test the specific line that's failing to get highlighted + var lines = textBuffer.CurrentSnapshot.Lines.ToArray(); + var templateLine = lines[1]; // Line with the template string + var templateSpan = new SnapshotSpan(templateLine.Start, templateLine.End); + + // Act - Test just the template line that should be highlighted + var result = classifier.GetClassificationSpans(templateSpan); + + // Assert - this line SHOULD have classifications but currently doesn't + var classifications = result.ToList(); + Assert.NotEmpty(classifications); // This will fail - proving the bug exists + + // Should find property names for UserId, ErrorCode from the message template + var properties = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyName).ToList(); + Assert.Equal(2, properties.Count); // UserId, ErrorCode + + // Should find braces for the two properties + var braces = classifications.Where(c => + c.ClassificationType.Classification == SerilogClassificationTypes.PropertyBrace).ToList(); + Assert.Equal(4, braces.Count); // 2 opening + 2 closing braces + } } } \ No newline at end of file diff --git a/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs b/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs index 6e7eb5f..ff68b42 100644 --- a/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs +++ b/SerilogSyntax.Tests/Navigation/SerilogNavigationProviderTests.cs @@ -1,5 +1,8 @@ +using Microsoft.VisualStudio.Text; using SerilogSyntax.Navigation; using SerilogSyntax.Parsing; +using SerilogSyntax.Tests.TestHelpers; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -101,4 +104,340 @@ public void NavigateToArgumentAction_DisplayText_FormatsNamedCorrectly() // Assert Assert.Equal("Navigate to 'UserName' argument", action.DisplayText); } + + [Fact] + public void GetSuggestedActions_MultiLineCall_ShouldProvideNavigation() + { + // This test simulates the real issue: multi-line calls don't provide navigation + // when the template is on one line and arguments are on subsequent lines + + // Arrange - Create mock text snapshot for multi-line scenario + var multiLineCode = + "logger.LogInformation(\"User {UserId} ({UserName}) placed {OrderCount} orders\",\r\n" + + " userId, userName, orderCount);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Create a range within the {UserId} property (position should be inside the template) + var userIdStart = multiLineCode.IndexOf("{UserId}"); + var range = new SnapshotSpan(mockSnapshot, userIdStart + 1, 6); // Inside "UserId" + + var provider = new SerilogSuggestedActionsSource(null); + + // Act - Try to get suggested actions for navigation + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + // Assert - Should provide navigation actions even for multi-line calls + Assert.NotEmpty(actions); // This should fail with current implementation + } + + [Fact] + public void GetSuggestedActions_ThreeLineCall_ShouldProvideNavigation() + { + // Test arguments spread across three lines + var multiLineCode = + "logger.LogInformation(\"Processing {UserId} with {UserName} in {Department} at {Timestamp:yyyy-MM-dd}\",\r\n" + + " userId, userName,\r\n" + + " department, DateTime.Now);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for different properties + var departmentStart = multiLineCode.IndexOf("{Department}"); + var range = new SnapshotSpan(mockSnapshot, departmentStart + 1, 10); // Inside "Department" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + } + + [Fact] + public void GetSuggestedActions_ArgumentsOnSeparateLines_ShouldProvideNavigation() + { + // Test with each argument on its own line + var multiLineCode = + "logger.LogError(exception, \"Error processing {UserId} with {ErrorCode} and {Message}\",\r\n" + + " userId,\r\n" + + " errorCode,\r\n" + + " errorMessage);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for the second property {ErrorCode} + var errorCodeStart = multiLineCode.IndexOf("{ErrorCode}"); + var range = new SnapshotSpan(mockSnapshot, errorCodeStart + 1, 9); // Inside "ErrorCode" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + } + + [Fact] + public void GetSuggestedActions_ComplexFormatSpecifiers_ShouldProvideNavigation() + { + // Test multi-line with complex format specifiers and alignment + var multiLineCode = + "logger.LogInformation(\"Order {OrderId,-10} by {CustomerName,15} for {Amount:C} on {Date:yyyy-MM-dd HH:mm}\",\r\n" + + " order.Id, customer.FullName, order.TotalAmount,\r\n" + + " order.CreatedDate);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {Amount:C} property + var amountStart = multiLineCode.IndexOf("{Amount:C}"); + var range = new SnapshotSpan(mockSnapshot, amountStart + 1, 6); // Inside "Amount" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + } + + [Fact] + public void GetSuggestedActions_DestructuredProperties_ShouldProvideNavigation() + { + // Test multi-line with destructured and stringified properties + var multiLineCode = + "logger.LogInformation(\"User {@User} performed {Action} with {@RequestData} and {$ErrorDetails}\",\r\n" + + " currentUser, actionType,\r\n" + + " requestData, errorDetails);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for destructured property {@RequestData} + var requestDataStart = multiLineCode.IndexOf("{@RequestData}"); + var range = new SnapshotSpan(mockSnapshot, requestDataStart + 2, 11); // Inside "RequestData" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + } + + [Fact] + public void GetSuggestedActions_PositionalProperties_ShouldProvideNavigation() + { + // Test multi-line with positional properties + var multiLineCode = + "logger.LogWarning(\"Warning: {0} failed for user {1} with error {2} at {3:yyyy-MM-dd}\",\r\n" + + " operationName, userId,\r\n" + + " errorMessage, DateTime.Now);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for positional property {1} + var positionalStart = multiLineCode.IndexOf("{1}"); + var range = new SnapshotSpan(mockSnapshot, positionalStart + 1, 1); // Inside "1" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + } + + [Fact] + public void GetSuggestedActions_MultiLineNavigation_ShouldSelectCorrectArgumentWithWhitespace() + { + // Test case that replicates the exact issue: navigation to {@Customer} should highlight the object, not part of the string + var multiLineCode = + "expressionLogger.Information(\"Order {OrderId} processed successfully for customer {@Customer} in {Duration}ms\",\r\n" + + " \"ORD-2024-0042\", new { Name = \"Bob Smith\", Tier = \"Premium\" }, 127);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {@Customer} property (the destructured customer object) + var customerStart = multiLineCode.IndexOf("{@Customer}"); + var range = new SnapshotSpan(mockSnapshot, customerStart + 2, 8); // Inside "Customer" (skip the {@) + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + + // Verify that the navigation action points to the correct argument + var actionSet = actions.First(); + var navigateAction = actionSet.Actions.OfType().First(); + + // Calculate expected position: the customer object starts with "new { Name = "Bob Smith"" + var expectedStart = multiLineCode.IndexOf("new { Name = \"Bob Smith\""); + var expectedLength = "new { Name = \"Bob Smith\", Tier = \"Premium\" }".Length; + + // The action should highlight the complete customer object, not part of the string literal + Assert.Equal(expectedStart, navigateAction.ArgumentStart); + Assert.Equal(expectedLength, navigateAction.ArgumentLength); + + // Verify the highlighted text is exactly what we expect + var highlightedText = multiLineCode.Substring(navigateAction.ArgumentStart, navigateAction.ArgumentLength); + Assert.Equal("new { Name = \"Bob Smith\", Tier = \"Premium\" }", highlightedText); + } + + [Fact] + public void GetSuggestedActions_VerbatimStringMultiLine_ShouldProvideNavigation() + { + // Test verbatim string spanning multiple lines + var multiLineCode = + "logger.LogInformation(@\"Processing files in path: {FilePath}\r\n" + + "Multiple lines are supported in verbatim strings\r\n" + + "With properties like {UserId} and {@Order}\r\n" + + "Even with \"\"escaped quotes\"\" in the template\",\r\n" + + " filePath, userId, order);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {UserId} property on line 3 of the template + var userIdStart = multiLineCode.IndexOf("{UserId}"); + var range = new SnapshotSpan(mockSnapshot, userIdStart + 1, 6); // Inside "UserId" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); // This should fail - no navigation appears + } + + [Fact] + public void GetSuggestedActions_VerbatimStringMultiLine_FilePath_ShouldProvideNavigation() + { + // Test that {FilePath} on the first line of a verbatim multi-line template provides navigation + var multiLineCode = + "logger.LogInformation(@\"Processing files in path: {FilePath}\r\n" + + "Multiple lines are supported in verbatim strings\r\n" + + "With properties like {UserId} and {@Order}\r\n" + + "Even with \"\"escaped quotes\"\" in the template\",\r\n" + + " filePath, userId, order);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {FilePath} property on the first line of the template + var filePathStart = multiLineCode.IndexOf("{FilePath}"); + var range = new SnapshotSpan(mockSnapshot, filePathStart + 1, 8); // Inside "FilePath" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); // This should fail - no navigation appears for {FilePath} + + // Verify it highlights the correct argument (filePath) + var actionSet = actions.First(); + var navigateAction = actionSet.Actions.OfType().First(); + + var expectedStart = multiLineCode.IndexOf("filePath"); + var expectedLength = "filePath".Length; + + Assert.Equal(expectedStart, navigateAction.ArgumentStart); + Assert.Equal(expectedLength, navigateAction.ArgumentLength); + + var highlightedText = multiLineCode.Substring(navigateAction.ArgumentStart, navigateAction.ArgumentLength); + Assert.Equal("filePath", highlightedText); + } + + [Fact] + public void GetSuggestedActions_VerbatimStringMultiLine_UserId_ShouldHighlightCorrectArgument() + { + // Test that {UserId} highlights the correct argument (userId, not filePath) + var multiLineCode = + "logger.LogInformation(@\"Processing files in path: {FilePath}\r\n" + + "Multiple lines are supported in verbatim strings\r\n" + + "With properties like {UserId} and {@Order}\r\n" + + "Even with \"\"escaped quotes\"\" in the template\",\r\n" + + " filePath, userId, order);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {UserId} property + var userIdStart = multiLineCode.IndexOf("{UserId}"); + var range = new SnapshotSpan(mockSnapshot, userIdStart + 1, 6); // Inside "UserId" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + + // Verify it highlights the correct argument (userId, not filePath) + var actionSet = actions.First(); + var navigateAction = actionSet.Actions.OfType().First(); + + var expectedStart = multiLineCode.IndexOf("userId"); + var expectedLength = "userId".Length; + + Assert.Equal(expectedStart, navigateAction.ArgumentStart); // This should fail - currently highlights filePath + Assert.Equal(expectedLength, navigateAction.ArgumentLength); + + var highlightedText = multiLineCode.Substring(navigateAction.ArgumentStart, navigateAction.ArgumentLength); + Assert.Equal("userId", highlightedText); // This should fail - currently highlights filePath + } + + [Fact] + public void GetSuggestedActions_VerbatimStringMultiLine_Order_ShouldHighlightCorrectArgument() + { + // Test that {@Order} highlights the correct argument (order, not userId) + var multiLineCode = + "logger.LogInformation(@\"Processing files in path: {FilePath}\r\n" + + "Multiple lines are supported in verbatim strings\r\n" + + "With properties like {UserId} and {@Order}\r\n" + + "Even with \"\"escaped quotes\"\" in the template\",\r\n" + + " filePath, userId, order);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {@Order} property + var orderStart = multiLineCode.IndexOf("{@Order}"); + var range = new SnapshotSpan(mockSnapshot, orderStart + 2, 5); // Inside "Order" (skip {@) + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); + + // Verify it highlights the correct argument (order, not userId) + var actionSet = actions.First(); + var navigateAction = actionSet.Actions.OfType().First(); + + var expectedStart = multiLineCode.IndexOf(", order);") + 2; // Find "order" after ", " + var expectedLength = "order".Length; + + Assert.Equal(expectedStart, navigateAction.ArgumentStart); // This should fail - currently highlights userId + Assert.Equal(expectedLength, navigateAction.ArgumentLength); + + var highlightedText = multiLineCode.Substring(navigateAction.ArgumentStart, navigateAction.ArgumentLength); + Assert.Equal("order", highlightedText); // This should fail - currently highlights userId + } + + [Fact] + public void GetSuggestedActions_RawStringLiteralMultiLine_ShouldProvideNavigation() + { + // Test raw string literal spanning multiple lines + var multiLineCode = + "logger.LogInformation(\"\"\"\r\n" + + " Raw String Report:\r\n" + + " Record: {RecordId} | Status: {Status,-12}\r\n" + + " User: {UserName} (ID: {UserId})\r\n" + + " Order: {@Order}\r\n" + + " Timestamp: {Timestamp:yyyy-MM-dd HH:mm:ss}\r\n" + + " \"\"\", recordId, status, userName, userId, order, timestamp);"; + + var mockBuffer = new MockTextBuffer(multiLineCode); + var mockSnapshot = new MockTextSnapshot(multiLineCode, mockBuffer, 1); + + // Test navigation for {RecordId} property on line 3 of the template + var recordIdStart = multiLineCode.IndexOf("{RecordId}"); + var range = new SnapshotSpan(mockSnapshot, recordIdStart + 1, 8); // Inside "RecordId" + + var provider = new SerilogSuggestedActionsSource(null); + var actions = provider.GetSuggestedActions(null, range, CancellationToken.None); + + Assert.NotEmpty(actions); // This should fail - no navigation appears + } } \ No newline at end of file diff --git a/SerilogSyntax/Classification/SerilogClassifier.cs b/SerilogSyntax/Classification/SerilogClassifier.cs index 8e1318b..d32a958 100644 --- a/SerilogSyntax/Classification/SerilogClassifier.cs +++ b/SerilogSyntax/Classification/SerilogClassifier.cs @@ -284,10 +284,19 @@ public IList GetClassificationSpans(SnapshotSpan span) } else { - // Check if this is a concatenated string that might be part of a Serilog call - // This happens when VS sends us just a fragment like: "for user {UserId} " + - var trimmedText = text.TrimStart(); - bool looksLikeConcatenatedTemplate = false; + // Check if this is part of a multi-line Serilog call + bool isPartOfMultiLineSerilogCall = IsInsideMultiLineSerilogCall(span); + + if (isPartOfMultiLineSerilogCall) + { + insideString = true; + } + else + { + // Check if this is a concatenated string that might be part of a Serilog call + // This happens when VS sends us just a fragment like: "for user {UserId} " + + var trimmedText = text.TrimStart(); + bool looksLikeConcatenatedTemplate = false; // Check if this line looks like a concatenated string with template syntax // It could end with + (continuation) or , (last string in concatenation) @@ -384,6 +393,7 @@ public IList GetClassificationSpans(SnapshotSpan span) } } } + } } if (insideString) @@ -1040,6 +1050,80 @@ private bool IsInsideRawStringLiteral(SnapshotSpan span) return _multiLineStringDetector.IsInsideRawStringLiteral(span); } + /// + /// Checks if the given span is inside a multi-line Serilog method call. + /// + /// The span to check. + /// True if the span is inside a multi-line Serilog call; otherwise, false. + private bool IsInsideMultiLineSerilogCall(SnapshotSpan span) + { + var currentLine = span.Snapshot.GetLineFromPosition(span.Start); + var currentText = currentLine.GetText().Trim(); + + // Quick check - if this line looks like it could be part of a multi-line call + // (string literal, comma, or continuation) + if (!currentText.StartsWith("\"") && !currentText.StartsWith("@\"") && + !currentText.Contains(",") && !currentText.Contains(")")) + return false; + + // Look backward up to 5 lines to find a Serilog call + for (int i = currentLine.LineNumber - 1; i >= Math.Max(0, currentLine.LineNumber - 5); i--) + { + var checkLine = span.Snapshot.GetLineFromLineNumber(i); + var checkText = checkLine.GetText(); + + // Check if this line contains a Serilog call + if (SerilogCallDetector.IsSerilogCall(checkText)) + { + // Found a Serilog call - now check if the call is still open + // (has opening parenthesis but not properly closed) + int openParenCount = 0; + int closeParenCount = 0; + bool inString = false; + bool escaped = false; + + // Count parentheses from the Serilog call line down to current line + for (int lineNum = i; lineNum <= currentLine.LineNumber; lineNum++) + { + var line = span.Snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + + foreach (char c in lineText) + { + if (escaped) + { + escaped = false; + continue; + } + + if (c == '\\' && inString) + { + escaped = true; + continue; + } + + if (c == '"' && !inString) + inString = true; + else if (c == '"' && inString) + inString = false; + else if (!inString) + { + if (c == '(') + openParenCount++; + else if (c == ')') + closeParenCount++; + } + } + } + + // If we have more opens than closes, we're inside a multi-line call + return openParenCount > closeParenCount; + } + } + + return false; + } + /// /// Determines if a LogError call has an exception parameter before the message template. /// Pattern: LogError(exception, "message template", args...) vs LogError("message template", args...) diff --git a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs index 613c297..be92241 100644 --- a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs +++ b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs @@ -4,12 +4,14 @@ using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Operations; using Microsoft.VisualStudio.Utilities; +using SerilogSyntax.Diagnostics; using SerilogSyntax.Parsing; using SerilogSyntax.Utilities; using System; using System.Collections.Generic; using System.ComponentModel.Composition; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -67,15 +69,29 @@ public async Task HasSuggestedActionsAsync( { return await Task.Run(() => { + DiagnosticLogger.Log("=== HasSuggestedActionsAsync called ==="); var triggerPoint = range.Start; 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); + DiagnosticLogger.Log($"Serilog match on current line: {(serilogMatch != null ? $"Found at {serilogMatch.Index}" : "Not found")}"); if (serilogMatch == null) - return false; + { + // Check if we're inside a multi-line template + var multiLineResult = FindSerilogCallInPreviousLines(range.Snapshot, line); + DiagnosticLogger.Log($"Multi-line Serilog call: {(multiLineResult != null ? "Found" : "Not found")}"); + if (multiLineResult == null) + { + DiagnosticLogger.Log("No Serilog call found - returning false"); + return false; + } + } // Find the template string var templateMatch = FindTemplateString(lineText, serilogMatch.Index + serilogMatch.Length); @@ -122,27 +138,78 @@ public IEnumerable GetSuggestedActions( // Check if we're in a Serilog call var serilogMatch = SerilogCallDetector.FindSerilogCall(lineText); + ITextSnapshotLine serilogCallLine = line; + + // If no Serilog call found on current line, check if we're inside a multi-line template if (serilogMatch == null) - yield break; - - // Find the template string - var templateMatch = FindTemplateString(lineText, serilogMatch.Index + serilogMatch.Length); - if (!templateMatch.HasValue) - yield break; + { + var multiLineResult = FindSerilogCallInPreviousLines(range.Snapshot, line); + if (multiLineResult == null) + yield break; + + serilogMatch = multiLineResult.Value.Match; + serilogCallLine = multiLineResult.Value.Line; + } - var (templateStart, templateEnd) = templateMatch.Value; - var template = lineText.Substring(templateStart, templateEnd - templateStart); + // Find the template string - handle both single-line and multi-line scenarios + string template; + int templateStartPosition; + int templateEndPosition; - // Check if cursor is within template - var positionInLine = triggerPoint.Position - lineStart; - if (positionInLine < templateStart || positionInLine > templateEnd) - yield break; + if (serilogCallLine == line) + { + // Same-line scenario: template starts on the same line as the Serilog call + var templateMatch = FindTemplateString(lineText, serilogMatch.Index + serilogMatch.Length); + if (!templateMatch.HasValue) + { + // No complete template found on this line - check if it's a multi-line template starting here + var multiLineTemplate = ReconstructMultiLineTemplate(range.Snapshot, serilogCallLine, line); + if (multiLineTemplate == null) + yield break; + + template = multiLineTemplate.Value.Template; + templateStartPosition = multiLineTemplate.Value.StartPosition; + templateEndPosition = multiLineTemplate.Value.EndPosition; + + // Check if cursor is within the multi-line template bounds + if (triggerPoint.Position < templateStartPosition || triggerPoint.Position > templateEndPosition) + yield break; + } + else + { + // Complete single-line template found + var (templateStart, templateEnd) = templateMatch.Value; + template = lineText.Substring(templateStart, templateEnd - templateStart); + templateStartPosition = lineStart + templateStart; + templateEndPosition = lineStart + templateEnd; + + // Check if cursor is within template + var positionInLine = triggerPoint.Position - lineStart; + if (positionInLine < templateStart || positionInLine > templateEnd) + yield break; + } + } + else + { + // Multi-line scenario: reconstruct the full template from multiple lines + var multiLineTemplate = ReconstructMultiLineTemplate(range.Snapshot, serilogCallLine, line); + if (multiLineTemplate == null) + yield break; + + template = multiLineTemplate.Value.Template; + templateStartPosition = multiLineTemplate.Value.StartPosition; + templateEndPosition = multiLineTemplate.Value.EndPosition; + + // Check if cursor is within the multi-line template bounds + if (triggerPoint.Position < templateStartPosition || triggerPoint.Position > templateEndPosition) + yield break; + } // Parse template to find properties var properties = _parser.Parse(template).ToList(); // Find which property the cursor is on - var cursorPosInTemplate = positionInLine - templateStart; + var cursorPosInTemplate = triggerPoint.Position - templateStartPosition; var property = properties.FirstOrDefault(p => cursorPosInTemplate >= p.BraceStartIndex && cursorPosInTemplate <= p.BraceEndIndex); @@ -154,15 +221,17 @@ public IEnumerable GetSuggestedActions( var propertyIndex = GetArgumentIndex(properties, property); if (propertyIndex >= 0) { - var argumentLocation = FindArgumentByPosition(lineText, templateEnd, propertyIndex); - if (argumentLocation.HasValue) + // For both single-line and multi-line templates starting on the same line as the Serilog call, + // we need to search for arguments from the template end position + var multiLineLocation = FindArgumentInMultiLineCall(range.Snapshot, templateEndPosition, propertyIndex); + if (multiLineLocation.HasValue) { var actions = new ISuggestedAction[] { new NavigateToArgumentAction( textView, - lineStart + argumentLocation.Value.Item1, - argumentLocation.Value.Item2, + multiLineLocation.Value.Item1, + multiLineLocation.Value.Item2, property.Name, property.Type) }; @@ -203,8 +272,31 @@ private int GetArgumentIndex(List properties, TemplateProperty /// A tuple of (start, end) indices of the string content, or null if not found. private (int, int)? FindTemplateString(string line, int startIndex) { + // Check if this is LogError with exception parameter + bool hasExceptionParam = line.Contains("LogError") && HasExceptionParameterBeforeTemplate(line, startIndex); + // Look for string literal after Serilog method call - for (int i = startIndex; i < line.Length; i++) + // If this has an exception parameter, we need to skip over it to find the template string + int searchPos = startIndex; + if (hasExceptionParam) + { + // Skip over the exception parameter by finding the first comma at parenthesis depth 1 + 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++; // Move past the comma + break; + } + searchPos++; + } + } + + for (int i = searchPos; i < line.Length; i++) { if (char.IsWhiteSpace(line[i])) continue; @@ -262,6 +354,400 @@ private int GetArgumentIndex(List properties, TemplateProperty return null; } + /// + /// Determines if a LogError call has an exception parameter before the template string. + /// + /// The line of text containing the LogError call. + /// The position to start searching from. + /// True if this is LogError with exception parameter, false otherwise. + private bool HasExceptionParameterBeforeTemplate(string line, int searchStart) + { + // Look for the parameter structure by finding the first comma at depth 1 + // LogError(exception, "message", args...) has a comma before the first string + // LogError("message", args...) has the string as the first parameter + + int pos = searchStart; + int parenDepth = 1; // We start after LogError( + bool foundCommaBeforeString = false; + + while (pos < line.Length && parenDepth > 0) + { + char c = line[pos]; + + if (c == '(') + { + parenDepth++; + } + else if (c == ')') + { + parenDepth--; + if (parenDepth == 0) break; + } + else if (c == ',' && parenDepth == 1) + { + foundCommaBeforeString = true; + } + else if (c == '"' && parenDepth == 1) + { + // Found a string - return whether we found a comma before it + return foundCommaBeforeString; + } + + pos++; + } + + return false; + } + + /// + /// Searches previous lines to find a Serilog method call when the current line is inside a multi-line template. + /// + /// The text snapshot to search in. + /// The current line where the cursor is positioned. + /// A tuple of the Serilog match and the line it was found on, or null if not found. + private (Match Match, ITextSnapshotLine Line)? FindSerilogCallInPreviousLines(ITextSnapshot snapshot, ITextSnapshotLine currentLine) + { + // Look backward up to 10 lines to find a Serilog call + for (int i = currentLine.LineNumber - 1; i >= Math.Max(0, currentLine.LineNumber - 10); i--) + { + var checkLine = snapshot.GetLineFromLineNumber(i); + var checkText = checkLine.GetText(); + + // Check if this line contains a Serilog call + var match = SerilogCallDetector.FindSerilogCall(checkText); + if (match != null) + { + // Verify we're actually inside a multi-line template by checking if the call is still open + if (IsInsideMultiLineTemplate(snapshot, checkLine, currentLine)) + { + return (match, checkLine); + } + } + } + + return null; + } + + /// + /// Determines if the current line is inside a multi-line template that started on the Serilog call line. + /// + /// The text snapshot. + /// The line containing the Serilog method call. + /// The current line where the cursor is positioned. + /// True if inside a multi-line template, false otherwise. + private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine) + { + // Count string delimiters to determine if we're inside a multi-line string + bool inString = false; + bool inVerbatimString = false; + bool inRawString = false; + int rawStringQuoteCount = 0; + + for (int lineNum = serilogCallLine.LineNumber; lineNum <= currentLine.LineNumber; lineNum++) + { + var line = snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + + for (int i = 0; i < lineText.Length; i++) + { + char c = lineText[i]; + + if (!inString && !inVerbatimString && !inRawString) + { + // Check for start of raw string (""") + if (i + 2 < lineText.Length && lineText.Substring(i, 3) == "\"\"\"") + { + inRawString = true; + rawStringQuoteCount = 3; + i += 2; // Skip next 2 quotes + continue; + } + // Check for verbatim string (@") + else if (i + 1 < lineText.Length && c == '@' && lineText[i + 1] == '"') + { + inVerbatimString = true; + i++; // Skip the quote + continue; + } + // Check for regular string + else if (c == '"') + { + inString = true; + continue; + } + } + else if (inRawString) + { + // Look for end of raw string + 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) + { + // In verbatim string, "" is escaped quote + if (c == '"') + { + if (i + 1 < lineText.Length && lineText[i + 1] == '"') + { + i++; // Skip escaped quote + } + else + { + inVerbatimString = false; + } + } + } + else if (inString) + { + // Regular string with \ escapes + if (c == '\\' && i + 1 < lineText.Length) + { + i++; // Skip escaped character + } + else if (c == '"') + { + inString = false; + } + } + } + + // If we've reached the current line and we're inside a string, return true + if (lineNum == currentLine.LineNumber && (inString || inVerbatimString || inRawString)) + { + return true; + } + } + + return false; + } + + /// + /// Reconstructs the full template string from a multi-line Serilog call. + /// + /// The text snapshot. + /// The line containing the Serilog method call. + /// The current line where the cursor is positioned. + /// The reconstructed template with start and end positions, or null if reconstruction fails. + private (string Template, int StartPosition, int EndPosition)? ReconstructMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine serilogCallLine, ITextSnapshotLine currentLine) + { + var templateBuilder = new System.Text.StringBuilder(); + int templateStartPosition = -1; + + bool foundTemplateStart = false; + bool inString = false; + bool inVerbatimString = false; + bool inRawString = false; + int rawStringQuoteCount = 0; + + for (int lineNum = serilogCallLine.LineNumber; lineNum <= currentLine.LineNumber + 5 && lineNum < snapshot.LineCount; lineNum++) + { + var line = snapshot.GetLineFromLineNumber(lineNum); + var lineText = line.GetText(); + + for (int i = 0; i < lineText.Length; i++) + { + char c = lineText[i]; + int absolutePosition = line.Start.Position + i; + + if (!foundTemplateStart && !inString && !inVerbatimString && !inRawString) + { + // Look for template start + if (i + 2 < lineText.Length && lineText.Substring(i, 3) == "\"\"\"") + { + // Raw string start + foundTemplateStart = true; + inRawString = true; + rawStringQuoteCount = 3; + templateStartPosition = absolutePosition + 3; // Position after opening """ + i += 2; // Skip next 2 quotes + continue; + } + else if (i + 1 < lineText.Length && c == '@' && lineText[i + 1] == '"') + { + // Verbatim string start + foundTemplateStart = true; + inVerbatimString = true; + templateStartPosition = absolutePosition + 2; // Position after @" + i++; // Skip the quote + continue; + } + else if (c == '"') + { + // Regular string start + foundTemplateStart = true; + inString = true; + templateStartPosition = absolutePosition + 1; // Position after opening " + continue; + } + } + else if (foundTemplateStart) + { + if (inRawString) + { + if (c == '"') + { + int consecutiveQuotes = 1; + while (i + consecutiveQuotes < lineText.Length && lineText[i + consecutiveQuotes] == '"') + consecutiveQuotes++; + + if (consecutiveQuotes >= rawStringQuoteCount) + { + // End of raw string + return (templateBuilder.ToString(), templateStartPosition, absolutePosition + consecutiveQuotes); + } + } + templateBuilder.Append(c); + } + else if (inVerbatimString) + { + if (c == '"') + { + if (i + 1 < lineText.Length && lineText[i + 1] == '"') + { + // Escaped quote in verbatim string + templateBuilder.Append("\"\""); + i++; // Skip next quote + } + else + { + // End of verbatim string + return (templateBuilder.ToString(), templateStartPosition, absolutePosition + 1); + } + } + else + { + templateBuilder.Append(c); + } + } + else if (inString) + { + if (c == '\\' && i + 1 < lineText.Length) + { + // Escaped character in regular string + templateBuilder.Append(c); + i++; + if (i < lineText.Length) + templateBuilder.Append(lineText[i]); + } + else if (c == '"') + { + // End of regular string + return (templateBuilder.ToString(), templateStartPosition, absolutePosition + 1); + } + else + { + templateBuilder.Append(c); + } + } + } + } + + // Add actual line ending for multi-line strings to preserve character positions + if (foundTemplateStart && (inVerbatimString || inRawString) && lineNum < snapshot.LineCount - 1) + { + var nextLine = snapshot.GetLineFromLineNumber(lineNum + 1); + if (nextLine.LineNumber <= currentLine.LineNumber + 5) + { + // Get the actual line break text from the snapshot + var lineBreakStart = line.End.Position; + var lineBreakEnd = nextLine.Start.Position; + if (lineBreakEnd > lineBreakStart) + { + var lineBreakText = snapshot.GetText(lineBreakStart, lineBreakEnd - lineBreakStart); + templateBuilder.Append(lineBreakText); + } + } + } + } + + return null; // Template reconstruction failed + } + + /// + /// Finds arguments in a multi-line Serilog call where the template spans multiple lines. + /// + /// The text snapshot. + /// The absolute position where the template ends. + /// The zero-based index of the argument to find. + /// A tuple of (absolute position, length) of the argument, or null if not found. + private (int, int)? FindArgumentInMultiLineCall(ITextSnapshot snapshot, int templateEndPosition, int argumentIndex) + { + var allArguments = new List<(int absolutePosition, int length)>(); + + // Start searching from the line containing the template end position + var templateEndLine = snapshot.GetLineFromPosition(templateEndPosition); + + // Parse any arguments on the template end line (after the template ends) + var templateEndLineText = templateEndLine.GetText(); + var templateEndInLine = templateEndPosition - templateEndLine.Start.Position; + + if (templateEndInLine < templateEndLineText.Length) + { + // Find the comma that starts the arguments (after the template) + 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)); + } + } + } + + // Continue searching subsequent lines until we find closing parenthesis + 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; + } + /// /// Finds the location of an argument at the specified position. /// @@ -288,6 +774,83 @@ private int GetArgumentIndex(List properties, TemplateProperty return null; } + /// + /// Finds arguments in subsequent lines for multi-line method calls. + /// + /// The current line containing the template. + /// The end position of the template string on the current line. + /// The zero-based index of the argument to find. + /// A tuple of (absolute position, length) of the argument, or null if not found. + private (int, int)? FindArgumentInSubsequentLines(ITextSnapshotLine currentLine, int templateEnd, int argumentIndex) + { + var currentLineText = currentLine.GetText(); + + // Check if current line ends with comma (indicating multi-line call) + var commaIndex = currentLineText.IndexOf(',', templateEnd); + if (commaIndex < 0) + return null; + + // Look for arguments starting from the next line + var snapshot = currentLine.Snapshot; + var currentLineNumber = currentLine.LineNumber; + var allArguments = new List<(int absolutePosition, int length)>(); + + // Parse any arguments on the current line after the comma + var currentLineArguments = ParseArguments(currentLineText, commaIndex + 1); + foreach (var (start, length) in currentLineArguments) + { + allArguments.Add((currentLine.Start.Position + start, length)); + } + + // Continue searching subsequent lines until we find closing parenthesis or reach end + for (int lineNum = currentLineNumber + 1; lineNum < snapshot.LineCount; lineNum++) + { + var nextLine = snapshot.GetLineFromLineNumber(lineNum); + var originalLineText = nextLine.GetText(); + var nextLineText = originalLineText.TrimStart(); + + if (string.IsNullOrEmpty(nextLineText)) + continue; + + // Calculate the offset from the start of the line to where the trimmed content begins + var trimOffset = originalLineText.Length - nextLineText.Length; + + // Check if line contains closing parenthesis (end of method call) + var closingParenIndex = nextLineText.IndexOf(");"); + if (closingParenIndex >= 0) + { + // Parse arguments on this final line + var finalLineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in finalLineArguments) + { + // Only add if before closing paren + if (start < closingParenIndex) + { + allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); + } + } + break; + } + else + { + // Parse all arguments on this line + var lineArguments = ParseArguments(nextLineText, 0); + foreach (var (start, length) in lineArguments) + { + allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); + } + } + } + + // Return the argument at the requested index + if (argumentIndex < allArguments.Count) + { + return allArguments[argumentIndex]; + } + + return null; + } + /// /// Parses comma-separated arguments from a method call, handling nested structures. /// @@ -434,6 +997,9 @@ internal class NavigateToArgumentAction( string propertyName, PropertyType propertyType) : ISuggestedAction { + public int ArgumentStart => position; + public int ArgumentLength => length; + public string DisplayText => propertyType == PropertyType.Positional ? $"Navigate to argument at position {propertyName}" : $"Navigate to '{propertyName}' argument"; From d0d47b748ff25ec4722bc7953bf37dc45ac60078 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 23:59:12 -0700 Subject: [PATCH 2/2] fix: Address review comments for multi-line navigation - Fix template end position consistency across string types - Extract repeated commaIndex + 1 calculation into ParseArgumentsAfterComma helper - Make ArgumentStart/ArgumentLength properties internal for better encapsulation --- .../Navigation/SerilogNavigationProvider.cs | 131 +++--------------- 1 file changed, 18 insertions(+), 113 deletions(-) diff --git a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs index be92241..6e8bf9d 100644 --- a/SerilogSyntax/Navigation/SerilogNavigationProvider.cs +++ b/SerilogSyntax/Navigation/SerilogNavigationProvider.cs @@ -621,7 +621,7 @@ private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine else { // End of verbatim string - return (templateBuilder.ToString(), templateStartPosition, absolutePosition + 1); + return (templateBuilder.ToString(), templateStartPosition, absolutePosition); } } else @@ -642,7 +642,7 @@ private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine else if (c == '"') { // End of regular string - return (templateBuilder.ToString(), templateStartPosition, absolutePosition + 1); + return (templateBuilder.ToString(), templateStartPosition, absolutePosition); } else { @@ -673,6 +673,17 @@ private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine return null; // Template reconstruction failed } + /// + /// Parses arguments starting after a comma delimiter. + /// + /// The line text to parse. + /// The index of the comma delimiter. + /// List of argument positions and lengths relative to the line start. + private List<(int start, int length)> ParseArgumentsAfterComma(string lineText, int commaIndex) + { + return commaIndex >= 0 ? ParseArguments(lineText, commaIndex + 1) : []; + } + /// /// Finds arguments in a multi-line Serilog call where the template spans multiple lines. /// @@ -695,13 +706,10 @@ private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine { // Find the comma that starts the arguments (after the template) var commaIndex = templateEndLineText.IndexOf(',', templateEndInLine); - if (commaIndex >= 0) + var endLineArguments = ParseArgumentsAfterComma(templateEndLineText, commaIndex); + foreach (var (start, length) in endLineArguments) { - var endLineArguments = ParseArguments(templateEndLineText, commaIndex + 1); - foreach (var (start, length) in endLineArguments) - { - allArguments.Add((templateEndLine.Start.Position + start, length)); - } + allArguments.Add((templateEndLine.Start.Position + start, length)); } } @@ -748,109 +756,6 @@ private bool IsInsideMultiLineTemplate(ITextSnapshot snapshot, ITextSnapshotLine return null; } - /// - /// Finds the location of an argument at the specified position. - /// - /// The line containing the arguments. - /// The end position of the template string. - /// The zero-based index of the argument to find. - /// A tuple of (start position, length) of the argument, or null if not found. - private (int, int)? FindArgumentByPosition(string line, int templateEnd, int argumentIndex) - { - // Find the start of arguments (after the template string) - var argumentsStart = line.IndexOf(',', templateEnd); - if (argumentsStart < 0) - return null; - - // Parse comma-separated arguments, accounting for nested parentheses and brackets - var arguments = ParseArguments(line, argumentsStart + 1); - - if (argumentIndex < arguments.Count) - { - var (start, length) = arguments[argumentIndex]; - return (start, length); - } - - return null; - } - - /// - /// Finds arguments in subsequent lines for multi-line method calls. - /// - /// The current line containing the template. - /// The end position of the template string on the current line. - /// The zero-based index of the argument to find. - /// A tuple of (absolute position, length) of the argument, or null if not found. - private (int, int)? FindArgumentInSubsequentLines(ITextSnapshotLine currentLine, int templateEnd, int argumentIndex) - { - var currentLineText = currentLine.GetText(); - - // Check if current line ends with comma (indicating multi-line call) - var commaIndex = currentLineText.IndexOf(',', templateEnd); - if (commaIndex < 0) - return null; - - // Look for arguments starting from the next line - var snapshot = currentLine.Snapshot; - var currentLineNumber = currentLine.LineNumber; - var allArguments = new List<(int absolutePosition, int length)>(); - - // Parse any arguments on the current line after the comma - var currentLineArguments = ParseArguments(currentLineText, commaIndex + 1); - foreach (var (start, length) in currentLineArguments) - { - allArguments.Add((currentLine.Start.Position + start, length)); - } - - // Continue searching subsequent lines until we find closing parenthesis or reach end - for (int lineNum = currentLineNumber + 1; lineNum < snapshot.LineCount; lineNum++) - { - var nextLine = snapshot.GetLineFromLineNumber(lineNum); - var originalLineText = nextLine.GetText(); - var nextLineText = originalLineText.TrimStart(); - - if (string.IsNullOrEmpty(nextLineText)) - continue; - - // Calculate the offset from the start of the line to where the trimmed content begins - var trimOffset = originalLineText.Length - nextLineText.Length; - - // Check if line contains closing parenthesis (end of method call) - var closingParenIndex = nextLineText.IndexOf(");"); - if (closingParenIndex >= 0) - { - // Parse arguments on this final line - var finalLineArguments = ParseArguments(nextLineText, 0); - foreach (var (start, length) in finalLineArguments) - { - // Only add if before closing paren - if (start < closingParenIndex) - { - allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); - } - } - break; - } - else - { - // Parse all arguments on this line - var lineArguments = ParseArguments(nextLineText, 0); - foreach (var (start, length) in lineArguments) - { - allArguments.Add((nextLine.Start.Position + trimOffset + start, length)); - } - } - } - - // Return the argument at the requested index - if (argumentIndex < allArguments.Count) - { - return allArguments[argumentIndex]; - } - - return null; - } - /// /// Parses comma-separated arguments from a method call, handling nested structures. /// @@ -997,8 +902,8 @@ internal class NavigateToArgumentAction( string propertyName, PropertyType propertyType) : ISuggestedAction { - public int ArgumentStart => position; - public int ArgumentLength => length; + internal int ArgumentStart => position; + internal int ArgumentLength => length; public string DisplayText => propertyType == PropertyType.Positional ? $"Navigate to argument at position {propertyName}"