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.
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
Completion Source: Implements
IAsyncCompletionSource
interface to provide HTMX-specific code completion.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
IAsyncCompletionSourceProvider
: provides instances ofIAsyncCompletionSource
which providesCompletionItems
IAsyncCompletionCommitManagerProvider
: provides instances ofIAsyncCompletionCommitManager
which provides means to adjust the commit behavior, including which typed characters commitIAsyncQuickInfoSourceProvider
: creates anIAsyncQuickInfoSource
specifiedITextBuffer
.IIntellisenseControllerProvider
: creates IntelliSense controllers for individualITextView
instances.
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.
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.
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:
API Documentation Gap: Current documentation, especially the manuals and examples, focuses on older methods, making it difficult to work with newer APIs.
Community Resources: Existing GitHub projects and issues often provide valuable insights into new APIs, so study them thoroughly.
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.
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:
Syntax highlighting to make hx-attributes more noticeable
More completion options for complex attributes like
hx-on:
and othersAdditional features based on user feedback
Useful Links
AsyncCompletion source code (with examples)
AsyncCompletion sample project
QuickInfo sample project
Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion
documentationMicrosoft.VisualStudio.Language.Intellisense.IIntellisenseController
documentationhtmx-pal source code
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.
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.