Extending Visual Studio 2022

Pavel OsadchukPavel Osadchuk
15 min read

HTMX is becoming extremely noisy in the web development world. It seems that more and more personal projects adopt it to simplify things. But recently, I noticed increasing adoption in corporate environments. Internal websites and even some public products are being built with htmx using different backend languages as we speak. Visual Studio and .NET are some of these languages.

Just look at this amazing growth of websites using the htmx over the year.

Historical trends in the usage of Htmx

Unfortunately, currently, Visual Studio's support for htmx is limited. While there are some NuGet packages providing tag helpers, developers lack crucial features like code completion and IntelliSense for HTMX. This gap in tooling can lead to inefficiencies, with developers constantly switching between Visual Studio and HTMX documentation.

Because I work with dotnet and htmx a lot, I decided to create a Visual Studio extension to fill the gap. I had no prior experience building extensions, so here are my discoveries and frustrations along the way. Despite the challenges, I managed to complete it, and now it's available in the VS marketplace.

This is what we want to achieve.

Building the HTMX Code Completion Extension

To build a Visual Studio extension, you need to install specific extension development packages through the Visual Studio Installer. It's important to note that Visual Studio extensions are built on the .NET framework. And Visual Studio works only on Windows now - they discontinued it literally a couple of days ago.

So the first thing we need is to install the required tooling. If you have Visual Studio, you have Visual Studio Installer. This is the app to install VSIX tooling

Additionally, it's recommended to install Extensibility Essentials. I didn't use it, but it looks like it has a ton of useful pieces. KnownMonikers for example is something I need but found on the web - it shows what kind of icons are available in VS.

Extension Architecture

A VSIX package is a .vsix archive that contains one or more Visual Studio extensions, together with the metadata Visual Studio uses to classify and install the extensions. The first thing in the extension is a manifest, VSIX manifest contains information about the extension to be installed.

Any VSIX extension can provide several exported Managed Extensibility Framework (MEF) classes. MEF is used to export classes and interfaces that Visual Studio uses to integrate new functionality. Recent versions of Visual Studio (2019 and 2022) have introduced significant improvements in code completion and IntelliSense features, particularly through asynchronous interfaces.

Here is an example of an exported source provider.

[Export(typeof(IAsyncQuickInfoSourceProvider))]
[Name("htmx tooltip quickInfo source")]
[ContentType("razor")]
internal class HtmxQuickInfoSourceProvider : IAsyncQuickInfoSourceProvider
{
    [Import]
    internal ITextStructureNavigatorSelectorService NavigatorService { get; set; }

    public IAsyncQuickInfoSource TryCreateQuickInfoSource(ITextBuffer textBuffer)
    {            
        return new HtmxQuickInfoSource(NavigatorService, textBuffer);
    }
}

This code exports our HtmxQuickInfoSourceProvider as an IAsyncQuickInfoSourceProvider, specifically for HTML content types. The [Import] attribute allows MEF to inject the required ITextStructureNavigatorSelectorService.

Key Components of the Extension

  1. Completion Source: Implements IAsyncCompletionSource interface to provide HTMX-specific code completion.

  2. Provider Interface: Exported as a MEF interface, called by Visual Studio under specific conditions (e.g., when an HTML file is opened). We particularly interested in the next providers

    1. IAsyncCompletionSourceProvider: provides instances of IAsyncCompletionSource which provides CompletionItems

    2. IAsyncCompletionCommitManagerProvider: provides instances of IAsyncCompletionCommitManager which provides means to adjust the commit behavior, including which typed characters commit

    3. IAsyncQuickInfoSourceProvider: creates an IAsyncQuickInfoSource specified ITextBuffer.

    4. IIntellisenseControllerProvider: creates IntelliSense controllers for individual ITextView instances.

  3. QuickInfo Source: Provides additional information when hovering over HTMX attributes with IAsyncQuickInfoSource.

Overall, the class overview of the extension would look like this:

Code Completion Implementation

The extension implements two completion sources: one for HTMX attributes and another for attribute values. The main challenge was detecting when a user starts typing an HTMX attribute (which always begins with "hx-").

