Package your projects for submission to CodeProject in one click from within Visual Studio
Introduction
If you submit articles here, by now, you know the drill: Either exit Visual Studio to clear all the file locks, delete the unnecessary files like the bin, obj, and .vs directories, and then zip the remaining contents into a file, or carefully and individually add the desired files to the zip, excluding the aforementioned directories. Another option, if you're using GitHub, is to just download your project from there as a prepackaged zip, and then upload it here.
Well, it's just enough work that I finally made a tool to automate it. Mainly, I'm sick of exiting Visual Studio to deliver my project. Maybe you are, too. If so, then this tool is for you.
Update: Fixed a bug where if you used links to share the same file across multiple projects in a solution it would add the file twice.
Using this Mess
There are two ways to engage this tool:
The first, and probably the most common is through the Visual Studio Tools menu. After installing this tool, under the Tools menu, you'll see Create Code Project/Zip Package. Clicking it will create a zip file named after the solution and place it within the solution directory. It will then launch a shell folder with the new zip file selected. You'll have to do this before submitting it to Code Project any time you update your code. Any previous zip is automatically and silently overwritten so be aware of that.
The second way is by using the command line utility, cppkg. Let's take a look at the usage screen:
cppkg.exe <inputFile> [/output <outputFile>]
Creates a zip file out of the projects in the solution.
<inputFile> The input solution file to package
<outputFile> The output zip file to create
As you can see, it's very simple. It takes one unswitched argument and one optional switched argument. The former is the path to the solution file (.sln) while the latter is the path to the zip file. If the output option is not specified then the zip file is created with the same name and within the same directory as the solution itself. Again, any previous zip file is silently overwritten.
You can "cheat" a little and specify this command line as a post build step in each of the solution's projects. This way, it will rebuild the zip any time the solution is rebuilt. It's a little bit more automatic than using the VS tool, but it's also overkill. Primarily, this CLI tool is provided for you if you ever want to batch the process for some reason.
The tools work by scanning the solution file for any projects, and then for each project, they do various queries on the project file to determine what files to include or exclude from the project. Therefore, you must include in the project any file you want to include in the zip. Being in the solution's folder is not enough. This has pros and cons, the pro being more tight control of what goes in the zip, but the con of having to remember to include content files into the project.
Understanding the Code
The meat of the code involves dealing with the solution file format and then the project file formats, of which there are two styles. The first style is a .NET framework style project file, and the second is a .NET core and standard project file. They both share a similar format but follow slightly different rules.
The project files are in XML, while the solution file is in its own proprietary format that requires a bit of hand rolled parsing. For that, we use my LexContext code, which dramatically simplifies the process. For the XML bits, we rely heavily on XPath
.
First, let's look at the solution format. Here, we'll use this cppkg.sln as an example:
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29905.134
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cppkg", _
"cppkg\cppkg.csproj", "{76E9E90F-A812-468A-BE80-4B9577CCFB97}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", _
"{C91DBE2D-B696-47DB-A04D-483D0E109321}"
ProjectSection(SolutionItems) = preProject
TextFile1.txt = TextFile1.txt
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cppkgvs", _
"cppkgvs\cppkgvs.csproj", "{A4607C1A-CD0E-4878-9BF1-520146AFC55D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "testCore", _
"testCore\testCore.csproj", "{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = _
"testVBCore", "testVBCore\testVBCore.vbproj", "{261DF867-B1A4-41C2-AAD3-87BFB4B22151}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Release|Any CPU.Build.0 = Release|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Release|Any CPU.Build.0 = Release|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Release|Any CPU.Build.0 = Release|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Debug|Any CPU.Build.0 = Debug|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Release|Any CPU.ActiveCfg = Release|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3578F29E-FEF5-4B2A-8259-3FE6EAB153D9}
EndGlobalSection
EndGlobal
We only care about the bolded bits above, so don't worry about the rest of it. Our code starts off by looking for "Project" as the first substring on each line. If it finds it, it branches for the one containing "solution folders" which has the identifying GUID of {2150E333-8FDC-42A3-9474-1A3956D46DE8}
because we need to handle it specially. Here's the code to do that and process the other projects as well:
var projects = new List<string>();
var files = new List<string>();
using (var sr = new StreamReader(solutionFile))
{
var dir = Path.GetDirectoryName(solutionFile);
string line;
while (null != (line = sr.ReadLine()))
{
if (line.ToLowerInvariant().StartsWith("project"))
{
var lc = LexContext.Create(line);
lc.TrySkipLetters();
lc.TrySkipWhiteSpace();
if ('(' == lc.Current)
{
if(-1!=lc.Advance())
{
lc.ClearCapture();
if(lc.TryReadDosString())
{
if ("\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\""
!= lc.GetCapture().ToUpperInvariant())
{
lc.TrySkipWhiteSpace();
if (')' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
if ('=' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
string name;
lc.ClearCapture();
if (lc.TryReadDosString())
{
name = lc.GetCapture();
lc.TrySkipWhiteSpace();
if (',' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
lc.ClearCapture();
string path;
if (lc.TryReadDosString())
{
path = lc.GetCapture();
path = path.Substring(1, path.Length - 2);
path = path.Replace("\"\"", "\"");
path = Path.Combine(dir, path);
path = Path.GetFullPath(path);
if(File.Exists(path))
projects.Add(path);
}
}
}
}
}
}
else
{
lc.TrySkipWhiteSpace();
if (')' == lc.Current && -1 != lc.Advance())
{
while (null != (line = sr.ReadLine().Trim().ToLowerInvariant()) &&
line != "endproject" &&
!line.StartsWith("projectsection")) ;
if(line.StartsWith("projectsection") &&
null != (line = sr.ReadLine()) &&
line.Trim().ToLowerInvariant() != "endproject")
{
do
{
lc = LexContext.Create(line);
lc.TrySkipWhiteSpace();
if(lc.TryReadUntil('=',false))
{
var fpath = lc.GetCapture().TrimEnd();
fpath = Path.Combine(dir, fpath);
fpath = Path.GetFullPath(fpath);
if(File.Exists(fpath))
files.Add(fpath);
}
} while (null != (line = sr.ReadLine()) &&
line.Trim().ToLowerInvariant() != "endproject" &&
!line.Trim().ToLowerInvariant().StartsWith_
("endprojectsection"));
}
}
}
}
}
}
}
}
}
return (projects,files);
It's a bit convoluted, but then that's par for the course where parsing is concerned. Fortunately, the lex context makes it much less so than it would otherwise be. Basically, what it's doing is ignoring anything that doesn't begin with "project" and then cracking that line apart with LexContext
until it finds a project path. If at any point the parse doesn't yield expected input, it skips parsing the rest of the line. Each time it finds a project path, it adds it to projects
. For the Solution Folders section, we scan deeper in until we find the list of files, which we then pick up and add to files
.
Once the code has this list of projects and files, it scans each of the projects in turn looking for each project's individual contents. It uses .NET's XML facilities for this, primarily XPath. It gets convoluted because there are two different project formats and they follow different rules, but at least the XML itself is similar in each case. In either case, we only care about what's between the <ItemGroup>
tags:
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.Numerics" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="LexContext.BaseExtensions.cs" />
<Compile Include="LexContext.CommonExtensions.cs" />
<Compile Include="LexContext.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
As you can see, there are several tags that have different local names, but each has an Include
attribute. These are what we look for. We exclude the tags <Reference>
and <ProjectReference>
but other than that, we take everything with an Include
attribute.
For a .NET Core or .NET Standard project, things are slightly different. While the above uses a whitelisting method of including files, the Core/Standard project files use a blacklisting method. That is, they include all files from all directories underneath except bin and obj, or except if they are excluded explicitly. Meanwhile, files under bin and obj can be included if explicitly included. Here's a typical project file for Core or Standard:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Remove="testExclude.txt" />
</ItemGroup>
</Project>
The project file is so small that I just presented the whole thing here. Note that there is only one <ItemGroup>
tag and it has an element with a Remove
attribute. That indicates that the file will be excluded from the project. As mentioned above, the rest of the files from the directory and subdirectories except bin and obj will be included.
I won't include all the code for scanning projects here as it's long because of the different rules, but basically we're just using XPath to find all of the above items of interest within a project file, and then collecting the source paths, which can also mean scanning the project directories for files to include. Here's the meat of what the code does:
iter = nav.Select("/e:Project/e:ItemGroup/*", res);
while (iter.MoveNext())
{
if ("Reference" != iter.Current.LocalName && "ProjectReference" != iter.Current.LocalName)
{
var iter2 = iter.Current.Select("@Include");
if (iter2.MoveNext())
{
var file = iter2.Current.Value;
result.Add(file);
}
}
}
It's mostly variations on the above, or alternatively when we need to scan the filesystem:
var dir = Path.GetDirectoryName(projectFile);
var files = Directory.GetFiles(dir, "*.*");
for (var i = 0; i < files.Length; ++i)
{
var f = Path.Combine(dir, files[i]);
f = Path.GetFullPath(f);
result.Add(f);
}
foreach (string d in Directory.GetDirectories(dir))
{
_DirSearch(dir, d, result);
}
_DirSearch()
above just does a recursive directory search. Note that there's a parameter and some code for exclusing files from the search. This is a holdover from my CSBrick project that required it. I adapted that code to this project since it too scanned project files. I decided to leave the excluded files feature in in case I needed it, since it's complicated and I don't want to have to reintegrate it if I need it in the future.
Finally, once we've gathered all the projects and files, we have to get their paths relative to the solution, and build a zip file with it (the code has been updated in the 1st update but it's not shown here - it's mostly the same):
if (File.Exists(zipPath))
File.Delete(zipPath);
using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
zip.CreateEntryFromFile(sln, Path.Combine
(Path.GetFileNameWithoutExtension(sln),Path.GetFileName(sln)));
stdout.WriteLine(Path.GetFileName(sln));
var ss = _GetSolutionStuff(sln);
foreach(var fpath in ss.Files)
{
var relPath = Path.Combine(Path.GetFileNameWithoutExtension(sln),
_GetRelativePath(fpath, dir, true));
stderr.Write("\t");
stdout.WriteLine(relPath);
zip.CreateEntryFromFile(fpath, relPath);
}
stderr.WriteLine();
foreach (var projPath in ss.Projects)
{
stdout.WriteLine(_GetRelativePath(projPath, dir, true));
var projRelPath = Path.Combine(Path.GetFileNameWithoutExtension(sln),
_GetRelativePath(projPath, dir, true));
zip.CreateEntryFromFile(projPath, projRelPath);
foreach (var filePath in _GetProjectInputs(projPath, new HashSet<string>()))
{
var relPath = _GetRelativePath(filePath, dir, true);
stderr.Write("\t");
stdout.WriteLine(relPath);
zip.CreateEntryFromFile(filePath,
Path.Combine(Path.GetFileNameWithoutExtension(sln), relPath));
}
stderr.WriteLine();
}
}
stderr.WriteLine();
The _GetRelativePathCode()
which we won't explore here is not mine. It is Copyright (c) 2014, Yves Goergen, http://unclassified.software/source/getrelativepath. I decided to save some time. The copyright notice appears in the source as well. Unfortunately, .NET 4.72 doesn't include the function yet. It's slated for .NET 5 although it's currently in Core and Standard.
One thing you might notice is my writing to stdout
and stderr
instead of using Console
. The reason for this is that this executable is also used like a library by the associated Visual Studio extension. In that environment, there is no console. We pass TextReader.Null
when calling this from inside that project.
Limitations
First of all, the tool will fail if some of your files are not underneath the solution's folder, so don't do that. It won't know where to put them in the zip file because everything is relative to the solution's path.
This should work with C++ projects but I've only tested it with one very small C++ project and I also haven't tested it with F# projects. YMMV. I'll test for more support for C++ soon.
It could stand more testing in general I suppose, just because of the number of different kinds of projects you can make so if you run into problems with it, please leave a comment. Just remember to include your readmes and license docs and images and other content into your project - anything you want to be in the zip. Otherwise, they won't get included.
Points of Interest
I originally was going to write entirely separate code to gather files from inside Visual Studio, but when I tried it, I found that strangely enough, it wasn't giving me accurate results. For one thing, it was in some cases returning directories instead of files, and it was missing things like files included under the Resources folder. What I initially thought would be more robust turned out to be a dud. That's really too bad, but oh well. At least this way, I don't need two different sets of code to accomplish the same thing.
History
- 7th July, 2020 - Initial submission
- 8th July, 2020 - Fixed bug introduced by timing issue where the shell wasn't selecting the zip file.
- 9th July, 2020 - Fixed bug where using links could add the same file twice