Adventures in Visual Studio Extension Development: Part 2 - Events
This is part of a series:
- Part 1: The Basics
- Part 2: Events
In this post, I will take my simple extension, NArrangeVS, from having a single command in the menu and some options to running the command on certain files each time they are saved. I will also refactor some of the code to enhance performance and fix issues that are encountered along the way.
Visual Studio Events
The first thing you will have to realize about extension development in Visual Studio is that there are a lot of them and they are spread out in different namespaces. The core extensibility functionality is located in the EnvDTE Namespace with additional functionality added in subsequent Visual Studio releases in the EnvDTE80 Namespace, EnvDTE90 Namespace, EnvDTE90a Namespace, and EnvDTE100 Namespace. The second is that there can be several classes or interfaces which build on top of each other. For example, there is an Events2 Interface in EnvDTE80 which adds additional functionality to the Events Interface in EnvDTE.
First Implementation Using DocumentEvents
The Events Interface has a DocumentEvents Property which returns a DocumentEvents Interface. This interface has a couple events for documents in Visual Studio, but the one needed for this extension is DocumentSaved. The following code shows how I got a reference to the DocumentEvents Interface in the VSPackage constructor (fileRegex
will be used later).
dte = new Lazy<DTE2>(() => ServiceProvider.GlobalProvider.GetService(typeof(EnvDTE.DTE)) as DTE2);
events = new Lazy<EnvDTE.Events>(() => dte.Value.Events);
documentEvents = new Lazy<EnvDTE.DocumentEvents>(() => events.Value.DocumentEvents);
fileRegex = new Lazy<Regex>(() => new Regex(options.Value.ArrangeFileRegex));
Then in the Initialize method, the event is added as shown below.
documentEvents.Value.DocumentSaved += DocumentEvents_DocumentSaved;
The actual event method is pretty simple and just calls the NArrangeDocument method defined in Part 1. It does use a couple new options added to the options page.
private void DocumentEvents_DocumentSaved(EnvDTE.Document document)
{
if (options.Value.ArrangeFileOnSave && fileRegex.Value.IsMatch(document.FullName)) {
NArrangeDocument(document);
}
}
The new options which were added are shown below. The first option, ArrangeFileOnSave
, enables and disables the functionality to arrange files on save. The second option, ArrangeFileRegex
, allows changing the filter for which files to arrange on save. The default for this option is the string \\.(cs|vb)$
, which allows all files with a cs
or vb
extension to automatically be arranged.
[Category("Integration")]
[DisplayName("Arrange File On Save")]
[Description("When enabled, all files which match the arrange file mask will be arranged.")]
public bool ArrangeFileOnSave
{
get;
set;
}
[Category("Integration")]
[DisplayName("Arrange File Mask")]
[Description("A regular expression to match files to automatically arrange.")]
public string ArrangeFileRegex
{
get;
set;
}
Loading the Extension
So, we implement all of the event functionality and try out the extension. The first time we save a file, no arranging happens. If we run the arrange command from the menu item, it runs fine and then subsequent file saves also get arranged. That seems weird. It turns out that Visual Studio only loads the extensions as needed. We have to add another one of our provide attributes, ProvideAutoLoadAttribute
, to the VSPackage class to get the extension to load sooner.
[ProvideAutoLoad("ADFC4E64-0397-11D1-9F4E-00A0C911004F")]
I found the GUID to pass to this from a blog post I found to get the extension to load as soon as possible. After further research on MSDN, it turns out to be the same as the UIContextGuids80.NoSolution
field. The remarks seem to indicate that it would only become active when a solution is closed or Visual Studio is started with the "Show Empty Environment" option set in startup; however, it looks like it happens when Visual Studio is started no matter the value of the startup setting. There are other useful contexts in the UIContextGuids80
Class. The attribute can be rewritten as shown.
[ProvideAutoLoad(UIContextGuids80.NoSolution)]
Tracking Down a Difficult Bug
A user reported that NArrangeVS was causing Visual Studio to crash when using the Refactor -> Rename option. It is funny that I had noticed this but never realized it was because of my extension. So I fired up the debugger and found that when using the Rename functionality it indeed did crash Visual Studio with a rather unhelpful AccessViolationException
.
Removing the document save event would allow rename to function without issue. So I thought maybe it had something to do with that event in particular. This lead to the separate event invitation I will show in the next section. This did nothing to fix the Rename functionality, but it did improve performance. Debugging showed the Rename would cause the arrange method to be called over and over again seemingly recursively which would eventually cause a crash. Google returned no help. I eventually just started removing stuff to see what would happen. The solution was to not use an UndoContext
. Fortunately by this point of refactoring, I really didn't need to use one.
I tried to see if there was some way to determine that a Rename was taking place or that an UndoContext
should not be used, but I was unable to find one. The class does say that you should check for an open context before opening one, but it does not show that one is open in this case. If someone knows why or how to handle this situation, please let me know.
Second Implementation Using IVsRunningDocTableEvents3
The IVsRunningDocTableEvents3
Interface exposes events that are fired when something happens to a document in the running document table. It inherits from IVsRunningDocTableEvents2
Interface which in turn inherits from the IVsRunningDocTableEvents
Interface. I chose this version because it is the first to include an OnBeforeSave
Method. There are also an IVsRunningDocTableEvents4
Interface and an IVsRunningDocTableEvents5
Interface.
So I created a new class which implements the IVsRunningDocTableEvents3
Interface. There are a lot of methods which I won't need. For those, we can just return a VSConstants.S_OK
. For the OnBeforeSave
method, we first have to get the RunningDocumentInfo
using the cookie provided and then get the actual Document
before calling our arrange method.
internal class RunningDocTableEvents : IVsRunningDocTableEvents3
{
private NArrangeVSPackage package;
public RunningDocTableEvents(NArrangeVSPackage package)
{
this.package = package;
}
public int OnAfterAttributeChange(uint docCookie, uint grfAttribs)
{
return VSConstants.S_OK;
}
public int OnAfterAttributeChangeEx(uint docCookie, uint grfAttribs, IVsHierarchy pHierOld, uint itemidOld, string pszMkDocumentOld, IVsHierarchy pHierNew, uint itemidNew, string pszMkDocumentNew)
{
return VSConstants.S_OK;
}
public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame pFrame)
{
return VSConstants.S_OK;
}
public int OnAfterFirstDocumentLock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining)
{
return VSConstants.S_OK;
}
public int OnAfterSave(uint docCookie)
{
return VSConstants.S_OK;
}
public int OnBeforeDocumentWindowShow(uint docCookie, int fFirstShow, IVsWindowFrame pFrame)
{
return VSConstants.S_OK;
}
public int OnBeforeLastDocumentUnlock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining)
{
return VSConstants.S_OK;
}
public int OnBeforeSave(uint docCookie)
{
if (package.options.Value.ArrangeFileOnSave) {
RunningDocumentInfo runningDocumentInfo = package.rdt.Value.GetDocumentInfo(docCookie);
EnvDTE.Document document = package.dte.Value.Documents.OfType<EnvDTE.Document>().SingleOrDefault(x => x.FullName == runningDocumentInfo.Moniker);
if ((document != null) && package.fileRegex.Value.IsMatch(document.FullName)) {
package.NArrangeDocument(document);
}
}
return VSConstants.S_OK;
}
}
Of course, creating this class won't cause the events to actually fire. We have to register the class using the RunningDocumentTable.Advise
Method. This is done in the Initialize
method for my VSPackage
. For my class, I passed in the VSPackage
class so I could use it to call the arrange method.
rdt.Value.Advise(new RunningDocTableEvents(this));
Arrange Method Improvements
During my escapades in bug tracking, the arrange method was completely overhauled. The biggest change is that I added a new class to the NArrange project, published the core dlls as a nuget package, and use them in the project directly instead of calling NArrange on the command line. This removes the need for NArrange to be installed on the local machine and improves the performance. It also allows me to use editing functionality built into Visual Studio to fetch and replace the text of the document.
The editing functionality involves using an EditPoint
. This interface exposes methods for all kinds of text manipulation. I first have to convert my Document
to a TextDocument
which has TextPoint
references to the start and end of the document. The TextPoint
also exposes a method, CreateEditPoint
which return the EditPoint
which is needed. The following code shows how I gather the entire document text and replace it with the arranged text in the arrange method. The -1 in the ReplaceText
sets all of the flags for the vsEPReplaceTextOptions
. In a later release of NArrangeVS, I actually make these options configurable as shown in the following code listings.
internal void NArrangeDocument(EnvDTE.Document document)
{
// Get the text document.
TextDocument textDocument = document.Object("TextDocument") as TextDocument;
// Get the full document text.
EditPoint editStart = textDocument.StartPoint.CreateEditPoint();
string inputFileText = editStart.GetText(textDocument.EndPoint);
// Arrange the text.
string outputFileText;
StringArranger stringArranger = new StringArranger(string.IsNullOrWhiteSpace(options.Value.NArrangeConfigLocation) ? null : options.Value.NArrangeConfigLocation, new OutputPaneLogger(this));
bool success = stringArranger.Arrange(document.FullName, inputFileText, out outputFileText);
if (success) {
// Overwrite the file.
editStart.ReplaceText(textDocument.EndPoint, outputFileText, -1);
}
}
// Overwrite the file.
ArrangeFileOptions arrangeFileOptions =
(options.Value.ArrangeFileFormat ? ArrangeFileOptions.Format : 0) |
(options.Value.ArrangeFileKeepMarkers ? ArrangeFileOptions.KeepMarkers : 0) |
(options.Value.ArrangeFileNormalizeNewlines ? ArrangeFileOptions.NormalizeNewlines : 0) |
(options.Value.ArrangeFileTabsSpaces ? ArrangeFileOptions.TabsSpaces : 0)
;
editStart.ReplaceText(textDocument.EndPoint, outputFileText, (int)arrangeFileOptions);
[Flags]
public enum ArrangeFileOptions
{
KeepMarkers = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers,
NormalizeNewlines = vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewlines,
TabsSpaces = vsEPReplaceTextOptions.vsEPReplaceTextTabsSpaces,
Format = vsEPReplaceTextOptions.vsEPReplaceTextAutoformat,
}
Conclusion
That concludes my adventures so far. In the future, I have some enhancements planned, such as adding a toolbar to quickly change settings, adding support for per project or per solution settings, adding support for arranging an entire project, adding an option to remove unused usings before arranging, and maybe even adding file specific settings similar to vim's modeline magic.