Arranging .csproj Files
Arranging .csproj
files makes it a lot easier to merge changes automatically. We were using the merge=union
option with git for our repository and started encountering some issues. After some searching around, I didn't find an alternative that I liked, so I created a new command line tool to arrange .csproj
files and ditched the merge=union
option for a clean filter.
Inspiration
We have been using a tool to keep our C# code organized, NArrange. It hasn't been updated since around 2009, but it is still the most powerful tool I have found for keeping our C# code all to the same standard formatting. I wrote a Visual Studio extension to automatically run NArrange when saving the files (one which I will hopefully release soon on GitHub). It sorts the classes by member type, accessibility, and name. This helps significantly with merging our different branches together. I wanted to have the same ability for my .csproj
files.
Implementation
A .csproj
file is just an XML file. So the first step was to load up the file in an XDocument
class.
// Load the document.
XDocument input;
if (inputFile == null) {
input = XDocument.Load(Console.OpenStandardInput());
} else {
input = XDocument.Load(inputFile);
}
Once it is in an XDocument
, the nodes can be re-arranged. I used the ReplaceNodes
method.
Comparers
One of the features I added was the ability to have some elements stick to the top of the list. The default list of sticky elements is as follows:
Task
PropertyGroup
ItemGroup
Target
Configuration
Platform
ProjectReference
Reference
Compile
Folder
Content
None
When
Otherwise
To make this work, I created a new IComparer<T>
for the XNode
. The Compare
and helper methods are listed below.
public int Compare(XNode x, XNode y)
{
string xName = GetName(x);
string yName = GetName(y);
var xIndex = StickyElementNames.IndexOf(xName);
var yIndex = StickyElementNames.IndexOf(yName);
if ((xIndex == -1) && (yIndex == -1)) {
return string.Compare(xName, yName);
}
if ((yIndex == -1) || ((xIndex != -1) && (xIndex < yIndex))) {
return -1;
}
if ((xIndex == -1) || ((yIndex != -1) && (xIndex > yIndex))) {
return 1;
}
return 0;
}
private string GetName(XNode node)
{
string name = null;
if (node.NodeType == XmlNodeType.Comment) {
name = GetNextClosestElementName(node);
}
if (node.NodeType == XmlNodeType.Element) {
name = ((XElement)node).Name.LocalName;
if (Options.HasFlag(ArrangeOptions.KeepImportWithNext)) {
if (name == "Import") {
// HACK: Need to figure out how to handle import. Just sticking to next element for now.
name = GetNextClosestElementName(node.NextNode);
}
}
}
return name;
}
private string GetNextClosestElementName(XNode node)
{
XElement result;
XNode current = node;
while (((result = current as XElement) == null) && ((current = current.NextNode) != null)) {
// Everything is already done in the condition.
}
if (result == null) {
return null;
}
return result.Name.LocalName;
}
One thing you may have noticed is a hack included for Import
elements. If they are moved to a different location in the file, it can lead to some issues. Sticking them to the nearest element seems to fix the majority of problems. For the files where that doesn't work, I added an option to not sort the root elements at all. The future improvements section discusses some ideas to make the Import
element work without this hack at all.
After sorting the elements by name, they are sorted by certain attributes. By default the only attribute that is sorted is the Include
attribute. This is what makes the files all get sorted. This was implemented using another IComparer<T>
implementation on IEnumerable<XAttribute>
called AttributeKeyComparer
. The Compare
method is listed below.
public int Compare(IEnumerable<XAttribute> x, IEnumerable<XAttribute> y)
{
if (x == null) {
if (y == null) {
return 0;
}
return 1;
}
if (y == null) {
return -1;
}
foreach (var attribute in SortAttributes) {
string xValue = null;
string yValue = null;
var xAttribute = x.FirstOrDefault(a => a.Name.LocalName == attribute);
var yAttribute = y.FirstOrDefault(a => a.Name.LocalName == attribute);
if (xAttribute != null) {
xValue = xAttribute.Value;
}
if (yAttribute != null) {
yValue = yAttribute.Value;
}
int result = string.Compare(xValue ?? string.Empty, yValue ?? string.Empty);
if (result != 0) {
return result;
}
}
return 0;
}
Additional XML Cleanup
In addition to sorting, there is an option to combine root elements if they have the same name and attributes. Then there is an additional option to split the ItemGroup
elements so that each one only contains one type of element.
Setup with Git
The first step is to set up the new clean filter in the .git/config
file.
[filter "csprojarrange"]
clean = CsProjArrange
Once the clean filter is set up, you can have it run for your files by adding it to your .gitattributes
.
*.csproj text filter=csprojarrange
You can set up different filters with different options if you need to have certain files arranged a different way. The clean filter in git will arrange the file before committing.
Future Improvements
After creating the Import
element hack, I came up with what I think is a better idea. I added it as an issue on the GitHub project: keep Import statements stuck to their location. The idea is to split the root elements up into groups between Import
elements. Then do the standard arranging on those sets of root elements.
Getting CsProjArrange
The command line utility is called CsProjArrange and can be found at the GitHub project page. The current release is 1.0.1.