How to integrate StyleCop.Analyzers in a Unity project in 2021

When working on a software project, you usually want your code to be as consistent and well-documented as possible. Sadly, the bigger a project gets the harder it is to keep up with all the changes. I used to fall into a habit of re-checking files many times, over and over again, to search for little style breaks and undocumented code. This is not really the most efficient way to keep your project clean, so instead we will use a linter to do that for us. A linter is basically a piece of software that is run in your integrated development environment and checks all of your code files for documentation, indentation, naming, spacing, etc. You can also edit or add your own code analysis rules for the specific project you're working on.

So, let's get a bit more specific. If you've landed here, you probably work on a Unity project and want to integrate a linter for clean, consistent and fully documented code. In this example, we will be using nuget to add the Stylecop.Analyzers package. Stylecop is a popular linter for C# code and works very well in my current project.

StyleCop.Analyzers running in JetBrains Rider IDEStyleCop.Analyzers running in JetBrains Rider IDE

Why installing StyleCop can be tedious

When adding StyleCop.Analyzers to a C# project, you usually install the nuget package, add some lines of configuration to the project's .csproj files and everything just works from then on. Unity makes life a bit more difficult here, since nuget packages often cannot be imported unchanged into the project. Unity also regenerates the projects' .csproj files after every code change, so adding our configuration there won't work either.

Unity also has a problem with many nuget packages, because they often contain DLL files which have the exact same name, though are in different subdirectories. Unity will not import these DLL files and throw an error when importing assets or compiling.

So, how does it work then?

As a workaround, we will create an asset post processor script, which will update our .csproj files for us and append the necessary configuration each time Unity regenerates the files. This also circumvents the naming problem with DLLs in Unity, since we are going to add them in our configuration and Unity doesn't have to handle them automatically, it will not crash on importing anymore and just load the files. We could also just rename and drag the DLLs into the Assets folder and add only the configuration via post processor, but to keep our packages updatable with nuget we keep them separated from the Unity files.

Creating the nuget.config file

First, let's set up our nuget configuration, so we can download the newest stable StyleCop package version into our project. We want to add our nuget packages outside of Unity's Assets folder, so it does not automatically try to import them and crash. To achieve this we create a file called "nuget.config" and place it in our projects' root folder.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <config>
        <add key="repositoryPath" value="./RoslynAnalyzers" />
    </config>
</configuration>

This will tell nuget to install any packages to the RoslynAnalyzers folder in our project root. We call this folder RoslynAnalyzers because C# code analyzers are working on the .NET Compiler Platform, which is also called "Roslyn". You can also rename this folder, for example if you want to use any different nuget packages and not only analyzers, you could name it "NugetPackages" or any other name you'd like. Just remember to update the name in the nuget.config file and in the asset post processor.

The RoslynAnalyzers folderThe RoslynAnalyzers folder

Handling stylecop.json and .ruleset files

StyleCop.Analyzers comes with preconfigured code analysis rules, for many projects those are already configured to work just fine. If we want to add our own rules or customize already existing ones, we need to add a ruleset file and the stylecop.json configuration. To add the files to our project we can just add them to the RoslynAnalyzers folder, and our asset post processor will automatically reference them for us and apply the ruleset and stylecop.json configuration in the project.

Example .ruleset file:

<?xml version="1.0"?>
<RuleSet Name="Custom StyleCop.Analyzers rules" Description="StyleCop.Analyzers rules to override" ToolsVersion="14.0">
    <Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.SpecialRules">
        <Rule Id="SA0001"  Action="Warning" />          <!-- XML comment analysis disabled -->
        <Rule Id="SA0002"  Action="Warning" />          <!-- Invalid settings file -->
    </Rules>
    <Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.SpacingRules">
        <Rule Id="SA1000"  Action="Warning" />          <!-- Keywords should be spaced correctly -->
        <Rule Id="SA1001"  Action="Warning" />          <!-- Commas should be spaced correctly -->
        <Rule Id="SA1002"  Action="Warning" />          <!-- Semicolons should be spaced correctly -->
        ...
    </Rules>
</RuleSet>

Example stylecop.json file:

{
    "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
    "settings": {
        "documentationRules": {
            "companyName": "Bussmann.io",
            "copyrightText": "Copyright (c) {creatorName}. All rights reserved.",
            "variables": {
                "creatorName": "Frederik Bussmann"
            }
        }
    }
}

Creating the .csproj file asset post processor

Jump to the finished file

Now, let's create our asset post processor. Since we want to edit all .csproj files, we'll name the file CsprojAssetPostProcessor and inherit the AssetPostProcessor class from the UnityEditor namespace:

namespace Bussmann.io
{
    using UnityEditor;

