File: GoToDefinition\AbstractDefinitionService.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
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;
 
internal abstract class AbstractDefinitionService(
    IRazorComponentSearchEngine componentSearchEngine,
    ITagHelperSearchEngine? tagHelperSearchEngine,
    IDocumentMappingService documentMappingService,
    ILogger logger) : IDefinitionService
{
    private readonly IRazorComponentSearchEngine _componentSearchEngine = componentSearchEngine;
    private readonly ITagHelperSearchEngine? _tagHelperSearchEngine = tagHelperSearchEngine;
    private readonly IDocumentMappingService _documentMappingService = documentMappingService;
    private readonly ILogger _logger = logger;
 
    public async Task<LspLocation[]?> GetDefinitionAsync(
        IDocumentSnapshot documentSnapshot,
        DocumentPositionInfo positionInfo,
        ISolutionQueryOperations solutionQueryOperations,
        bool includeMvcTagHelpers,
        CancellationToken cancellationToken)
    {
        if (!includeMvcTagHelpers && !documentSnapshot.FileKind.IsComponent())
        {
            _logger.LogInformation($"'{documentSnapshot.FileKind}' is not a component type.");
            return null;
        }
 
        var codeDocument = await documentSnapshot.GetGeneratedOutputAsync(cancellationToken).ConfigureAwait(false);
 
        if (!RazorComponentDefinitionHelpers.TryGetBoundTagHelpers(codeDocument, positionInfo.HostDocumentIndex, _logger, out var boundTagHelperResults))
        {
            _logger.LogInformation($"Could not retrieve bound tag helper information.");
            return null;
        }
 
        if (includeMvcTagHelpers)
        {
            Debug.Assert(_tagHelperSearchEngine is not null, "If includeMvcTagHelpers is true, _tagHelperSearchEngine must not be null.");
 
            var tagHelperLocations = await _tagHelperSearchEngine.TryLocateTagHelperDefinitionsAsync(boundTagHelperResults, documentSnapshot, solutionQueryOperations, cancellationToken).ConfigureAwait(false);
            if (tagHelperLocations is { Length: > 0 })
            {
                return tagHelperLocations;
            }
        }
 
        // For Razor components, there can only ever be one tag helper result
        var (boundTagHelper, boundAttribute) = boundTagHelperResults[0];
 
        var componentDocument = await _componentSearchEngine
            .TryLocateComponentAsync(boundTagHelper, solutionQueryOperations, cancellationToken)
            .ConfigureAwait(false);
 
        if (componentDocument is null)
        {
            _logger.LogInformation($"Could not locate component document.");
            return null;
        }
 
        var componentFilePath = componentDocument.FilePath;
 
        _logger.LogInformation($"Definition found at file path: {componentFilePath}");
 
        var range = await GetNavigateRangeAsync(componentDocument, boundAttribute, cancellationToken).ConfigureAwait(false);
 
        return [LspFactory.CreateLocation(componentFilePath, range)];
    }
 
    private async Task<LspRange> GetNavigateRangeAsync(IDocumentSnapshot documentSnapshot, BoundAttributeDescriptor? attributeDescriptor, CancellationToken cancellationToken)
    {
        if (attributeDescriptor is not null)
        {
            _logger.LogInformation($"Attempting to get definition from an attribute directly.");
 
            var range = await RazorComponentDefinitionHelpers
                .TryGetPropertyRangeAsync(documentSnapshot, attributeDescriptor.PropertyName, _documentMappingService, _logger, cancellationToken)
                .ConfigureAwait(false);
 
            if (range is not null)
            {
                return range;
            }
        }
 
        // When navigating from a start or end tag, we just take the user to the top of the file.
        // If we were trying to navigate to a property, and we couldn't find it, we can at least take
        // them to the file for the component. If the property was defined in a partial class they can
        // at least then press F7 to go there.
        return LspFactory.DefaultRange;
    }
 
    public async Task<LspLocation[]?> TryGetDefinitionFromStringLiteralAsync(
        IDocumentSnapshot documentSnapshot,
        Position 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}");
 
            // 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;
        }
 
        // Only process if it looks like a Razor file path
        if (!filePath.IsRazorFilePath())
        {
            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;
    }
}