How to Create a Visual Studio 2012 Project Template - Part 3: The Wizard

by Larry Spencer Thursday, December 6, 2012 6:02 AM

In this series, we have been creating a Visual Studio 2012 Template for a unit-testing project. The objective has been to assist developers in following certain standards that may be similar to ones your organization has.

So far, we have

  • Created the unit-testing project that serves as the basis for our Project Template;
  • Considered how far you can get with the built-in File -> Export Template command; and
  • Created our own custom exporter.

The sample code is available here: ProjectTemplateTutorial.zip (271.98 kb)

If we were to use the template we've created so far, we'd find a few problems.

  • Our custom template parameter, $fwsdir$, which points to the base of our source tree, has not been populated.
  • Certain relative-path references in the created project will be incorrect. (More on this later.)
  • If the user enters Test as the project name, then the project and its default namespace will also be called Test. What we want instead is to infer the correct assembly name, project name and default namespace based on the directory in which the project is being created. For example, if we're creating the Test project in C:\Software\Fws\Common\Test, then we want Fws.Common.Test.dll, with a default namespace of Fws.Common.Test and a project named Fws.Common.Test.csproj.

Fortunately Visual Studio lets us install custom code to correct those problems and more. All we have to do is include a class that derives from Microsoft.VisualStudio.TemplateWizard.IWizard in our extension.

In this post, we'll see how to create the IWizard. In the next post, we'll package it as part of the deployed template.

Creating the IWizard-Derived Class

The IWizard-derived class can go in any assembly that is strongly named. In the sample code, it has its own project, Fws.VS2012.ProjectWizard. The class itself is Wizard.cs.

Adding Custom Template Parameters

When Visual Studio creates a project from a template, it makes substitutions for parameters it encounters in the source files that form the template. Our first task in the Wizard will be to add a custom variable to the list.

The IWizard interface specifies a RunStarted method, called at the beginning of the template-instantiation process. One of its parameters is the dictionary of template parameters. When we implement RunStarted, we're free to add additional variables to the dictionary, or modify the ones that are already there.

In this code, we start with the destination directory that the user chose in Add -> New Project and compute from that the the relative path to the root of our source tree. We put that in a new variable, $fwsdir$ (named in Properties.Resources.FwsDirVariable).

We also figure out what the namespace of the new project should be based on our standard that it match the directory structure. We put that in a property, TestProjectNamespace, that we'll be able to reference in later Wizard methods.

Finally, we modify Visual Studio's $namespace$ variable (Properties.Resources.NamespaceVariable) so that it, too, matches the directory structure.

 

/// <summary>
/// Called when when processing starts.
/// </summary>
/// <param name="automationObject">Unused.</param>
/// <param name="replacementsDictionary">A dictionary of replacement variables.</param>
/// <param name="runKind">Should always be AsNewProject for this extension.</param>
/// <param name="customParams">Unused.</param>
public void RunStarted(object automationObject, Dictionary<string, string> replacementsDictionary, WizardRunKind runKind, object[] customParams)
{
    DestinationDirectory = replacementsDictionary["$destinationdirectory$"];

    replacementsDictionary.Add(Properties.Resources.FwsDirVariable, Properties.Resources.DefaultFwsDir);

    TestProjectNamespace = Properties.Resources.DefaultTestProjectNamespace;

    if (Regex.IsMatch(DestinationDirectory, @"\\fws\\", RegexOptions.IgnoreCase))
    {
        // Our standards require a namespace that matches the directory hierarchy, starting with the last Fws node.
        TestProjectNamespace = Regex.Replace(
            DestinationDirectory.Replace('\\', '.').Replace(" ", ""),
            @"^.*\.Fws\.",
            "Fws.");
        // We also want to ensure that each letter that follows a period is upper case.
        var matchCollection = Regex.Matches(TestProjectNamespace, @"\.[a-z]");
        foreach (Match match in matchCollection)
        {
            TestProjectNamespace = TestProjectNamespace.Substring(0, match.Index) + match.Value.ToUpper() + TestProjectNamespace.Substring(match.Index + match.Length);
        }

        // Set the $fwsdir$ variable to be the path relative to the destination directory, that points to fws
        int directoriesToGoBack = Regex.Match(DestinationDirectory, @"\\Fws\\([^\\]+\\?)+", RegexOptions.IgnoreCase).Groups[1].Captures.Count;
        FwsDir =
        replacementsDictionary[Properties.Resources.FwsDirVariable] = new string('x', directoriesToGoBack).Replace("x", @"..\");
    }
    replacementsDictionary.Add(Properties.Resources.NamespaceVariable, TestProjectNamespace);
}

 

Modifying the Name of the Project

Visual Studio calls IWizard.ProjectFinishedGenerating when it has buit the project in memory, but not yet written it to disk. The EnvDTE.Project parameter has a Name property that is just what we want. Here we set it from the CorrectProjectName property of our class, which in turn equates to the namespace.

 

private string CorrectProjectName { get { return TestProjectNamespace; } }

// ...

/// <summary>
/// This IWizard method is called when the project has been generated.
/// In our implementation, we rename the project to conform to our standards
/// (matching the project name to the namespace). 
/// </summary>
/// <param name="project">The new project.</param>
public void ProjectFinishedGenerating(EnvDTE.Project project)
{
    project.Name = CorrectProjectName;
}

 

Fixing Relative References

Now comes the tricky bit. Apparently, when Visual Studio creates the project on disk, it writes it to someplace under your TEMP directory (e.g., under C:\users\lspencer\AppData\Local). When it finally moves the project to the directory you requested, the relative links in the project file will still point back to the TEMP area. This was the problem we encountered at the end of Part 1 of this series.

We don't get a chance to fix this until IWizard.RunFinished. Here, we load the project file into an XmlDocument and search for <Link> elements anywhere under <Project><ItemGroup> elements. Wherever we find one, we change its Include attribute to point to the base of our source tree (the FwsDir property that we set in RunStarted).

Once that's done, we want to save the project. If we were to do that naively, the user would get the message, "Project Xyz.Test has changed. Do you want to reload it?" (Remember, this whole thing is running while you're in Visual Studio!) To avoid that annoyance, we use the EnvDTE80.DTE2 COM interface to unload the project before saving, and then reload it after.

 