    public class CsprojAssetPostprocessor : AssetPostprocessor
    {
        ...

To hook our post processor to the end of the file generation process, we have to give it a number that determines the order in which the post processors will run. In our case we use 20, this will add our post processor to the very end of the process.

public override int GetPostprocessOrder()
{
    return 20;
}

For the next step, we have to create the OnGeneratedCSProjectFiles function. As the name already tells us, this function will be called when the .csproj files of our project are generated. For this function we will have to load the namespaces System, System.IO, System.Linq and UnityEngine.

public static void OnGeneratedCSProjectFiles()
{
    try
    {
        // First, get all lines of text from all csproj files in the current solution and the current directory
        string[] lines = GetCsprojLinesInSln();
        string currentDirectory = Directory.GetCurrentDirectory();

        // Use a linq expression to get all the .csproj files and store them in a string array
        string[] projectFiles = Directory
            .GetFiles(currentDirectory, "*.csproj")
            .Where(csprojFile => lines.Any(line => line
                .Contains("\"" + Path.GetFileName(csprojFile) + "\""))).ToArray();

        // Iterate our string array and update each project file
        foreach (string file in projectFiles)
        {
            UpdateProjectFile(file);
        }
    }
    catch (Exception error)
    {
        Debug.LogError(error);
    }
}

Now we have to add the other functions this code depends on, at first we will create the GetCsprojLinesInSln method to get all text from the .csproj files in our solution:

private static string[] GetCsprojLinesInSln()
{
    // Get the project directory
    string projectDirectory = Directory.GetParent(Application.dataPath)?.FullName;

    // Get the project name
    string projectName = Path.GetFileName(projectDirectory);

    // Get the path to our solution file
    string slnFile = Path.GetFullPath($"{projectName}.sln");

    // Check if the solution file exists
    if (!File.Exists(slnFile))
    {
        return new string[0];
    }

    // Get all text lines from the solution
    string slnAllText = File.ReadAllText(slnFile);

    // Get all lines that are in .csproj files
    string[] lines = slnAllText
        .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
        .Where(a => a.StartsWith("Project(")).ToArray();

    // Return the retreived .csproj files' text
    return lines;
}

The next function we will create is the UpdateProjectFile function, which is also called from OnGeneratedCSProjectFiles. For this one we will need to add the namespace System.Xml.Linq.

private static void UpdateProjectFile(string projectFile)
{
    // Create xml document
    XDocument document;

    try
    {
        // Try to load the project file as the xml document
        document = XDocument.Load(projectFile);
    }
    catch (Exception)
    {
        Debug.LogError($"Failed to Load {projectFile}");

        return;
    }

    // Get the project element from the .csproj file
    XElement projectContentElement = document.Root;

    if (projectContentElement != null)
    {
        // Get the xml namespace
        XNamespace xmlns = projectContentElement.Name.NamespaceName;

        // Add the roslyn analyzers from the RoslynAnalyzers folder to this .csproj file
        AddRoslynAnalyzers(projectContentElement, xmlns);
    }

    // Save the document file
    document.Save(projectFile);
}

Now that we can load all .csproj files and edit each of them in a loop, we have to create a function that adds all the DLL files and analyzer configuration to it. The following code will grab all the files from the RoslynAnalyzers folder that are relevant and add a reference to them in the given .csproj file. The function needs to have the additional namespace System.Collections.Generic added to the file.

private static void AddRoslynAnalyzers(XContainer projectContentElement, XNamespace xmlns)
{
    // Get the current working directory
    string currentDirectory = Directory.GetCurrentDirectory();

    // Get the RoslynAnalyzers directory
    DirectoryInfo roslynAnalysersBaseDir = new DirectoryInfo(Path.Combine(currentDirectory, "RoslynAnalyzers"));

    // Throw error and return if RoslynAnalyzers directory is not found
    if (!roslynAnalysersBaseDir.Exists)
    {
        Debug.LogError("The nuget package directory could not be found.");

        return;
    }

    // Get the relative files paths from the directory
    IEnumerable<string> relativePaths = roslynAnalysersBaseDir
        .GetFiles("*", SearchOption.AllDirectories)
        .Select(x => x.FullName.Substring(currentDirectory.Length + 1));

    // Create a new ItemGroup element in the .csproj file to add our configuration to
    XElement itemGroup = new XElement(xmlns + "ItemGroup");

    // Iterate all files
    foreach (string file in relativePaths)
    {
        // Handle .dll files
        if (new FileInfo(file).Extension == ".dll")
        {
            XElement reference = new XElement(xmlns + "Analyzer");
            reference.Add(new XAttribute("Include", file));
            itemGroup.Add(reference);
        }

        // Handle .json files (for example stylecop.json configuration)
        if (new FileInfo(file).Extension == ".json")
        {
            XElement reference = new XElement(xmlns + "AdditionalFiles");
            reference.Add(new XAttribute("Include", file));
            itemGroup.Add(reference);
        }

        // Handle .ruleset files
        if (new FileInfo(file).Extension == ".ruleset")
        {
            SetOrUpdateProperty(projectContentElement, xmlns, "CodeAnalysisRuleSet", existing => file);
        }
    }

    // Add the newly generated ItemGroup with the analyzer data to the project content element
    projectContentElement.Add(itemGroup);
}

Now we need to add the SetOrUpdateProperty function, which will search for occurrences of the PropertyGroup XML tag and append our configuration values or create one if it does not exist or skip if they are already added:

private static void SetOrUpdateProperty(XContainer root, XNamespace xmlns, string name, Func<string, string> updater)
{
    // Get first PropertyGroup in the .csproj file or create one
    XElement element = root.Elements(xmlns + "PropertyGroup").Elements(xmlns + name).FirstOrDefault();

    if (element != null)
    {
        // Update the element value
        string result = updater(element.Value);

        // If the result of the updater function matches the already saved value, return
        if (result == element.Value)
        {
            return;
        }

        // Count the subdirectories in the element value
        int currentSubDirectoryCount = Regex.Matches(element.Value, "/").Count;
        int newSubDirectoryCount = Regex.Matches(result, "/").Count;

        // Compare the values from the element
        if (currentSubDirectoryCount != 0 && currentSubDirectoryCount < newSubDirectoryCount)
        {
            return;
        }

        // Set the element value if it is different from the current files' value
        element.SetValue(result);
    }
    else
    {
        // Add the property as new because it does not exist already
        AddProperty(root, xmlns, name, updater(string.Empty));
    }
}

Now for the last step, we will create the AddProperty function to finally add our configuration to the PropertyGroup XML tag of the .csproj files.

private static void AddProperty(XContainer root, XNamespace xmlns, string name, string content)
{
    // Get the property group tag
    XElement propertyGroup = root.Elements(xmlns + "PropertyGroup")
        .FirstOrDefault(element => !element.Attributes(xmlns + "Condition").Any());

    if (propertyGroup == null)
    {
        // Create new property group tag if none found
        propertyGroup = new XElement(xmlns + "PropertyGroup");

        // Add newly created tag to the root element
        root.AddFirst(propertyGroup);
    }

    // Add the new configuration to the property group
    propertyGroup.Add(new XElement(xmlns + name, content));
}

And that's it! We've just finished our CsprojAssetPostProcessor class, and it will now run every time we change code in our Unity project and add our StyleCop packages and configuration. This file should be added to the Editor folder in the Unity Assets directory, I usually put it into the directory "/Assets/Editor/PostProcessing/CsprojAssetPostProcessor.cs".

TLDR;

Too much? no problem, just grab the finished file without extensive documentation and put it into the Editor folder in your Unity Assets directory:

namespace Bussmann.io
{
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text.RegularExpressions;
    using System.Xml.Linq;
    using UnityEditor;
    using UnityEngine;

