Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public void Method(string? value)

// Use value here
}

// ✅ Use GetRequiredAbsoluteIndex for converting LinePosition to absolute index
// This correctly handles positions past the end of the file per LSP spec
var absoluteIndex = sourceText.GetRequiredAbsoluteIndex(linePosition);
// ❌ Don't use: sourceText.Lines.GetPosition(linePosition)
```

### Testing Patterns
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;
using CSharpSyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind;

namespace Microsoft.CodeAnalysis.Razor.GoToDefinition;

Expand Down Expand Up @@ -100,4 +104,91 @@ private async Task<LspRange> GetNavigateRangeAsync(IDocumentSnapshot documentSna
// at least then press F7 to go there.
return LspFactory.DefaultRange;
}

public async Task<LspLocation[]?> TryGetDefinitionFromStringLiteralAsync(
IDocumentSnapshot documentSnapshot,
LinePosition position,
CancellationToken cancellationToken)
{
_logger.LogDebug($"Attempting to get definition from string literal at position {position}.");

// Get the C# syntax tree to analyze the string literal
var syntaxTree = await documentSnapshot.GetCSharpSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false);
var sourceText = await syntaxTree.GetTextAsync(cancellationToken).ConfigureAwait(false);

// Convert position to absolute index
var absoluteIndex = sourceText.GetRequiredAbsoluteIndex(position);

// Find the token at the current position
var token = root.FindToken(absoluteIndex);

// Check if we're in a string literal
if (token.IsKind(CSharpSyntaxKind.StringLiteralToken))
{
var literalText = token.ValueText;
_logger.LogDebug($"Found string literal: {literalText}");

// Only process if it looks like a Razor file path
if (literalText.IsRazorFilePath())
{
// Try to resolve the file path
if (TryResolveFilePath(documentSnapshot, literalText, out var resolvedPath))
{
_logger.LogDebug($"Resolved file path: {resolvedPath}");
return [LspFactory.CreateLocation(resolvedPath, LspFactory.DefaultRange)];
}
}
}

return null;
}

private bool TryResolveFilePath(IDocumentSnapshot documentSnapshot, string filePath, out string resolvedPath)
{
resolvedPath = string.Empty;

if (string.IsNullOrWhiteSpace(filePath))
{
return false;
}

var project = documentSnapshot.Project;

// Handle tilde paths (~/ or ~\) - these are relative to the project root
if (filePath is ['~', '/' or '\\', ..])
{
var projectDirectory = Path.GetDirectoryName(project.FilePath);
if (projectDirectory is null)
{
return false;
}

// Remove the tilde and normalize path separators
var relativePath = filePath.Substring(2).Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var candidatePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath));

if (project.ContainsDocument(candidatePath))
{
resolvedPath = candidatePath;
return true;
}
}

// Handle relative paths - relative to the current document
var currentDocumentDirectory = Path.GetDirectoryName(documentSnapshot.FilePath);
if (currentDocumentDirectory is not null)
{
var normalizedPath = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var candidatePath = Path.GetFullPath(Path.Combine(currentDocumentDirectory, normalizedPath));

if (project.ContainsDocument(candidatePath))
{
resolvedPath = candidatePath;
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.Razor.GoToDefinition;

Expand All @@ -20,4 +21,9 @@ internal interface IDefinitionService
bool ignoreComponentAttributes,
bool includeMvcTagHelpers,
CancellationToken cancellationToken);

Task<LspLocation[]?> TryGetDefinitionFromStringLiteralAsync(
IDocumentSnapshot documentSnapshot,
LinePosition position,
CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg
return Results(componentLocations);
}

// Check if we're in a string literal with a file path (before calling C# which would navigate to String class)
if (positionInfo.LanguageKind is RazorLanguageKind.CSharp)
{
var stringLiteralLocations = await _definitionService.TryGetDefinitionFromStringLiteralAsync(
context.Snapshot,
positionInfo.Position.ToLinePosition(),
cancellationToken)
.ConfigureAwait(false);

if (stringLiteralLocations is { Length: > 0 })
{
return Results(stringLiteralLocations);
}
}

if (positionInfo.LanguageKind is RazorLanguageKind.Html or RazorLanguageKind.Razor)
{
// If it isn't a Razor construct, and it isn't C#, let the server know to delegate to HTML.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,111 @@ private async Task VerifyGoToDefinitionAsync(
Assert.Equal(document.CreateUri(), location.DocumentUri.GetRequiredParsedUri());
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/4325")]
public async Task StringLiteral_TildePath()
{
var input = """
@{
Html.Partial("~/Views/Shared/_Pa$$rtial.cshtml");
}
""";

var partialFileContent = """
<div>This is a partial view</div>
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Legacy,
additionalFiles: (FileName("Views/Shared/_Partial.cshtml"), partialFileContent));

Assert.NotNull(result.Value.Second);
var locations = result.Value.Second;
var location = Assert.Single(locations);

Assert.Equal(FileUri("Views/Shared/_Partial.cshtml"), location.DocumentUri.GetRequiredParsedUri());
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/4325")]
public async Task StringLiteral_RelativePath()
{
var input = """
@{
Html.Partial("_Pa$$rtial.cshtml");
}
""";

var partialFileContent = """
<div>This is a partial view</div>
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Legacy,
additionalFiles: (FileName("_Partial.cshtml"), partialFileContent));

Assert.NotNull(result.Value.Second);
var locations = result.Value.Second;
var location = Assert.Single(locations);

Assert.Equal(FileUri("_Partial.cshtml"), location.DocumentUri.GetRequiredParsedUri());
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/4325")]
public async Task StringLiteral_NonExistentFile()
{
var input = """
@{
Html.Partial("~/Views/Shared/_NonExistent$$File.cshtml");
}
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Legacy);

// Should return null if file doesn't exist
Assert.Null(result);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/4325")]
public async Task StringLiteral_RazorComponent()
{
var input = """
@{
var path = "~/Pages/Cou$$nter.razor";
}
""";

var counterFileContent = """
@page "/counter"

<h1>Counter</h1>

@code {
private int currentCount = 0;
}
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Component,
additionalFiles: (FileName("Pages/Counter.razor"), counterFileContent));

Assert.NotNull(result.Value.Second);
var locations = result.Value.Second;
var location = Assert.Single(locations);

Assert.Equal(FileUri("Pages/Counter.razor"), location.DocumentUri.GetRequiredParsedUri());
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/4325")]
public async Task StringLiteral_NotInString()
{
var input = """
@{
var $$ foo = "bar";
}
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Legacy);

// Should return null when not in a string literal
Assert.Null(result);
}

private async Task<SumType<LspLocation, LspLocation[], DocumentLink[]>?> GetGoToDefinitionResultAsync(
TestCode input,
RazorFileKind? fileKind = null,
Expand Down
Loading