/// <summary>
/// This method is called at the very end of IWizard processing. We use it to fix bad links.
/// </summary>
/// <remarks>
/// <para>
/// This annoying step is necessary because VS originally creates the project file somewhere off
/// the temp directory. As it does so, it messes up the paths to linked files so instead of
/// "..\..\_SigningInformation\Fws.snk" we get
/// "..\..\..\..\..\Users\lspencer\AppData\Local\Temp\__SigningInformation\Fws.snk".</para>
/// <para>I have proven that this is a bug in VS by doing a 
/// File -> Export Template and using the template thus generated, with no custom
/// code at all; it has the same problem.
/// </para>
/// <para>We'd like to have fixed the file names in the ProjectFinishedGenerating method, but it turns out
/// that the file names are not settable there, nor can we remove the offending ProjectItem
/// and replace it with a corrected one (VS complains that we already have a ProjectItem
/// by that name, even if we save the Project after removing the ProjectItem).
/// </para>
/// </remarks>
public void RunFinished()
{
    XmlDocument doc = new XmlDocument();
    doc.Load(FullyPathedProjectFileName);
    var nsmgr = new XmlNamespaceManager(doc.NameTable);
    var ns = "http://schemas.microsoft.com/developer/msbuild/2003";
    nsmgr.AddNamespace("d", ns);
    XmlNodeList linkNodes = doc.SelectNodes("/d:Project/d:ItemGroup//d:Link",nsmgr); // Between ItemGroup and Link there could be Compile, None, or maybe others.
    bool changed = false;
    for (int ix = 0; ix < linkNodes.Count; ++ix)
    {
        var attrInclude = linkNodes[ix].ParentNode.Attributes["Include"];
        if (attrInclude != null)
        {
            var fullPath = Path.GetFullPath(attrInclude.Value);
            var tempPath = Path.GetTempPath();
            if (fullPath.StartsWith(tempPath))
            {
                attrInclude.Value = fullPath.Replace(tempPath, FwsDir);
                changed = true;
            }
        }
    }
    if (changed)
    {
        // We want to unload the project before saving our changes,
        // so the user does not get a message asking if he wants to reload it.
        // We will reload programmatically after the save.
        EnvDTE80.DTE2 dte = (EnvDTE80.DTE2)System.Runtime.InteropServices.Marshal.GetActiveObject("VisualStudio.DTE.11.0");
        string solutionName = Path.GetFileNameWithoutExtension(dte.Solution.FullName);
        dte.Windows.Item(EnvDTE.Constants.vsWindowKindSolutionExplorer).Activate();
        dte.ToolWindows.SolutionExplorer.GetItem(solutionName + @"\" + CorrectProjectName).Select(vsUISelectionType.vsUISelectionTypeSelect);
        dte.ExecuteCommand("Project.UnloadProject");
        doc.Save(FullyPathedProjectFileName);
        dte.ExecuteCommand("Project.ReloadProject");
    }
}

Next time, we'll see how to package the Wizard, the template and the icon into a .vsix file for deployment.

Tags: ,

All | Talks

About the Author

Larry Spencer

Larry Spencer develops software with the Microsoft .NET Framework for ScerIS, a document-management company in Sudbury, MA.