Joshua Thompson

Engineer · Programmer · Web Enthusiast

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:

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.