The full completion process looks like this:

  • IAsyncCompletionSource - Produces the completion list when needed.

  • IAsyncCompletionBroker - Triggers completion and provides the completion session.

  • IAsyncCompletionItemManager - Filters and sorts completion items.

  • IAsyncCompletionCommitManager - Adjust the commit behavior and perform a completion commit

For attribute code completion we would need to implement two interfaces: IAsyncCompletionSourceProvider and IAsyncCompletionSource

To handle completion Visual Studio sends an event when the completion trigger is fired, which should be handled by IAsyncCompletionSource. In it, we should implement three methods:

internal class HtmxCompletionSource : IAsyncCompletionSource
{
    public Task<CompletionContext> GetCompletionContextAsync(IAsyncCompletionSession session, CompletionTrigger trigger, SnapshotPoint triggerLocation, SnapshotSpan applicableToSpan, CancellationToken cancellationToken)
    {
        /// Provide completion context which has a list of items to show
    }

    public Task<object> GetDescriptionAsync(IAsyncCompletionSession session, CompletionItem item, CancellationToken token)
    {
        /// Provide description for currently selected item
    }

    public CompletionStartData InitializeCompletion(CompletionTrigger trigger, SnapshotPoint triggerLocation, CancellationToken cancellationToken)
    {
       /// Perform checks if completion needs to be shown
    }

Method InitializeCompletion provides the trigger and a snapshot, which is essentially the location in the text view where the cursor was at the moment the trigger occurred.

This allows us to evaluate the context around the cursor to decide whether to trigger completion.

public CompletionStartData InitializeCompletion(CompletionTrigger trigger, SnapshotPoint triggerLocation, CancellationToken cancellationToken)
{
    Output.WriteInfo($"HtmxCompletionSource:InitializeCompletion: triggered by {trigger.Reason}:{trigger.Character}.");
    if (!triggerLocation.IsInsideHtmlTag())
    {
        return CompletionStartData.DoesNotParticipateInCompletion;
    }

    var tokenSpan = triggerLocation.GetContainingToken();
    var currentToken = tokenSpan.GetText();
    Output.WriteInfo($"HtmxCompletionSource:InitializeCompletion: {currentToken}");

    if (currentToken.StartsWith("hx", StringComparison.OrdinalIgnoreCase) ||
        currentToken.StartsWith("hx-", StringComparison.OrdinalIgnoreCase) ||
        (trigger.Character == '-' && tokenSpan.GetPreviousToken().GetText().Equals("hx", StringComparison.OrdinalIgnoreCase)))
    {
        return new CompletionStartData(CompletionParticipation.ProvidesItems, tokenSpan);
    }

    return CompletionStartData.DoesNotParticipateInCompletion;
}

First, we check if our cursor is inside an HTML tag. Then, we check if it is inside an htmx attribute. These checks are implemented as extension methods.

public static bool IsInsideHtmlTag(this SnapshotPoint point)
{
    var snapshot = point.Snapshot;
    var position = point.Position;

    // Check if we're already at the start of the snapshot
    if (position == 0)
        return false;

    int openBracketPos = -1;

    // Look backwards for opening bracket
    for (int i = position - 1; i >= 0; i--)
    {
        if (snapshot[i] == '>')
            return false; // Found closing bracket first, so we're not inside a tag
        if (snapshot[i] == '<')
        {
            openBracketPos = i;
            break;
        }
    }

    return openBracketPos != -1;
}

A Snapshot is an ITextSnapshot, which provides read access to an unchangeable snapshot of an ITextBuffer containing a sequence of Unicode characters. The algorithm is simple — we move left until we confirm that we are inside an HTML tag.

public static SnapshotSpan GetContainingToken(this SnapshotPoint point)
{
    var line = point.GetContainingLine();

    var linePosition = point.Position;
    var snapshot = line.Snapshot;

    int tokenStart = linePosition;
    while (tokenStart > line.Start.Position && IsValidTokenChar(snapshot[tokenStart - 1]))
    {
        tokenStart--;
    }

    int tokenEnd = linePosition;
    while (tokenEnd < line.End.Position && IsValidTokenChar(snapshot[tokenEnd]))
    {
        tokenEnd++;
    }

    return new SnapshotSpan(line.Snapshot, tokenStart, tokenEnd - tokenStart);
}

A line here is ITextSnapshotLine - an immutable information about a line of text from an ITextSnapshot. We do not need to go outside the line, because attributes could not be beyond the single line. The algorithm is pretty much the same, but we go to both sides of the line to extract the entire attribute name.

If we successfully confirm that the cursor is inside an attribute, the CompletionContext with a list of items will be requested.

Providing a List of Completion Items

List of completion items provided from CompletionContext

public Task<CompletionContext> GetCompletionContextAsync(IAsyncCompletionSession session, CompletionTrigger trigger, SnapshotPoint triggerLocation, SnapshotSpan applicableToSpan, CancellationToken cancellationToken)
{
    var arr = ToolTipsProvider.Instance.Keywords.Select(ConvertToItem).ToImmutableArray();

    Output.WriteInfo($"HtmxCompletionSource:GetCompletionContextAsync: got a request for a context. Returning {arr.Length} items.");

    return Task.FromResult(new CompletionContext(arr));
}

The item itself has several parameters, and to be honest, I don't know what half of them do. However, I do know that we have text, insertText which will be inserted, and icon which adds a small icon to the left of the completion item.

private static ImageElement CompletionItemIcon = new ImageElement(KnownMonikers.HTMLEndTag.ToImageId(), "htmx");

private CompletionItem ConvertToItem(string text)
{
    string insertText = text == "hx-on" ? $"hx-on:" : $"{text}=\"\"";
    return new CompletionItem(
                    text,
                    insertText: insertText,
                    sortText: text,
                    filterText: text,
                    automationText: text,
                    source: this,
                    filters: ImmutableArray<CompletionFilter>.Empty,
                    icon: CompletionItemIcon,
                    suffix: default,
                    attributeIcons: ImmutableArray<ImageElement>.Empty);
}

Here is a screenshot to better understand what each part does:

Customizing Completion Behavior

By default, the completion feature places the cursor after the string. So, when hx-get is completed and hx-get="" is inserted, the cursor is set at the end. However, this isn't very convenient. We want the cursor inside the quotation marks, like this: hx-get="|".

This can be achieved by using IAsyncCompletionCommitManager.

