Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
473db3f
Initial plan
Copilot Oct 13, 2025
3cfa493
Add snippet support for component completion with EditorRequired attr…
Copilot Oct 13, 2025
61fbb8e
Address PR feedback: use pooled StringBuilder, always add quotes, add…
Copilot Oct 13, 2025
a98a719
Simplify Cohost test and optimize component tag helper lookup
Copilot Oct 13, 2025
7272bb5
Merge remote-tracking branch 'origin/main' into copilot/add-completio…
Copilot Oct 14, 2025
58fa7d4
Fix test for snippet completion - snippets don't need resolve
Copilot Oct 15, 2025
0ba4329
Run activate.sh after restore to set DOTNET_ROOT etc.
davidwengier Oct 15, 2025
02b3f11
Update snippet display text and simplify test using helper
Copilot Oct 15, 2025
d701787
Update test infra to support InsertText and AdditionalEdits
davidwengier Oct 16, 2025
e396fda
Fix test
davidwengier Oct 16, 2025
c128f7d
Optimize to avoid .Any() and .ToImmutableArray() allocation
Copilot Oct 16, 2025
bac8a8b
Use localized resource string for component completion label
Copilot Oct 16, 2025
c5b5633
Use SR resource in test for component completion label
Copilot Oct 16, 2025
cb304fd
Merge branch 'main' into copilot/add-completion-snippet-for-editor-re…
davidwengier Oct 16, 2025
a4bda3d
Extract snippet completion logic to separate method
Copilot Oct 16, 2025
f20fdb8
Refactor: invert if for early exit and pass descriptionInfo from caller
Copilot Oct 16, 2025
1350ef3
Merge main into branch and resolve conflicts
invalid-email-address Oct 17, 2025
5911e37
Add comment to SR.resx explaining "req'd" abbreviation
Copilot Oct 17, 2025
3b7914e
Update src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Completi…
davidwengier Oct 17, 2025
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 @@ -82,8 +82,9 @@ public static RazorCompletionItem CreateMarkupTransition(
public static RazorCompletionItem CreateTagHelperElement(
string displayText, string insertText,
AggregateBoundElementDescription descriptionInfo,
ImmutableArray<RazorCommitCharacter> commitCharacters)
=> new(RazorCompletionItemKind.TagHelperElement, displayText, insertText, sortText: null, descriptionInfo, commitCharacters, isSnippet: false);
ImmutableArray<RazorCommitCharacter> commitCharacters,
bool isSnippet = false)
=> new(RazorCompletionItemKind.TagHelperElement, displayText, insertText, sortText: null, descriptionInfo, commitCharacters, isSnippet);

public static RazorCompletionItem CreateTagHelperAttribute(
string displayText, string insertText, string? sortText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
Expand Down Expand Up @@ -235,11 +236,26 @@ private ImmutableArray<RazorCompletionItem> GetElementCompletions(
{
var tagHelperDescriptions = tagHelpers.SelectAsArray(BoundElementDescriptionInfo.From);

var insertText = displayText;
var isSnippet = false;

// Check if snippets are supported and if any of the tag helpers have EditorRequired attributes
if (context.Options.SnippetsSupported && tagHelpers.Any())
Comment thread
davidwengier marked this conversation as resolved.
Outdated
{
var tagHelpersArray = tagHelpers.ToImmutableArray();
if (TryGetEditorRequiredAttributesSnippet(tagHelpersArray, displayText, context.Options.AutoInsertAttributeQuotes, out var snippetText))
{
insertText = snippetText;
isSnippet = true;
}
}

var razorCompletionItem = RazorCompletionItem.CreateTagHelperElement(
Comment thread
davidwengier marked this conversation as resolved.
displayText: displayText,
insertText: displayText,
insertText: insertText,
descriptionInfo: new(tagHelperDescriptions),
commitCharacters: commitChars);
commitCharacters: commitChars,
isSnippet: isSnippet);

completionItems.Add(razorCompletionItem);
}
Expand Down Expand Up @@ -283,6 +299,60 @@ private static ImmutableArray<RazorCommitCharacter> ResolveAttributeCommitCharac
};
}