    /// <summary>
    /// Csproj asset post processor.
    ///
    /// <para>Applies changes after csproj assets are generated.</para>
    /// </summary>
    public class CsprojAssetPostprocessor : AssetPostprocessor
    {
        /// <summary>
        /// Called on csproj files generated.
        /// </summary>
        public static void OnGeneratedCSProjectFiles()
        {
            try
            {
                string[] lines = GetCsprojLinesInSln();
                string currentDirectory = Directory.GetCurrentDirectory();

                string[] projectFiles = Directory
                    .GetFiles(currentDirectory, "*.csproj")
                    .Where(csprojFile => lines.Any(line => line
                        .Contains("\"" + Path.GetFileName(csprojFile) + "\""))).ToArray();

                foreach (string file in projectFiles)
                {
                    UpdateProjectFile(file);
                }
            }
            catch (Exception error)
            {
                Debug.LogError(error);
            }
        }

        /// <summary>
        /// Gets the post process order.
        /// </summary>
        ///
        /// <returns>The post process order.</returns>
        public override int GetPostprocessOrder()
        {
            return 20;
        }

        /// <summary>
        /// Gets the csproj lines in the current solution.
        /// </summary>
        ///
        /// <returns>The csproj text lines.</returns>
        private static string[] GetCsprojLinesInSln()
        {
            string projectDirectory = Directory.GetParent(Application.dataPath)?.FullName;
            string projectName = Path.GetFileName(projectDirectory);
            string slnFile = Path.GetFullPath($"{projectName}.sln");

            if (!File.Exists(slnFile))
            {
                return new string[0];
            }

            string slnAllText = File.ReadAllText(slnFile);

            string[] lines = slnAllText
                .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
                .Where(a => a.StartsWith("Project(")).ToArray();

            return lines;
        }

