Skip to content

Commit 71676b1

Browse files
authored
Add Go To Definition support for file paths in string literals (#12323)
- [x] Analyze existing Go To Definition flow and understand the architecture - [x] Add logic to detect string literals containing file paths in RemoteGoToDefinitionService - [x] Implement file path resolution and validation logic in AbstractDefinitionService - [x] Add tests in CohostGoToDefinitionEndpointTest.cs for various scenarios - [x] Build and validate compilation - [x] Address PR feedback (all rounds): - [x] Renamed method to `TryGetDefinitionFromStringLiteralAsync` - [x] Changed log messages to debug level - [x] Use `GetRequiredAbsoluteIndex` instead of `sourceText.Lines.GetPosition` - [x] Added file extension check (.cshtml/.razor) before path resolution - [x] Removed absolute path handling - [x] Use `ContainsDocument` instead of `TryGetDocument` - [x] Added WorkItem attributes to all new tests - [x] Added entry to copilot-instructions.md about GetRequiredAbsoluteIndex - [x] Updated tests to use local `FileName` helper - [x] Use `IsRazorFilePath()` extension method - [x] Use pattern matching for tilde path check - [x] Removed `DocumentFilePaths` checks (redundant with `ContainsDocument`) - [x] Move string literal check before C# Go-to-Definition call - [x] Merged main branch (test files moved to new location) - [x] Move IsRazorFilePath check into TryResolveFilePath method for better encapsulation - [x] Remove tests with incorrect expectations - [x] Merge main again and fix build errors ## Implementation Summary Added support for navigating to Razor/CSHTML files by filename string in Go To Definition (F12). When the cursor is in a string literal containing a file path (e.g., `@Html.Partial("~/Views/xy.cshtml")`), pressing F12 will navigate to that file. ### Key Features: - Supports tilde paths (`~/...`) relative to project root - Supports relative paths (relative to current file) - Works with both .razor and .cshtml files - Only processes strings with .cshtml or .razor extensions ### Technical Approach: - Checks for string literals BEFORE calling C# Go-to-Definition (prevents navigation to String class when file exists) - Uses `IsRazorFilePath()` extension method for file type checking (inside TryResolveFilePath) - Uses pattern matching for tilde path detection - Only modifies Workspaces and Remote layers (as per requirements) ### Test Status: All 7 tests now pass (includes 4 new tests from main merge): - ✅ StringLiteral_TildePath - Navigates to file using tilde path - ✅ StringLiteral_RelativePath - Navigates to file using relative path - ✅ StringLiteral_RazorComponent - Navigates to .razor component file - ✅ StringLiteral_NotFileReference (4 variants) - Correctly ignores non-file strings Fixes #4325 <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Navigating to partial razor files by filename string</issue_title> > <issue_description>_This issue has been moved from [a ticket on Developer Community](https://developercommunity.visualstudio.com/t/Navigating-to-partial-razor-files-by-fil/1479773)._ > > --- > In our codebase we often break webpages down by adding components with the following snippet: > > ``` > @Html.Partial("~/Views/xy.cshtml") > ``` > It would be very useful if you could navigate to the specified .cshtml file by putting the caret in the string, and pressing F12, as you often do with other code sections. > > --- > ### Original Comments > > #### Feedback Bot on 7/20/2021, 06:59 PM: > > Thank you for taking the time to provide your suggestion. We will do some preliminary checks to make sure we can proceed further. We'll provide an update once the issue has been triaged by the product team. > > #### Feedback Bot on 8/4/2021, 03:55 PM: > > <p>Your suggestion has been queued up for prioritization. Feature suggestions are prioritized based on the value to our broader developer community and the product roadmap. We may not be able to pursue this one immediately, but we will continue to monitor it for community input</p> > </issue_description> > > <agent_instructions>Don't make any changes to the compiler layer, or to the Microsoft.AspNetCore.Razor.LanguageServer project. Changes should be focused in services in either the Workspace or Remote projects. No new services or endpoints will be needed. Comprehensive tests should go in the existing CohostGoToDefinitionEndpointTest.cs file.</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> Fixes #4325 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/dotnet/razor/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.
2 parents a7b8b2a + cd9fea8 commit 71676b1

File tree

5 files changed

+236
-23
lines changed

5 files changed

+236
-23
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Razor documents contain multiple languages:
3737
- Prefer immutable collection types and pooled collections where possible
3838
- Use `using` statements for disposable resources
3939
- Ensure proper async/await patterns, avoid `Task.Wait()`
40+
- Use GetRequiredAbsoluteIndex for converting positions to absolute indexes
4041

4142
### Testing Patterns
4243

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/AbstractDefinitionService.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.IO;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.AspNetCore.Razor.Language;
89
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
910
using Microsoft.CodeAnalysis.Razor.Logging;
1011
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
1112
using Microsoft.CodeAnalysis.Razor.Workspaces;
13+
using Microsoft.CodeAnalysis.Text;
14+
using CSharpSyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind;
1215

1316
namespace Microsoft.CodeAnalysis.Razor.GoToDefinition;
1417

@@ -100,4 +103,93 @@ private async Task<LspRange> GetNavigateRangeAsync(IDocumentSnapshot documentSna
100103
// at least then press F7 to go there.
101104
return LspFactory.DefaultRange;
102105
}
106+
107+
public async Task<LspLocation[]?> TryGetDefinitionFromStringLiteralAsync(
108+
IDocumentSnapshot documentSnapshot,
109+
Position position,
110+
CancellationToken cancellationToken)
111+
{
112+
_logger.LogDebug($"Attempting to get definition from string literal at position {position}.");
113+
114+
// Get the C# syntax tree to analyze the string literal
115+
var syntaxTree = await documentSnapshot.GetCSharpSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
116+
var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false);
117+
var sourceText = await syntaxTree.GetTextAsync(cancellationToken).ConfigureAwait(false);
118+
119+
// Convert position to absolute index
120+
var absoluteIndex = sourceText.GetRequiredAbsoluteIndex(position);
121+
122+
// Find the token at the current position
123+
var token = root.FindToken(absoluteIndex);
124+
125+
// Check if we're in a string literal
126+
if (token.IsKind(CSharpSyntaxKind.StringLiteralToken))
127+
{
128+
var literalText = token.ValueText;
129+
_logger.LogDebug($"Found string literal: {literalText}");
130+
131+
// Try to resolve the file path
132+
if (TryResolveFilePath(documentSnapshot, literalText, out var resolvedPath))
133+
{
134+
_logger.LogDebug($"Resolved file path: {resolvedPath}");
135+
return [LspFactory.CreateLocation(resolvedPath, LspFactory.DefaultRange)];
136+
}
137+
}
138+
139+
return null;
140+
}
141+
142+
private bool TryResolveFilePath(IDocumentSnapshot documentSnapshot, string filePath, out string resolvedPath)
143+
{
144+
resolvedPath = string.Empty;
145+
146+
if (string.IsNullOrWhiteSpace(filePath))
147+
{
148+
return false;
149+
}
150+
151+
// Only process if it looks like a Razor file path
152+
if (!filePath.IsRazorFilePath())
153+
{
154+
return false;
155+
}
156+
157+
var project = documentSnapshot.Project;
158+
159+
// Handle tilde paths (~/ or ~\) - these are relative to the project root
160+
if (filePath is ['~', '/' or '\\', ..])
161+
{
162+
var projectDirectory = Path.GetDirectoryName(project.FilePath);
163+
if (projectDirectory is null)
164+
{
165+
return false;
166+
}
167+
168+
// Remove the tilde and normalize path separators
169+
var relativePath = filePath.Substring(2).Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
170+
var candidatePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath));
171+
172+
if (project.ContainsDocument(candidatePath))
173+
{
174+
resolvedPath = candidatePath;
175+
return true;
176+
}
177+
}
178+
179+
// Handle relative paths - relative to the current document
180+
var currentDocumentDirectory = Path.GetDirectoryName(documentSnapshot.FilePath);
181+
if (currentDocumentDirectory is not null)
182+
{
183+
var normalizedPath = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
184+
var candidatePath = Path.GetFullPath(Path.Combine(currentDocumentDirectory, normalizedPath));
185+
186+
if (project.ContainsDocument(candidatePath))
187+
{
188+
resolvedPath = candidatePath;
189+
return true;
190+
}
191+
}
192+
193+
return false;
194+
}
103195
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/IDefinitionService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ internal interface IDefinitionService
2020
bool ignoreComponentAttributes,
2121
bool includeMvcTagHelpers,
2222
CancellationToken cancellationToken);
23+
24+
Task<LspLocation[]?> TryGetDefinitionFromStringLiteralAsync(
25+
IDocumentSnapshot documentSnapshot,
26+
Position position,
27+
CancellationToken cancellationToken);
2328
}

src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RemoteGoToDefinitionService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg
7575
return Results(componentLocations);
7676
}
7777

78+
// Check if we're in a string literal with a file path (before calling C# which would navigate to String class)
79+
if (positionInfo.LanguageKind is RazorLanguageKind.CSharp)
80+
{
81+
var stringLiteralLocations = await _definitionService.TryGetDefinitionFromStringLiteralAsync(
82+
context.Snapshot,
83+
positionInfo.Position,
84+
cancellationToken)
85+
.ConfigureAwait(false);
86+
87+
if (stringLiteralLocations is { Length: > 0 })
88+
{
89+
return Results(stringLiteralLocations);
90+
}
91+
}
92+
7893
if (positionInfo.LanguageKind is RazorLanguageKind.Html or RazorLanguageKind.Razor)
7994
{
8095
// If it isn't a Razor construct, and it isn't C#, let the server know to delegate to HTML.

0 commit comments

Comments
 (0)