private static bool TryGetEditorRequiredAttributesSnippet(
ImmutableArray<TagHelperDescriptor> tagHelpers,
string tagName,
bool autoInsertAttributeQuotes,
[NotNullWhen(true)] out string? snippetText)
{
// Collect all unique EditorRequired attributes from all matching tag helpers
using var editorRequiredAttributes = new PooledHashSet<string>(StringHashSetPool.Ordinal);

foreach (var tagHelper in tagHelpers)
Comment thread
davidwengier marked this conversation as resolved.
Outdated
{
var requiredAttributes = tagHelper.EditorRequiredAttributes;
foreach (var attribute in requiredAttributes)
{
editorRequiredAttributes.Add(attribute.Name);
}
}

// If there are no EditorRequired attributes, don't create a snippet
if (editorRequiredAttributes.Count == 0)
{
snippetText = null;
return false;
}

// Build snippet with placeholders for each required attribute
var builder = new StringBuilder();
Comment thread
davidwengier marked this conversation as resolved.
Outdated
builder.Append(tagName);

var tabStopIndex = 1;
var attributeNames = editorRequiredAttributes.ToArray();
foreach (var attributeName in attributeNames)
{
builder.Append(' ');
builder.Append(attributeName);
builder.Append(autoInsertAttributeQuotes ? "=\"$" : "=$");
Comment thread
davidwengier marked this conversation as resolved.
Outdated
builder.Append(tabStopIndex);
if (autoInsertAttributeQuotes)
{
builder.Append('"');
}

tabStopIndex++;
}

// Add final tab stop for the element content
builder.Append(">$0</");
builder.Append(tagName);
builder.Append('>');

snippetText = builder.ToString();
return true;
}

private enum AttributeContext
{
Indexer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,139 @@ private static void AssertTest1Test2Completions(IReadOnlyList<RazorCompletionIte
);
}

[Fact]
public void GetCompletionAt_ComponentWithEditorRequiredAttributes_SnippetsSupported_ReturnsSnippet()
{
// Arrange
var componentBuilder = TagHelperDescriptorBuilder.CreateComponent("ComponentWithRequiredParams", "TestAssembly");
componentBuilder.SetTypeName(
fullName: "TestNamespace.ComponentWithRequiredParams",
typeNamespace: "TestNamespace",
typeNameIdentifier: "ComponentWithRequiredParams");
componentBuilder.TagMatchingRule(rule => rule.TagName = "ComponentWithRequiredParams");
componentBuilder.IsFullyQualifiedNameMatch = true;
componentBuilder.BindAttribute(attribute =>
{
attribute.Name = "RequiredParam1";
attribute.PropertyName = "RequiredParam1";
attribute.TypeName = typeof(string).FullName;
attribute.IsEditorRequired = true;
});
componentBuilder.BindAttribute(attribute =>
{
attribute.Name = "RequiredParam2";
attribute.PropertyName = "RequiredParam2";
attribute.TypeName = typeof(int).FullName;
attribute.IsEditorRequired = true;
});
componentBuilder.BindAttribute(attribute =>
{
attribute.Name = "OptionalParam";
attribute.PropertyName = "OptionalParam";
attribute.TypeName = typeof(string).FullName;
});

var service = CreateTagHelperCompletionProvider();
var options = new RazorCompletionOptions(SnippetsSupported: true, AutoInsertAttributeQuotes: true, CommitElementsWithSpace: true, UseVsCodeCompletionCommitCharacters: false);
var context = CreateRazorCompletionContext(
"""
<$$
""",
isRazorFile: true,
options,
tagHelpers: ImmutableArray.Create(componentBuilder.Build()));

// Act
var completions = service.GetCompletionItems(context);

// Assert
var completion = Assert.Single(completions);
Assert.Equal("ComponentWithRequiredParams", completion.DisplayText);
Assert.True(completion.IsSnippet);
Assert.Contains("RequiredParam1", completion.InsertText);
Assert.Contains("RequiredParam2", completion.InsertText);
Assert.Contains("$1", completion.InsertText);
Assert.Contains("$2", completion.InsertText);
Assert.Contains("$0", completion.InsertText);
}