        /// <summary>
        /// Updates a given project file.
        /// </summary>
        ///
        /// <param name="projectFile">The file to process.</param>
        private static void UpdateProjectFile(string projectFile)
        {
            XDocument document;

            try
            {
                document = XDocument.Load(projectFile);
            }
            catch (Exception)
            {
                Debug.LogError($"Failed to Load {projectFile}");

                return;
            }

            XElement projectContentElement = document.Root;

            if (projectContentElement != null)
            {
                XNamespace xmlns = projectContentElement.Name.NamespaceName;
                AddRoslynAnalyzers(projectContentElement, xmlns);
            }

            document.Save(projectFile);
        }

        /// <summary>
        /// Adds the roslyn analyzers to the csproj.
        /// </summary>
        ///
        /// <param name="projectContentElement">The project content element to append.</param>
        /// <param name="xmlns">The xml namespace.</param>
        private static void AddRoslynAnalyzers(XContainer projectContentElement, XNamespace xmlns)
        {
            string currentDirectory = Directory.GetCurrentDirectory();

            DirectoryInfo roslynAnalysersBaseDir = new DirectoryInfo(Path.Combine(currentDirectory, "RoslynAnalyzers"));

            if (!roslynAnalysersBaseDir.Exists)
            {
                Debug.LogError("The nuget package directory could not be found.");

                return;
            }

            IEnumerable<string> relativePaths = roslynAnalysersBaseDir
                .GetFiles("*", SearchOption.AllDirectories)
                .Select(x => x.FullName.Substring(currentDirectory.Length + 1));

            XElement itemGroup = new XElement(xmlns + "ItemGroup");

            foreach (string file in relativePaths)
            {
                if (new FileInfo(file).Extension == ".dll")
                {
                    XElement reference = new XElement(xmlns + "Analyzer");
                    reference.Add(new XAttribute("Include", file));
                    itemGroup.Add(reference);
                }

                if (new FileInfo(file).Extension == ".json")
                {
                    XElement reference = new XElement(xmlns + "AdditionalFiles");
                    reference.Add(new XAttribute("Include", file));
                    itemGroup.Add(reference);
                }

                if (new FileInfo(file).Extension == ".ruleset")
                {
                    SetOrUpdateProperty(projectContentElement, xmlns, "CodeAnalysisRuleSet", existing => file);
                }
            }

            projectContentElement.Add(itemGroup);
        }

        /// <summary>
        /// Sets or updates a given property in a given <see cref="XElement"/>.
        /// </summary>
        ///
        /// <param name="root">The root element to update.</param>
        /// <param name="xmlns">The xml namespace.</param>
        /// <param name="name">The property name.</param>
        /// <param name="updater">The updater function.</param>
        private static void SetOrUpdateProperty(XContainer root, XNamespace xmlns, string name, Func<string, string> updater)
        {
            XElement element = root.Elements(xmlns + "PropertyGroup").Elements(xmlns + name).FirstOrDefault();

            if (element != null)
            {
                string result = updater(element.Value);

                if (result == element.Value)
                {
                    return;
                }

                int currentSubDirectoryCount = Regex.Matches(element.Value, "/").Count;
                int newSubDirectoryCount = Regex.Matches(result, "/").Count;

                if (currentSubDirectoryCount != 0 && currentSubDirectoryCount < newSubDirectoryCount)
                {
                    return;
                }

                element.SetValue(result);
            }
            else
            {
                AddProperty(root, xmlns, name, updater(string.Empty));
            }
        }

        /// <summary>
        /// Adds a property to the first property group without a condition.
        /// </summary>
        ///
        /// <param name="root">The root element to add to.</param>
        /// <param name="xmlns">The xml namespace.</param>
        /// <param name="name">The property name.</param>
        /// <param name="content">The property content.</param>
        private static void AddProperty(XContainer root, XNamespace xmlns, string name, string content)
        {
            XElement propertyGroup = root.Elements(xmlns + "PropertyGroup")
                .FirstOrDefault(element => !element.Attributes(xmlns + "Condition").Any());

            if (propertyGroup == null)
            {
                propertyGroup = new XElement(xmlns + "PropertyGroup");

                root.AddFirst(propertyGroup);
            }

            propertyGroup.Add(new XElement(xmlns + name, content));
        }
    }
}

Thank you for visiting! If you have any questions about this post, feel free to email us at info(bussmann)io.

Great thanks to Van800, for sharing the awesome code used as a base to build upon for this tutorial.

Frederik BußmannFrederik Bußmann