# Extending Visual Studio 2022

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](https://w3techs.com/diagram/history_technology/js-htmx align="center")

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](https://xakpc.info/htmx-dotnet) 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](https://marketplace.visualstudio.com/items?itemName=xakpc.htmx-pal) in the VS marketplace.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725302720988/3a80ac56-dbe5-4b19-b35d-d90978daeb19.gif align="center")

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](https://learn.microsoft.com/en-us/visualstudio/releases/2022/what-happened-to-vs-for-mac) it literally a couple of days ago.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725293391858/7dc3fc18-00a3-4ae2-8ed1-28414395b519.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725294812069/17eb354d-7022-43ee-b8aa-d92a81e9e5c9.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725294991236/91d09304-b0a7-441d-9afa-fb12b47b238f.png align="center")

# 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](https://learn.microsoft.com/en-us/visualstudio/extensibility/vsix-extension-schema-2-0-reference?view=vs-2022), 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.

```csharp
[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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725296948465/d6711653-fad8-4d10-b6f2-84e43640b61f.png align="center")

# 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-").

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725307786172/36dc5f98-73d1-42d5-8173-5ec3159b95de.gif align="center")

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:

```csharp
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.

```csharp
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.

```csharp
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.

```csharp
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`

```csharp
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.

```csharp
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725308836134/eb108900-30bf-491c-9eb6-c99dc3d301c0.png align="center")

### 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`.

```csharp
 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725312796259/16949674-def7-4aa9-861f-7daf55537e99.png align="center")

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.

```csharp
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725313838722/84d34863-e59b-45ad-ba35-349f87020765.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725314490521/22bd6ea1-c44e-427e-ba13-6ee9f6013a74.png align="center")

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.

```csharp
[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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725320812887/02b7c7a1-d0c9-4fb2-97dc-8fa19613c459.gif align="center")

The info popup itself provided through `IAsyncQuickInfoSource`

```csharp
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725316734169/7dd96eeb-01bd-43f2-a591-720c924e929d.png align="center")

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](https://github.com/xakpc/HtmxPal/blob/master/src/Xakpc.VisualStudio.Extensions.HtmxPal/Services/MarkdownConverter.cs).

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There are plenty of built-in styles in <code>PredefinedClassificationTypeNames</code>, making it easy to present tooltips properly.</div>
</div>

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:

```csharp
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.

```csharp
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.

```csharp
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.

```csharp
    [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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725318556732/cf79eacf-c449-407d-a64d-2dc0980c626c.png align="center")

Targeting multiple versions of Visual Studio requires setting up different targets, using different SDKs, and sharing projects. There is a comprehensive guide [available](https://learn.microsoft.com/en-us/visualstudio/extensibility/migration/update-visual-studio-extension?view=vs-2022) 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](https://marketplace.visualstudio.com/manage/) and upload the `.vsix` file.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725318897646/f73b9bb3-b15e-4a10-9dec-12832ca38ea5.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725319122421/2e2ebc08-1b01-41ad-9411-2fbaa453ab70.png align="center")

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">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.</div>
</div>

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725319251936/0e760fa5-d72d-4bc6-b295-9db36ecec811.png align="center")

# 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

* AsyncCompletion [source code](https://github.com/KirillOsenkov/vs-editor-api/tree/0209a13c58194d4f2a7d03a2615ef03e857547e7/src/Editor/Language/Def/Language/AsyncCompletion) (with examples)
    
* AsyncCompletion [sample project](https://github.com/microsoft/VSSDK-Extensibility-Samples/tree/master/AsyncCompletion)
    
* QuickInfo [sample project](https://github.com/microsoft/VSSDK-Extensibility-Samples/tree/master/AsyncQuickInfo)
    
* `Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion` [documentation](http://Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion)
    
* `Microsoft.VisualStudio.Language.Intellisense.IIntellisenseController` [documentation](https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.language.intellisense.iintellisensecontroller?view=visualstudiosdk-2022)
    
* htmx-pal [source code](https://github.com/xakpc/HtmxPal/tree/master)
    

# 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](https://github.com/xakpc/HtmxPal). The extension itself is published on the [marketplace](https://marketplace.visualstudio.com/items?itemName=xakpc.htmx-pal). If you find it useful, I would be grateful if you rate it.