 internal class HtmxCompletionCommitManager : IAsyncCompletionCommitManager
 {
     // ...
     public CommitResult TryCommit(IAsyncCompletionSession session, ITextBuffer buffer, CompletionItem item, char typedChar, CancellationToken token)
     {
         // Check if this is one of your custom completions
         if (item.InsertText.StartsWith("hx") && item.InsertText.EndsWith("=\"\""))
         {
             var span = session.ApplicableToSpan;

             // Commit the completion
             using (var edit = buffer.CreateEdit())
             {
                 edit.Replace(span.GetSpan(buffer.CurrentSnapshot), item.InsertText);
                 edit.Apply();
             }

             // Move the caret inside the quotes
             var newPosition = span.GetStartPoint(buffer.CurrentSnapshot).Position + item.InsertText.Length - 1;
             session.TextView.Caret.MoveTo(new SnapshotPoint(buffer.CurrentSnapshot, newPosition));

             Output.WriteInfo("HtmxCompletionCommitManager:TryCommit: committed completion.");
             return CommitResult.Handled;
         }

         return CommitResult.Unhandled;
     }
 }

The implementation here is straightforward. If we insert an hx- attribute, we move the caret inside the quotation marks.

Providing Selected Item Description

Method GetDescriptionAsync is responsible for obtaining an object to be rendered as a popup window with a description.

This popup window is different from the one rendered by IntelliSense. I believe It is intended to show less information, like text-only help. However, due to convenience, I implemented a single ContainerElement for both cases.

public Task<object> GetDescriptionAsync(IAsyncCompletionSession session, CompletionItem item, CancellationToken token)
{
    if (ToolTipsProvider.Instance.TryGetValue(item.DisplayText, out var element))
    {
        // add some spacing between text paragraphs (should really cache that as well)
        var elements = element.Elements.ToList();
        var newElements = new object[elements.Count * 2 - 1];
        for (int i = 0; i < elements.Count; i++)
        {
            newElements[i * 2] = elements[i];
            if (i < elements.Count - 1)
            {
                newElements[i * 2 + 1] = new ClassifiedTextElement(new ClassifiedTextRun(PredefinedClassificationTypeNames.WhiteSpace, " "));
            }
        }
        element = new ContainerElement(element.Style | ContainerElementStyle.Wrapped, newElements);
        return Task.FromResult((object)element);
    }

    return Task.FromResult<object>(null);
}

You can see that I had to add an empty line between paragraphs in the popup window because the renderer ignores ContainerElementStyle.VerticalPadding. Also link element is not supported in the code-completion window.

I will explore how to build ContainerElement later, as I initially built it for IntelliSense.

Source Provider

Interface IAsyncCompletionSourceProvider is responsible for providing the completion source for code completion. This is a MEF component and should be exported with the [ContentType] and [Name] attributes, and optionally the [TextViewRoles] attribute.

The completion feature will request data from all exported IAsyncCompletionSources whose ContentType matches the content type of any buffer at the completion's trigger location.

These content types are a bit tricky, so I started with [ContentType("html")] and [ContentType("razor")] and was very surprised that they do not work for .html files. I checked all available documentation and did not find out why. The problem is that in VS2022, ContentType("html-delegation") is used. You will not find this information in the documentation.

To figure this out, I was forced to set [ContentType("text")], which is the basic type for all text files, and then catch textView.TextBuffer.ContentType in the GetOrCreate method.

The final list of content types becomes quite extensive.

[Export(typeof(IAsyncCompletionSourceProvider))]
[Name("htmx completion source")]
[ContentType("html")]
[ContentType("htmlx")]
[ContentType("html-delegation")] // VS 2022
[ContentType("razor")]
[ContentType("LegacyRazorCSharp")] // VS 2022
internal class HtmxCompletionSourceProvider : IAsyncCompletionSourceProvider
{
    private readonly Lazy<HtmxCompletionSource> Source = new Lazy<HtmxCompletionSource>(() => new HtmxCompletionSource());

