Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -23,6 +23,17 @@ internal sealed class RazorCompletionItem
public ImmutableArray<RazorCommitCharacter> CommitCharacters { get; }
public bool IsSnippet { get; }

/// <summary>
/// For component completions, additional text edits to apply when the completion is committed (e.g., @using statements).
/// </summary>
public ImmutableArray<Roslyn.LanguageServer.Protocol.TextEdit> AdditionalTextEdits { get; }

/// <summary>
/// For component completions, the namespace to auto-insert via @using statement when committed.
/// Internal-only property used during completion item creation before TextEdits are generated.
/// </summary>
internal string? AutoInsertNamespace { get; }

/// <summary>
/// Creates a new Razor completion item
/// </summary>
Expand All @@ -33,6 +44,8 @@ internal sealed class RazorCompletionItem
/// <param name="descriptionInfo">An object that provides description information for this completion item.</param>
/// <param name="commitCharacters">Characters that can be used to commit the completion item.</param>
/// <param name="isSnippet">Indicates whether the completion item's <see cref="InsertText"/> is an LSP snippet or not.</param>
/// <param name="autoInsertNamespace">For component completions, the namespace to auto-insert via @using statement when committed.</param>
/// <param name="additionalTextEdits">Additional text edits to apply when the completion is committed.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="displayText"/> or <paramref name="insertText"/> are <see langword="null"/>.</exception>
private RazorCompletionItem(
RazorCompletionItemKind kind,
Expand All @@ -41,7 +54,9 @@ private RazorCompletionItem(
string? sortText,
object descriptionInfo,
ImmutableArray<RazorCommitCharacter> commitCharacters,
bool isSnippet)
bool isSnippet,
string? autoInsertNamespace = null,
ImmutableArray<Roslyn.LanguageServer.Protocol.TextEdit> additionalTextEdits = default)
{
ArgHelper.ThrowIfNull(displayText);
ArgHelper.ThrowIfNull(insertText);
Expand All @@ -53,6 +68,8 @@ private RazorCompletionItem(
DescriptionInfo = descriptionInfo;
CommitCharacters = commitCharacters.NullToEmpty();
IsSnippet = isSnippet;
AutoInsertNamespace = autoInsertNamespace;
AdditionalTextEdits = additionalTextEdits.NullToEmpty();
}

public static RazorCompletionItem CreateDirective(
Expand Down Expand Up @@ -82,8 +99,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,
string? autoInsertNamespace = null)
=> new(RazorCompletionItemKind.TagHelperElement, displayText, insertText, sortText: null, descriptionInfo, commitCharacters, isSnippet: false, autoInsertNamespace);

public static RazorCompletionItem CreateTagHelperAttribute(
string displayText, string insertText, string? sortText,
Expand All @@ -95,4 +113,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);

/// <summary>
/// Creates a copy of this completion item with the specified additional text edits.
/// </summary>
public RazorCompletionItem WithAdditionalTextEdits(ImmutableArray<Roslyn.LanguageServer.Protocol.TextEdit> additionalTextEdits)
=> new(Kind, DisplayText, InsertText, SortText, DescriptionInfo, CommitCharacters, IsSnippet, AutoInsertNamespace, additionalTextEdits);
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ internal class RazorCompletionListProvider(
return null;
}

// Process completion items that need @using statements added and populate their AdditionalTextEdits
razorCompletionItems = PopulateAdditionalTextEdits(razorCompletionItems, codeDocument);

var completionList = CreateLSPCompletionList(razorCompletionItems, clientCapabilities);

// The completion list is cached and can be retrieved via this result id to enable the resolve completion functionality.
Expand Down Expand Up @@ -256,4 +259,42 @@ internal static bool TryConvert(
completionItem = null;
return false;
}

private static ImmutableArray<RazorCompletionItem> PopulateAdditionalTextEdits(
ImmutableArray<RazorCompletionItem> completionItems,
RazorCodeDocument codeDocument)
{
var hasItemsWithNamespace = false;
foreach (var item in completionItems)
{
if (item.AutoInsertNamespace is not null)
{
hasItemsWithNamespace = true;
break;
}
}

if (!hasItemsWithNamespace)
{
return completionItems;
}

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

foreach (var item in completionItems)
{
if (item.AutoInsertNamespace is { } @namespace)
{
var textEdit = Formatting.AddUsingsHelper.CreateAddUsingTextEdit(@namespace, codeDocument);
var updatedItem = item.WithAdditionalTextEdits(ImmutableArray.Create(textEdit));
updatedItems.Add(updatedItem);
}
else
{
updatedItems.Add(item);
}
}

return updatedItems.ToImmutable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,30 @@ private ImmutableArray<RazorCompletionItem> GetElementCompletions(
commitCharacters: commitChars);

completionItems.Add(razorCompletionItem);

// Check if this is a fully qualified name (contains a dot), which means there's an out-of-scope component
// For these, add an additional completion item with @using hint
if (displayText.Contains('.'))
{
// Extract namespace from the fully qualified name (everything before the last dot)
var lastDotIndex = displayText.LastIndexOf('.');
if (lastDotIndex > 0)
{
var @namespace = displayText[..lastDotIndex];
var shortName = displayText[(lastDotIndex + 1)..]; // Get the short name after the last dot
var displayTextWithUsing = $"{shortName} - @using {@namespace}";

// Create a completion item with modified display text and namespace for auto-insert
var razorCompletionItemWithUsing = RazorCompletionItem.CreateTagHelperElement(
Comment thread
davidwengier marked this conversation as resolved.
Outdated
displayText: displayTextWithUsing,
insertText: shortName,
descriptionInfo: new(tagHelperDescriptions),
commitCharacters: commitChars,
autoInsertNamespace: @namespace);

completionItems.Add(razorCompletionItemWithUsing);
}
}
}

return completionItems.ToImmutableAndClear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,16 @@ private async ValueTask<VSInternalCompletionItem> ResolveRazorCompletionItemAsyn
cancellationToken).ConfigureAwait(false);

