Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public static void AddCompletionServices(this IServiceCollection services)
services.AddSingleton<IRazorCompletionItemProvider, DirectiveAttributeTransitionCompletionItemProvider>();
services.AddSingleton<IRazorCompletionItemProvider, MarkupTransitionCompletionItemProvider>();
services.AddSingleton<IRazorCompletionItemProvider, TagHelperCompletionProvider>();
services.AddSingleton<IRazorCompletionItemProvider, BlazorDataAttributeCompletionItemProvider>();
}

public static void AddDiagnosticServices(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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.Collections.Immutable;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.Tooltip;
using Microsoft.VisualStudio.Editor.Razor;

namespace Microsoft.CodeAnalysis.Razor.Completion;

/// <summary>
/// Provides completions for Blazor-specific data-* attributes used for enhanced navigation and form handling.
/// </summary>
internal class BlazorDataAttributeCompletionItemProvider : IRazorCompletionItemProvider
{
private static readonly ImmutableArray<RazorCommitCharacter> AttributeCommitCharacters = RazorCommitCharacter.CreateArray(["="]);
private static readonly ImmutableArray<RazorCommitCharacter> AttributeSnippetCommitCharacters = RazorCommitCharacter.CreateArray(["="], insert: false);

// Define the Blazor-specific data attributes
private static readonly ImmutableArray<(string Name, string Description)> s_blazorDataAttributes =
[
("data-enhance", "Opts in to enhanced form handling for a form element."),
("data-enhance-nav", "Disables enhanced navigation for a link or DOM subtree."),
("data-permanent", "Marks an element to be preserved when handling enhanced navigation or form requests.")
];

public ImmutableArray<RazorCompletionItem> GetCompletionItems(RazorCompletionContext context)
{
// Only provide completions for component files
if (!context.SyntaxTree.Options.FileKind.IsComponent())
{
return [];
}

var owner = CompletionContextHelper.AdjustSyntaxNodeForCompletion(context.Owner);
if (owner is null)
{
return [];
}

// Check if we're in an attribute context
if (!HtmlFacts.TryGetAttributeInfo(
owner,
out var containingTagNameToken,
out var prefixLocation,
out var selectedAttributeName,
out var selectedAttributeNameLocation,
out var attributes))
{
return [];
}

// Only provide completions when we're completing an attribute name
if (!CompletionContextHelper.IsAttributeNameCompletionContext(
selectedAttributeName,
selectedAttributeNameLocation,
prefixLocation,
context.AbsoluteIndex))
{
return [];
}

// Don't provide completions if the user is typing a directive attribute (starts with @)
if (selectedAttributeName?.StartsWith('@') == true)
{
return [];
}

var containingTagName = containingTagNameToken.Content;

using var completionItems = new PooledArrayBuilder<RazorCompletionItem>();

foreach (var (attributeName, description) in s_blazorDataAttributes)
{
// Only show data-enhance for form elements
if (attributeName == "data-enhance" &&
!string.Equals(containingTagName, "form", System.StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Check if the attribute already exists on the element
var alreadyExists = false;
foreach (var attribute in attributes)
{
var existingAttributeName = attribute switch
{
MarkupAttributeBlockSyntax attributeBlock => attributeBlock.Name.GetContent(),
MarkupMinimizedAttributeBlockSyntax minimizedAttributeBlock => minimizedAttributeBlock.Name.GetContent(),
_ => null
};

if (existingAttributeName == attributeName)
{
alreadyExists = true;
break;
}
}

if (alreadyExists && selectedAttributeName != attributeName)
{
// Attribute already exists and is not the one currently being edited
continue;
}

var insertText = attributeName;
var isSnippet = false;

// Add snippet text for attribute value if snippets are supported
if (context.Options.SnippetsSupported)
{
var snippetSuffix = context.Options.AutoInsertAttributeQuotes ? "=\"$0\"" : "=$0";
insertText = attributeName + snippetSuffix;
isSnippet = true;
}

// VSCode doesn't use commit characters for attribute completions
var commitCharacters = context.Options.UseVsCodeCompletionCommitCharacters
? ImmutableArray<RazorCommitCharacter>.Empty
: (isSnippet ? AttributeSnippetCommitCharacters : AttributeCommitCharacters);

var descriptionInfo = new AttributeDescriptionInfo(
Name: attributeName,
Documentation: description);

var completionItem = RazorCompletionItem.CreateAttribute(
displayText: attributeName,
insertText: insertText,
descriptionInfo: descriptionInfo,
commitCharacters: commitCharacters,
isSnippet: isSnippet);

completionItems.Add(completionItem);
}

return completionItems.ToImmutable();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Razor.Language.Syntax;
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;

namespace Microsoft.CodeAnalysis.Razor.Completion;

internal static class CompletionContextHelper
{
/// <summary>
/// Adjusts the syntax node owner to find the nearest start or end tag for completion purposes.
/// </summary>
/// <param name="owner">The original syntax node owner.</param>
/// <returns>The adjusted owner node.</returns>
public static RazorSyntaxNode? AdjustSyntaxNodeForCompletion(RazorSyntaxNode? owner)
=> owner switch
{
// This provider is trying to find the nearest Start or End tag. Most of the time, that's a level up, but if the index the user is typing at
// is a token of a start or end tag directly, we already have the node we want.
MarkupStartTagSyntax or MarkupEndTagSyntax or MarkupTagHelperStartTagSyntax or MarkupTagHelperEndTagSyntax or MarkupTagHelperAttributeSyntax => owner,
// Invoking completion in an empty file will give us RazorDocumentSyntax which always has null parent
RazorDocumentSyntax => owner,
// Either the parent is a context we can handle, or it's not and we shouldn't show completions.
_ => owner?.Parent
};

/// <summary>
/// Determines if the absolute index is within an attribute name completion context.
/// </summary>
/// <param name="selectedAttributeName">The currently selected or partially typed attribute name.</param>
/// <param name="selectedAttributeNameLocation">The location of the selected attribute name.</param>
/// <param name="prefixLocation">The location of the attribute prefix (e.g., "@" for directive attributes).</param>
/// <param name="absoluteIndex">The cursor position in the document.</param>
/// <returns>True if the context is appropriate for attribute name completion, false otherwise.</returns>
/// <remarks>
/// To align with HTML completion behavior we only want to provide completion items if we're trying to resolve completion at the
/// beginning of an HTML attribute name or at the end of possible partially written attribute. We do extra checks on prefix locations here in order to rule out malformed cases when the Razor
/// compiler incorrectly parses multi-line attributes while in the middle of typing out an element. For instance:
/// &lt;SurveyPrompt |
/// @code { ... }
/// Will be interpreted as having an `@code` attribute name due to multi-line attributes being a thing. Ultimately this is mostly a
/// heuristic that we have to apply in order to workaround limitations of the Razor compiler.
/// </remarks>
public static bool IsAttributeNameCompletionContext(
string? selectedAttributeName,
Microsoft.CodeAnalysis.Text.TextSpan? selectedAttributeNameLocation,
Microsoft.CodeAnalysis.Text.TextSpan? prefixLocation,
int absoluteIndex)
{
return selectedAttributeName is null ||
selectedAttributeNameLocation?.IntersectsWith(absoluteIndex) == true ||
(prefixLocation?.IntersectsWith(absoluteIndex) ?? false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,10 @@ public static RazorCompletionItem CreateDirectiveAttributeEventParameterHtmlEven
string displayText, string insertText,
ImmutableArray<RazorCommitCharacter> commitCharacters)
=> new(RazorCompletionItemKind.DirectiveAttributeParameterEventValue, displayText, insertText, sortText: null, descriptionInfo: AggregateBoundAttributeDescription.Empty, commitCharacters, isSnippet: false);

public static RazorCompletionItem CreateAttribute(
string displayText, string insertText,
AttributeDescriptionInfo descriptionInfo,
ImmutableArray<RazorCommitCharacter> commitCharacters, bool isSnippet)
=> new(RazorCompletionItemKind.Attribute, displayText, insertText, sortText: null, descriptionInfo, commitCharacters, isSnippet);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ internal enum RazorCompletionItemKind
MarkupTransition,
TagHelperElement,
TagHelperAttribute,
Attribute,
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ internal class RazorCompletionItemResolver : CompletionItemResolver
completionItem.Documentation = descriptionInfo.Description;
}

break;
}
case RazorCompletionItemKind.Attribute:
{
if (associatedRazorCompletion.DescriptionInfo is AttributeDescriptionInfo descriptionInfo)
{
completionItem.Documentation = new MarkupContent
{
Kind = documentationKind,
Value = descriptionInfo.Documentation
};
}

break;
}
case RazorCompletionItemKind.DirectiveAttribute:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@ internal static bool TryConvert(
completionItem = tagHelperAttributeCompletionItem;
return true;
}
case RazorCompletionItemKind.Attribute:
{
var attributeCompletionItem = new VSInternalCompletionItem()
{
Label = razorCompletionItem.DisplayText,
InsertText = razorCompletionItem.InsertText,
FilterText = razorCompletionItem.DisplayText,
SortText = razorCompletionItem.SortText,
InsertTextFormat = insertTextFormat,
Kind = CompletionItemKind.Property,
};

attributeCompletionItem.UseCommitCharactersFrom(razorCompletionItem, clientCapabilities);

completionItem = attributeCompletionItem;
return true;
}
}

completionItem = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,11 @@ public ImmutableArray<RazorCompletionItem> GetCompletionItems(RazorCompletionCon
return [];
}

owner = owner switch
owner = CompletionContextHelper.AdjustSyntaxNodeForCompletion(owner);
if (owner is null)
{
// This provider is trying to find the nearest Start or End tag. Most of the time, that's a level up, but if the index the user is typing at
// is a token of a start or end tag directly, we already have the node we want.
MarkupStartTagSyntax or MarkupEndTagSyntax or MarkupTagHelperStartTagSyntax or MarkupTagHelperEndTagSyntax or MarkupTagHelperAttributeSyntax => owner,
// Invoking completion in an empty file will give us RazorDocumentSyntax which always has null parent
RazorDocumentSyntax => owner,
// Either the parent is a context we can handle, or it's not and we shouldn't show completions.
_ => owner.Parent
};
return [];
}

if (HtmlFacts.TryGetElementInfo(owner, out var containingTagNameToken, out var attributes, out _) &&
containingTagNameToken.Span.IntersectsWith(context.AbsoluteIndex))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.CodeAnalysis.Razor.Tooltip;

/// <summary>
/// Provides description information for HTML attributes that are not bound to tag helpers.
/// </summary>
/// <param name="Name">The name of the attribute.</param>
/// <param name="Documentation">The documentation text describing the attribute.</param>
internal sealed record AttributeDescriptionInfo(string Name, string Documentation);
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ internal sealed class OOPMarkupTransitionCompletionItemProvider : MarkupTransiti
[method: ImportingConstructor]
internal sealed class OOPTagHelperCompletionProvider(ITagHelperCompletionService tagHelperCompletionService)
: TagHelperCompletionProvider(tagHelperCompletionService);

[Export(typeof(IRazorCompletionItemProvider)), Shared]
internal sealed class OOPBlazorDataAttributeCompletionItemProvider : BlazorDataAttributeCompletionItemProvider;
Loading