    /// <summary>
    /// Gets or creates an instance of <see cref="HtmxCompletionSource"/>.
    /// </summary>
    /// <param name="textView">The text view for which the completion source is requested.</param>
    /// <returns>An instance of <see cref="HtmxCompletionSource"/>.</returns>
    public IAsyncCompletionSource GetOrCreate(ITextView textView)
    {
        Output.WriteInfo("HtmxCompletionSourceProvider:GetOrCreate: got a request for a source.");
        return Source.Value;
    }
}

Also, notice that I use Lazy here to save memory and avoid creating anything unless it's requested. As I understand it, the lifecycle provider is created once, but GetOrCreate could be requested for each completion.

With our code completion feature in place, let's turn our attention to another critical aspect of developer assistance: IntelliSense. This feature works hand-in-hand with code completion to provide a comprehensive development experience.

IntelliSense Implementation

The IntelliSense feature displays additional information when hovering over an HTMX attribute. It checks if the attribute is within an HTML element and if it's an HTMX attribute before displaying the corresponding description.

The info popup itself provided through IAsyncQuickInfoSource

internal class HtmxQuickInfoSource : IAsyncQuickInfoSource
{
    public async Task<QuickInfoItem> GetQuickInfoItemAsync(IAsyncQuickInfoSession session, CancellationToken cancellationToken)
    {
        var (containerElement, applicableToSpan) = await BuildQuickInfoElementsAsync(session, cancellationToken);

        if (containerElement != null && applicableToSpan != null)
        {
            return new QuickInfoItem(applicableToSpan, containerElement);
        }

        return null;
    }