// If we couldn't resolve, fall back to what we were passed in
return result ?? request;
result ??= request;

// Check if this completion item has additional text edits (e.g., @using statements) and apply them
var associatedRazorCompletion = razorResolutionContext.CompletionItems.FirstOrDefault(c => c.DisplayText == result.Label);
if (associatedRazorCompletion?.AdditionalTextEdits is { Length: > 0 } additionalEdits)
{
result.AdditionalTextEdits = additionalEdits.ToArray();
}

return result;
}

private async ValueTask<VSInternalCompletionItem> ResolveCSharpCompletionItemAsync(RemoteDocumentContext context, VSInternalCompletionItem request, VSInternalCompletionList containingCompletionList, DelegatedCompletionResolutionContext resolutionContext, RazorFormattingOptions formattingOptions, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -328,6 +329,112 @@ The end.
htmlItemLabels: ["div", "h1"]);
}

[Fact]
public async Task ComponentCompletionWithUsing()
{
// Create a component in a custom namespace that won't be imported by default
var customComponent = """
@namespace MyApp.Components

<h3>CustomWidget</h3>

@code {
[Parameter] public string? Title { get; set; }
}
""";

// Use a modified version of VerifyCompletionListAsync that supports additionalFiles
var document = CreateProjectAndRazorDocument(
contents: "<$$",
additionalFiles: [(Path.Combine(TestProjectData.SomeProjectPath, "CustomWidget.razor"), customComponent)]);

var sourceText = await document.GetTextAsync(DisposalToken);
Comment thread
davidwengier marked this conversation as resolved.
Outdated

ClientSettingsManager.Update(ClientAdvancedSettings.Default);

var response = new RazorVSInternalCompletionList()
{
Items = [new VSInternalCompletionItem() { Label = "div" }],
IsIncomplete = true
};

var requestInvoker = new TestHtmlRequestInvoker([(Methods.TextDocumentCompletionName, response)]);

#if VSCODE
ISnippetCompletionItemProvider? snippetCompletionItemProvider = null;
#else
var snippetCompletionItemProvider = new SnippetCompletionItemProvider(new SnippetCache());
#endif

var languageServerFeatureOptions = new TestLanguageServerFeatureOptions();
var completionListCache = new CompletionListCache();
var endpoint = new CohostDocumentCompletionEndpoint(
IncompatibleProjectService,
RemoteServiceInvoker,
ClientSettingsManager,
ClientCapabilitiesService,
snippetCompletionItemProvider,
languageServerFeatureOptions,
requestInvoker,
completionListCache,
NoOpTelemetryReporter.Instance,
LoggerFactory);

var request = new RazorVSInternalCompletionParams()
{
TextDocument = new TextDocumentIdentifier()
{
DocumentUri = document.CreateDocumentUri()
},
Position = sourceText.GetPosition(1),
Context = new VSInternalCompletionContext()
{
InvokeKind = VSInternalCompletionInvokeKind.Typing,
TriggerCharacter = "<",
TriggerKind = CompletionTriggerKind.TriggerCharacter
}
};

var result = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken);

Assert.NotNull(result);

// Verify expected items are present
var labelSet = result.Items.Select(i => i.Label).ToHashSet();
Assert.Contains("MyApp.Components.CustomWidget", labelSet);
Assert.Contains("CustomWidget - @using MyApp.Components", labelSet);

// Test resolution - find and resolve the "with using" item
var itemToResolve = result.Items.First(i => i.Label == "CustomWidget - @using MyApp.Components");
itemToResolve = JsonSerializer.Deserialize<VSInternalCompletionItem>(
JsonSerializer.SerializeToElement(itemToResolve, JsonHelpers.JsonSerializerOptions),
JsonHelpers.JsonSerializerOptions)!;
itemToResolve.Data ??= result.Data ?? result.ItemDefaults?.Data;
itemToResolve.Data = JsonSerializer.SerializeToElement(itemToResolve.Data, JsonHelpers.JsonSerializerOptions);

var resolveEndpoint = new CohostDocumentCompletionResolveEndpoint(
IncompatibleProjectService,
completionListCache,
RemoteServiceInvoker,
ClientSettingsManager,
new TestHtmlRequestInvoker(),
ClientCapabilitiesService,
new ThrowingSnippetCompletionItemResolveProvider(),
LoggerFactory);

var resolvedItem = await resolveEndpoint.GetTestAccessor().HandleRequestAsync(itemToResolve, document, DisposalToken);

Assert.NotNull(resolvedItem);

// Verify AdditionalTextEdits contains the @using statement
Assert.NotNull(resolvedItem.AdditionalTextEdits);
var additionalEdit = Assert.Single(resolvedItem.AdditionalTextEdits);
Assert.Contains("@using MyApp.Components", additionalEdit.NewText);

// Verify the @using edit is at the start of the file
Assert.Equal(0, additionalEdit.Range.Start.Line);
}

[Fact]
public async Task HtmlElementNamesAndTagHelpersCompletion_EndOfDocument()
{
Expand Down