[Fact]
public void GetCompletionAt_ComponentWithEditorRequiredAttributes_SnippetsNotSupported_ReturnsNonSnippet()
{
// Arrange
var componentBuilder = TagHelperDescriptorBuilder.CreateComponent("ComponentWithRequiredParams", "TestAssembly");
componentBuilder.SetTypeName(
fullName: "TestNamespace.ComponentWithRequiredParams",
typeNamespace: "TestNamespace",
typeNameIdentifier: "ComponentWithRequiredParams");
componentBuilder.TagMatchingRule(rule => rule.TagName = "ComponentWithRequiredParams");
componentBuilder.IsFullyQualifiedNameMatch = true;
componentBuilder.BindAttribute(attribute =>
{
attribute.Name = "RequiredParam1";
attribute.PropertyName = "RequiredParam1";
attribute.TypeName = typeof(string).FullName;
attribute.IsEditorRequired = true;
});

var service = CreateTagHelperCompletionProvider();
var options = new RazorCompletionOptions(SnippetsSupported: false, AutoInsertAttributeQuotes: true, CommitElementsWithSpace: true, UseVsCodeCompletionCommitCharacters: false);
var context = CreateRazorCompletionContext(
"""
<$$
""",
isRazorFile: true,
options,
tagHelpers: ImmutableArray.Create(componentBuilder.Build()));

// Act
var completions = service.GetCompletionItems(context);

// Assert
var completion = Assert.Single(completions);
Assert.Equal("ComponentWithRequiredParams", completion.DisplayText);
Assert.False(completion.IsSnippet);
Assert.Equal("ComponentWithRequiredParams", completion.InsertText);
}

[Fact]
public void GetCompletionAt_ComponentWithNoEditorRequiredAttributes_SnippetsSupported_ReturnsNonSnippet()
{
// Arrange
var componentBuilder = TagHelperDescriptorBuilder.CreateComponent("ComponentWithoutRequired", "TestAssembly");
componentBuilder.SetTypeName(
fullName: "TestNamespace.ComponentWithoutRequired",
typeNamespace: "TestNamespace",
typeNameIdentifier: "ComponentWithoutRequired");
componentBuilder.TagMatchingRule(rule => rule.TagName = "ComponentWithoutRequired");
componentBuilder.IsFullyQualifiedNameMatch = true;
componentBuilder.BindAttribute(attribute =>
{
attribute.Name = "OptionalParam";
attribute.PropertyName = "OptionalParam";
attribute.TypeName = typeof(string).FullName;
});

var service = CreateTagHelperCompletionProvider();
var options = new RazorCompletionOptions(SnippetsSupported: true, AutoInsertAttributeQuotes: true, CommitElementsWithSpace: true, UseVsCodeCompletionCommitCharacters: false);
var context = CreateRazorCompletionContext(
"""
<$$
""",
isRazorFile: true,
options,
tagHelpers: ImmutableArray.Create(componentBuilder.Build()));

// Act
var completions = service.GetCompletionItems(context);

// Assert
var completion = Assert.Single(completions);
Assert.Equal("ComponentWithoutRequired", completion.DisplayText);
Assert.False(completion.IsSnippet);
Assert.Equal("ComponentWithoutRequired", completion.InsertText);
}

private static RazorCompletionContext CreateRazorCompletionContext(string markup, bool isRazorFile, RazorCompletionOptions options = default, ImmutableArray<TagHelperDescriptor> tagHelpers = default)
{
tagHelpers = tagHelpers.NullToEmpty();
Expand Down