    // ... BuildQuickInfoElementsAsync
}

Building Container Element

A ContainerElement class represents a container of one or more elements for display in an IToolTipPresenter. It can contain other containers or ClassifiedTextRun elements, which represent parts of the text with applied styles.

Here is an example of how a typical tooltip is built. We have a ContainerElement with ClassifiedTextRun elements inside, each with different types.

This tooltip is created by parsing a markdown file into a set of containers. The code is straightforward, and if you want to check it out, feel free to look at the GitHub repo.

💡
There are plenty of built-in styles in PredefinedClassificationTypeNames, making it easy to present tooltips properly.

The link container is built a bit differently. We have a couple of factory methods in ClassifiedTextElement to create plain text and hyperlinks. So we could use it here:

public static ContainerElement CreateRichContent(string description, string link)
{
    var converter = new MarkdownConverter();

    var elements = converter.Convert(description);
    elements.Add(new ContainerElement(ContainerElementStyle.Wrapped,
        CreateHyperlink(link)));
    return new ContainerElement(ContainerElementStyle.Stacked | ContainerElementStyle.VerticalPadding,
        elements.ToArray());
}

private static ClassifiedTextElement CreateHyperlink(string url)
{
    return ClassifiedTextElement.CreateHyperlink("HTMX Reference", "Click here to see more details in official documentation", () =>
    {
        System.Diagnostics.Process.Start(url);
    });
}

Inside ClassifiedTextElement.CreateHyperlink is a simple text element with a navigation action set. This action will launch the default app to open the URL and navigate there.

return new ClassifiedTextElement(new ClassifiedTextRun("text", text, navigationAction, tooltip));

IntelliSense Controller

Interface IAsyncQuickInfoSource itself would not show quick info. To present a quick info tooltip we need to implement IIntellisenseController and handle the mouse event by ourselves.

internal class HtmxQuickInfoController : IIntellisenseController
{
    // ...
    public HtmxQuickInfoController(ITextView textView, IList<ITextBuffer> subjectBuffers, IAsyncQuickInfoBroker broker)
    {
        _textView = textView;
        _subjectBuffers = subjectBuffers;
        _broker = broker;

        _textView.MouseHover += this.OnTextViewMouseHover;
    }

    private async void OnTextViewMouseHover(object sender, MouseHoverEventArgs e)
    {
        // Debounce (not sure if needed)
        if ((DateTime.Now - _lastCheckTime).TotalMilliseconds < DebounceMs)
        {
            return;
        }

        _lastCheckTime = DateTime.Now;

        // find the mouse position by mapping down to the subject buffer
        var point = _textView.BufferGraph.MapDownToFirstMatch(new SnapshotPoint(_textView.TextSnapshot, e.Position),
                PointTrackingMode.Positive,
                snapshot => _subjectBuffers.Contains(snapshot.TextBuffer),
                PositionAffinity.Predecessor);

        if (point != null)
        {
            ITrackingPoint triggerPoint = point.Value.Snapshot.CreateTrackingPoint(point.Value.Position,
            PointTrackingMode.Positive);

            if (!_broker.IsQuickInfoActive(_textView))
            {
                _ = await _broker.
                    TriggerQuickInfoAsync(_textView, triggerPoint, QuickInfoSessionOptions.TrackMouse);
            }
        }
    }
}

The controller handles mouse movement and then triggers quick info through the broker. The appropriate quick info is shown based on whichever source returns the first quick info.

You might also notice a debounce logic there. I'm not entirely sure it's needed, but it seems to improve the experience, so I included it.

Source Provider

Obviously, we need to export our IIntellisenseController and IAsyncQuickInfoSource. This is done through source providers, similar to how completion sources work. The only difference is that they call TryCreate once, so there's no need for lazy initialization.

    [Export(typeof(IAsyncQuickInfoSourceProvider))]
    [Name("htmx tooltip quickInfo source")]
    [ContentType("html")]
    [ContentType("htmlx")]
    [ContentType("html-delegation")] // VS 2022
    [ContentType("razor")]
    [ContentType("LegacyRazorCSharp")] // VS 2022
    internal class HtmxQuickInfoSourceProvider : IAsyncQuickInfoSourceProvider
    {
        [Import]
        internal ITextStructureNavigatorSelectorService NavigatorService { get; set; }

