Skip to content

Conversation

alexgav
Copy link
Contributor

@alexgav alexgav commented Jul 3, 2025

Summary of the changes

  • Using snippet insert text will ensure that equals and quotes are added on attribute commit, and cursor is moved to the correct position between double quotes.

Fixes:
#11395

This will insure that equals and quotes are added on attribute commit, and cursor is moved to the correct position between double quotes.
@alexgav alexgav requested a review from a team as a code owner July 3, 2025 19:50
Comment on lines 131 to 145
(var endIndex, var attributePrefix) = insertText.EndsWith("...", StringComparison.Ordinal) ? (^3, true) : (^0, false);

// Don't allocate a new string unless we need to make a change.
if (startIndex > 0 || endIndex.Value > 0)
{
insertText = insertText[startIndex..endIndex];
}

var isSnippet = false;
if (!attributePrefix // Don't even try to add snippet to something like "@bind-..."
&& TryGetSnippetText(containingAttribute, insertText, razorCompletionOptions, out var snippetText))
{
insertText = snippetText;
isSnippet = true;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big fan of the tuple-returning-ternary, but I think this whole bit of code is pretty hard to follow, and it took me a while to work out what attributePrefix means (I think the word "prefix" and the fact that its based on the presence of a suffix does my head in). Can it be expanded? Something like (untested):

            var insertText = displayText.AsSpan();
            
            if (insertText.StartsWith('@')
            {
                insertText = insertText[1..];
            }
            
            if (insertText.EndsWith("...", StringComparison.Orginal))
            {
                insertText = insertText[..^3];
            }
            else
            {
                var isSnippet = false;
                if (TryGetSnippetText(containingAttribute, insertText, razorCompletionOptions, out var snippetText))
                {
                    insertText = snippetText;
                    isSnippet = true;
                }
            }

Obviously either TryGetSnippetText will need to change, because inputText is now a span, but I think making it a span early means not having to jump through so many logic hoops to keep track of everything.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure all of the span calculatoins/collection operators made things easier to read, but I gave it a try. Let me know if you see ways to simplify things. Concatenation of spans seems a bit messy, not sure if there is a way to do it better. Ambiguity and different parameter sets (singe value vs span) in SpanExtensions and MemoryExtensions make things a bit messy to IMHO. Let me know if you know of a way to clean this up more. So far I'm really on the fence about this, but if it makes it easier to read for you I guess it's fine.

@@ -45,7 +48,7 @@ private RazorCodeDocument GetCodeDocument(string content)
public void GetCompletionItems_OnNonAttributeArea_ReturnsEmptyCollection()
{
// Arrange
var context = CreateRazorCompletionContext(absoluteIndex: 3, "<input @ />");
var context = CreateRazorCompletionContext("<in$$put @ />");
Copy link
Member

@davidwengier davidwengier Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love all of these updates to the test inputs, thank you! #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, yeah, inputs were pretty hard to read before, weren't they :)

@@ -278,15 +330,21 @@ private static void AssertDoesNotContain(IReadOnlyList<RazorCompletionItem> comp
RazorCompletionItemKind.DirectiveAttribute == completion.Kind);
}

private RazorCompletionContext CreateRazorCompletionContext(int absoluteIndex, string documentContent)
private RazorCompletionContext CreateRazorCompletionContext(string testCodeText)
Copy link
Member

@davidwengier davidwengier Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just make the parameter TestCode testCode, and remove line 335, and everything should still comple the same. There is an implicit conversion. #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, cleaned that up.

Copy link
Member

@davidwengier davidwengier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I do think it's much easier to read now, though I'd love to know why they can't be proper extension method calls.


// Strip off the @ from the insertion text. This change is here to align the insertion text with the
// completion hooks into VS and VSCode. Basically, completion triggers when `@` is typed so we don't
// want to insert `@bind` because `@` already exists.
var startIndex = insertText.StartsWith('@') ? 1 : 0;
if (SpanExtensions.StartsWith(insertTextSpan, '@'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't this just be used as an extension method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both of them are in System - not sure if there is a better way to address this, but this is super-weird.


// Don't allocate a new string unless we need to make a change.
if (startIndex > 0 || endIndex.Value > 0)
if (MemoryExtensions.EndsWith(insertTextSpan, "...".AsSpan()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above :)

&& containingAttribute is not (MarkupTagHelperDirectiveAttributeSyntax or MarkupAttributeBlockSyntax)
&& containingAttribute.Parent is not (MarkupTagHelperDirectiveAttributeSyntax or MarkupAttributeBlockSyntax))
{
var suffixTextSpan = razorCompletionOptions.AutoInsertAttributeQuotes ? "=\"$0\"".AsSpan() : "=$0".AsSpan();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to store these two spans as static ReadOnlyMemory<char> so they're only created once.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Learning opportunity for me. What extra allocations happen here? I would have thought the strings would be compiled into consts, and then the AsSpan calls would just create a struct around that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe they're all structs, so not really an "allocation" as we would usually talk about it (ie, not something to be garbage collected), I just meant to store then as a static readonly, like you'd do with any other thing that is only ever one value, but can't be a const for reasons.

But really I'm just copying @DustinCampbell since I assume he actually knows what he's talking about: https://github.com/dotnet/razor/blob/main/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorHtmlWriter.cs#L18

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... or maybe not, looking at it more closely. Either way, seems like caching can't hurt.

@@ -140,15 +154,42 @@ internal static ImmutableArray<RazorCompletionItem> GetAttributeCompletions(

var razorCompletionItem = RazorCompletionItem.CreateDirectiveAttribute(
displayText,
insertText,
insertTextSpan.ToString(),
Copy link
Contributor

@ToddGrun ToddGrun Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insertTextSpan.ToString(),

In the common case (none of the conditions above hit), aren't we allocating something for insertTextSpan whereas we didn't before?

Copy link
Contributor Author

@alexgav alexgav Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we actually allocate here because ReadOnlySpan special-cases 'char' case

https://github.com/dotnet/runtime/blob/6d869a82ccbad6c279da52f9bd507057e6e30495/src/libraries/System.Private.CoreLib/src/System/ReadOnlySpan.cs#L358

Also, the common case is actually use modifying the string rather than not modifying it - snippet ="{0}" is almost always added. It's not added only for indexers (ending with "...").

However, it was easy enough to check by adding another local (let me know if I'm overlooking a clever way of doing it), so I modified the code to do so. Thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we actually allocate here

Isn't that specialization you linked to doing a string allocation? Either way, the check you added makes that no longer a concern.

        public override string ToString()
        {
            if (typeof(T) == typeof(char))
            {
                return new string(new ReadOnlySpan<char>(ref Unsafe.As<T, char>(ref _reference), _length));
            }
            return $"System.ReadOnlySpan<{typeof(T).Name}>[{_length}]";
        }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a similar one, but I did say later ".. or maybe not, looking at it more closely" :)


completionItems.Add(razorCompletionItem);
}

return completionItems.ToImmutableAndClear();

bool TryGetSnippetText(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bool TryGetSnippetText(

nit: can this be static instead to be more explicit about what is being captured?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was actually a subtle bug there where I was capturing containingAttribute unintentionally. Nice suggestion, thanks. Fixed.

@alexgav
Copy link
Contributor Author

alexgav commented Jul 10, 2025

I'll enable auto-merge per earlier in-person conversation. Happy to address any additional feedback (if any) in a follow-up PR.

@alexgav alexgav enabled auto-merge (squash) July 10, 2025 06:26
@alexgav alexgav merged commit 27d4d4c into main Jul 10, 2025
11 checks passed
@alexgav alexgav deleted the dev/alexgav/UseSnippetInsertTextInDirectiveAttributes branch July 10, 2025 06:59
@dotnet-policy-service dotnet-policy-service bot added this to the Next milestone Jul 10, 2025
@RikkiGibson RikkiGibson modified the milestones: Next, 18.0 P1 Aug 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants