Dr. Dobb's Journal January, 2005
Visual Studio 2003 has an object model that encourages you to extend its capabilities. You can access the objects three waysby writing add-ins or plug-in modules, using it as a component within external applications, or by writing Visual Basic macros. Each approach has advantages. In this article, I focus on writing macros, which are an excellent way to automate repetitive tasks. For instance, maintaining NANT build scripts is one repetitive task that can be automated using macros (for a description of NANT, see the accompanying sidebar entitled "A Brief Description Of NANT").
Within the Visual Studio object model is a set of objects that represent solutions, projects, files, and settings that make up a Visual Studio solution. When you execute the build command within Visual Studio, it uses this information to build your solution. Here, I present a macro that uses NANT to drive the build instead. The macro generates a NANT script and compiles a C# project. (You can add support for Visual Basic, too.) A second macro generates a skeleton master build file that calls each of the project builds.
While automated builds make the development process more manageable, maintaining an automated build is still a complex task. To make the build easier to manage, each project is put in its own build file. A master-build file is then generated separately to drive the build. This approach makes it easier for you to add extra steps to the build process. For instance, you might want to include a set of unit tests in your build. With this approach, you can rerun the macro to regenerate the project files and not lose any of your customizations in the master-build file.
To begin writing the macro, first start Visual Studio. To make debugging the macro easier, open or create a project. Next, open the macro explorer by going to the Other Windows submenu under the View Menu. (Alternatively, you can just press Alt+F8.) The macro explorer is typically docked with your solution explorer and class viewer. You should see two nodes in the macro treeMyMacros and Samples. Both are provided by Visual Studio when you install it. Expand MyMacros and you see "Module1" appear below it. Right click on Module1 and choose New Macro. This start-up uses the Visual Studio Macros editor, which is another instance of Visual Studio, but it has your MyMacros file open in it.
All Visual Studio macros have to be written in Visual Basic, which provides full access to the .NET Framework. The macro file already has two imports at the top.
To write the macro, you need to import a few more namespaces. First, you need the VSLangProj, which provides a set of objects that extend the Visual Studio object model specifically for C# and Visual Basic projects. Next, you need the System.IO namespace to read/write files. System.Text is needed for string builder, and the System.XML namespace to generate the NANT XML build script.
I divided the macro into separate setup subroutines, making it easier to modify for other purposes. The first subroutine iterates over the collection of projects in the solution. For each C# project it finds, it generates the project's build script. To iterate over the collection of projects, you first have to get references to the collection of projects in the active solution. This collection follows normal Visual Basic practices as its first index is at 1 (I expected it to be at 0, which caused me some frustration initially). Iterate over the collection and call a subroutine for each C# project; see Listing One. The VSLangProj namespace provided the prjKind object used in the if statement. Using the prjKind object makes the code more readable. I would have had to compare against the C# project GUID without it.
If you have a C# project, call the GenerateCSharpProjectBuild subroutine and pass it the project object to work on. To include Visual Basic, you would also need to generate vb tasks. A simple csc task looks something like Listing Two.
To make generating the build for the C# project easier, I split the process by first setting up the build file and generating the basic properties, then dealing with references, resources, and source files in separate subroutines. The properties for the first step are the output directory, the name of the output assembly, and whether you are compiling for release or debug (Listing Three).
The build file is kept in the same directory as the project file; the directory name comes from the project's properties collection. The properties collection is a dictionary of name value pairs. The property is FullPath.
The project's csc task needs to be contained within a project and target. The project provides a scope so that the master-build file can provide settings to the project. The target is a subroutine that the master-build calls to build the project. Both elements require a name attribute. The project requires a default target that it calls when it runs. Within the project's target, I add a property for the target to hold the output assembly name.
I add the depends attribute to the target to control the order in which the projects get built. To create the list of target dependencies, the GetProjectDependencies iterates over the project's references collection. Each reference that refers to a Visual Studio project gets added to the list of dependencies. The list is a comma-delimited string. When NANT builds the solution, it uses the dependencies list to determine the order in which it builds the solution.
VisualStudio's build process keeps the output in the project's directory, too. The output gets put in a subdirectory of the project's bin directory. The subdirectory is named for the build configuration. Normally, it is either "Debug" or "Release." To implement the build, I use a pair of NANT properties. The first property is the name of the output being built that contains another NANT property that determines the subdirectory to put the output in. The output assembly property looks like Listing Four.
The outputSubDirectory property is set in the master-build file prior to calling the project's build. Within the macro project, I wrote two subroutines to make this part of the process easier. The first subrouting writes NANT properties, while the second generates the output path (see Listing Five).
While the csc task has lots of attributes that can be supplied, I only use three:
Within the csc task, you need to supply the references, resources, and source files for the compiler to use. The WriteReferenceElement subroutine writes the references for the project. The project object contains a collection of references for the project. The macro needs to handle DLL references and project references. In both cases the result is a path to a DLL. References to DLLs (assemblies) are easily handled. Project references require an extra step. You have to determine the output path first. To get the project output path, build a string using the project's properties for "FullPath" and "OutputFileName." The GetReferenceProjectOutput handles building the string; see Listing Seven.
If there are resources in the project, the WriteResourcesElement subroutine gets called. If the item in the ProjectItems collection is a resource file, it is added to the resources element. The actual source-code files are added to the sources element by the WriteSourcesElement subroutine. Just like the WriteResourcesElement subroutine, it iterates over the ProjectItems collection. The difference here is that it looks for C# source files; see Listing Eight.
After you have generated a build file for each project in the solution, you can generate a master-build file. Generally, I don't want to automatically generate the master-build script because there are many other tasks that need to occur during a build besides compiling projects. For instance, before compiling the projects, you may want to grab the latest source code from a source repository such as SourceSafe. You may also wish to run unit tests and generate an MSI after you have successfully compiled your projects. Some of these tasks could be generated by a macro.
However, you may inherit a project that does not have an automated build. In this case, having a skeleton master-build script generated would be nice. The master-build script will include all of the project-build scripts and call each project's build once for a debug build and once for a release build. In the process of doing these tasks, it sets the properties for output directory and debug mode. The macro for doing this is in GenerateNANTSolutionScript. To make the script easier to maintain and edit, the build is broken into four parts. The buildAll target calls the buildDebug and buildRelease targets. The buildDebug and buildRelease targets both set up the debugMode and outputSubDirectory properties, then call the build target, which calls the actual projects so they can compile the sources. When the macro is run, it generates the project-build scripts, then generates the master-build script. After you have generated your initial build scripts you only have to regenerate the project-build scripts to maintain the build.
Using the generation of NANT build script, I've demonstrated how to use Visual Studio's Solution and Project objects to make build management easier. Visual Studio provides an object model that is accessible and that lets you extend and expand its capabilities. Effectively used, you can save yourself a lot of work.
DDJ
Dim projectCollection as EnvDTE.Projects
Dim counter as integer
projectCollection = DTE.Solution.Projects
For counter = 1 to projectCollection.count
Dim aProject as Project
aProject = projectCollection.Item(counter)
if aProject.Kind = prjKind.prjKindCSharpProject then
GenerateCSharpProjectBuild(aProject)
end if
next
Back to article<csc ...>
<references>
....
</references>
<resources>
...
</resources>
<sources>
...
</sources>
</csc>
Back to articleDim outputFileName As String
outputFileName = theProject.Properties.Item("FullPath").Value & "\" &
theProject.Name & ".build"
Dim xmlWriter As New XmlTextWriter(outputFileName, _
System.Text.ASCIIEncoding.ASCII)
xmlWriter.Formatting = Formatting.Indented
Back to article<property name="outputAssembly" value="C:\work\keep\sample\${outputSubDirectory}\sample.exe"/>
Back to article'Write a NANT property element
Private Sub WritePropertyElement(ByVal writer As XmlTextWriter,
ByVal propertyName As String, ByVal propertyValue As String)
writer.WriteStartElement("property")
writer.WriteAttributeString("name", propertyName)
writer.WriteAttributeString("value", propertyValue)
writer.WriteEndElement()
End Sub
Private Function GetOutputPath(ByVal theProject As Project)
GetOutputPath = theProject.Properties.Item("FullPath").Value &
"${outputSubDirectory}\" &
theProject.Properties.Item("OutputFileName").Value
End Function
Back to article'Determine the project's type (exe, dll, etc)
Private Function GetProjectType(ByVal theProject As Project) As String
Select Case CType(theProject.Properties.Item("OutputType").Value, Integer)
Case 1
GetProjectType = "exe"
Case 2
GetProjectType = "library"
Case 0
GetProjectType = "winexe"
Case 4
GetProjectType = "module"
End Select
End Function
Back to article'Returns the path to the references project's output
Private Function GetReferencedProjectOutput(ByVal refProject As Project) As String
GetReferencedProjectOutput = refProject.Properties.Item("FullPath").Value &
"${outputSubDirectory}\" & _
refProject.Properties.Item("OutputFileName").Value
End Function
Back to article'Write the sources needed to compile the project
Private Sub WriteSourcesElement(ByVal theProject As Project, ByVal writer As XmlTextWriter)
Dim counter As Integer
writer.WriteStartElement("sources")
writer.WriteAttributeString("basedir", theProject.Properties.Item("FullPath").Value)
counter = 1
For counter = 1 To theProject.ProjectItems.Count
Dim projectFile As ProjectItem
Dim filePath As String
projectFile = theProject.ProjectItems.Item(counter)
If "cs" = Right(projectFile.Name, 2) Then
WriteIncludeElement(projectFile.Name, writer)
End If
Next
writer.WriteEndElement()
End Sub
Back to article