        public IAsyncQuickInfoSource TryCreateQuickInfoSource(ITextBuffer textBuffer)
        {            
            Output.WriteInfo("HtmxQuickInfoSourceProvider:TryCreateQuickInfoSource: got a request for a source.");
            return new HtmxQuickInfoSource(NavigatorService, textBuffer);
        }
    }

    [Export(typeof(IIntellisenseControllerProvider))]
    [Name("htmx tooltip quickinfo controller")]
    [ContentType("html")]
    [ContentType("htmlx")]
    [ContentType("html-delegation")] // VS 2022
    [ContentType("razor")]
    [ContentType("LegacyRazorCSharp")] // VS 2022
    internal class HtmxQuickInfoControllerProvider : IIntellisenseControllerProvider
    {
        [Import]
        internal IAsyncQuickInfoBroker QuickInfoBroker { get; set; }

        public IIntellisenseController TryCreateIntellisenseController(ITextView textView, IList<ITextBuffer> subjectBuffers)
        {
            Output.WriteInfo("HtmxQuickInfoControllerProvider:TryCreateIntellisenseController: got a request for a controller.");

            return new HtmxQuickInfoController(textView, subjectBuffers, QuickInfoBroker);
        }
}

Cross-Version Support Challenges

Initially, my goal was to support both Visual Studio 2019 and 2022. However, due to architectural differences (x86 vs. x64) and API changes, the focus shifted to Visual Studio 2022 only.

When I set manifest, I could add different install targets, based on the Visual Studio version and architecture.

Targeting multiple versions of Visual Studio requires setting up different targets, using different SDKs, and sharing projects. There is a comprehensive guide available that I might explore later.

Packaging and Publishing

The extension is packaged as a .vsix file and published on the Visual Studio Marketplace. This process is very simple. When you build as release, Visual Studio packs your .vsix file. I recommend installing it locally first to ensure it works correctly.

The next step is to create an account on Visual Studio Marketplace and upload the .vsix file.

Most of the information will be taken from the manifest, but you can write a README and select categories and tags.

💡
I discovered that you cannot update the readme page without updating the VSIX version (hence v.1.0.1), so double-check your markdown before publishing.

The marketplace also provides useful analytics, so you can monitor the growth of your user base.

Lessons Learned and Future Plans

Writing an extension was a very rewarding experience. I got to explore how the internals of Visual Studio work, reviewed documentation and examples and significantly improved my htmx developer experience.

Here are some key lessons I want to share:

  1. API Documentation Gap: Current documentation, especially the manuals and examples, focuses on older methods, making it difficult to work with newer APIs.

  2. Community Resources: Existing GitHub projects and issues often provide valuable insights into new APIs, so study them thoroughly.

  3. Version Focus: Concentrating on a single Visual Studio version simplifies development. If I ever need to support the 2019 version, I would approach it with a fresh perspective.

  4. Experimentation: Understanding the completion subsystem required hands-on experimentation. The debugger works great, so use it to explore all the details.

In the future, I might add some improvements to the extension:

  1. Syntax highlighting to make hx-attributes more noticeable

  2. More completion options for complex attributes like hx-on: and others

  3. Additional features based on user feedback

Useful Links

Conclusion

The htmx-pal extension enhances Visual Studio's capabilities for HTMX development, offering code completion and quick info features. I spent around 12 hours building and publishing it, and I'm extremely delighted with how smooth the experience was.

There were moments when I almost gave up, such as when adding hx-... to the default code completion. However, these issues don't significantly affect the developer experience, so it's okay.

The most important part is that I now have a better way to write htmx apps, and so do you.

The extension is open source, and the code is available on GitHub. The extension itself is published on the marketplace. If you find it useful, I would be grateful if you rate it.

1
Subscribe to my newsletter

Read articles from Pavel Osadchuk directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pavel Osadchuk
Pavel Osadchuk

I'm a .NET Developer working on MS Stack for more than 12 years already. I worked with most dotnet-based technologies, from WinForms to Azure Functions. In my time, I won a dozen hackathons, launched a couple of startups, failed them, and am now working as a lead .NET developer in an enterprise company.