Lessons learned from building a static code analyzer for C#
Tags: csharp, dotnet, roslyn
Published: 2023-10-11 11:54:11

## Introduction

Static code analyzers are tools used to analyze software code without executing it. They can examine the code to find code smells, vulnerabilities, potential errors, and code out of a defined standard, for example. They work by parsing the source code and evaluating its syntax (structure of the code) and semantic (meaning of the code).

Roslyn, the C# compiler, provides tools for developing Roslyn Analyzers (Static code analyzers for Roslyn), giving access to the syntax and semantic of the code, that can be executed at development and build time, providing feedback in near real-time to the developers.

In this post, I'll show a Roslyn Analyzer I built for using Discriminated Unions in C#, requiring to check the union type before access, and talk about some lessons learned while developing it.

## DiscriminatedUnions.Net package

The package allows the use of Discriminated Unions by enforcing two rules:

1. The type of a Union must be checked before access;
1. All types of a Union must be checked (or have an `else`/`default`/`discard` case).

To use the package, install it in the project:

```bash
<PackageReference Include="DiscriminatedUnions.Net" Version="1.0.0.19" />
```

Extend `UnionValue` when declaring the types that will be used in the Discriminated Union:

```csharp
public class Bird: UnionValue
{
}

public class Dog : UnionValue
{
}
```

Declare the `Union` member passing the possible value types:

```csharp
Union<Dog, Bird> animal = new Dog();
```

And access it checking for the type with `If`, `Switch/Case` or `Pattern Matching`:

```csharp
if (animal.Value is Dog)
{
    Console.WriteLine($"Dog: {animal.Value}");
}
else
...
```

Accessing the object without  checking will yield an error `DUN002 - 'animal.Value' not checked before access`:

![DUN002 - 'animal.Value' not checked before access](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/DUN002_NotCheckedBeforeAccess.png)

Not checking for all possible types will yield an error `DUN001 - 'animal' not being evaluated for all possible types`:

![DUN001 - 'animal' not being evaluated for all possible types](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/DUN001_NotBeingEvaluatedForAllPossibleTypes.png)

> ⚠️ **This package was made for study purposes only**. Feel free to test it, but won't use it for production code as it won't be maintained and may not cover all edge cases.

## DiscriminatedUnions.NET Source Code

https://github.com/dgenezini/DiscriminatedUnions.NET

## Lessons learned

### Development-time only nuget packages

Roslyn analyzers can be distributed as nuget packages, but it is important to generate the package as a Development Dependency only, setting the property `DevelopmentDependency` as `true` on the `csproj` file:

```xml
<DevelopmentDependency>true</DevelopmentDependency>
```

This will generate two tags in the nuget package properties of the consumer projects:

```xml
<PackageReference Include="DiscriminatedUnions.Net.Analyzers" Version="1.0.0.1">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
```

- `PrivateAssets` with the value `all`, indicating that this package won't flow to projects depending on the consumer project. For example, in a scenario where `Project1` depends on the `DiscriminatedUnions.Net.Analyzers` package and `Project2` depends on `Project1`, the analyzer won't be available for `Project2`;
- `IncludeAssets` without the `compile` value in its value, indicating the consumer of the package won't have access to its compiled assemblies.

### Roslyn Analyzers can be used to enforce rules on how to use a package

A great use case for Roslyn Analyzers is to enforce rules on nuget packages.

The DistributedUnion.Net package is one example. It has the `Union` and `UnionValue` types and analyzers to enforce the rules on its use.

Because classes inside Analyzers packages are not accessible by consumers (and shouldn't be), the most correct way, in my opinion, is to have the public classes in one package and this package will consume another package with the analyzers, but removing the `PrivateAssets` configuration. This way, the analyzers will be available for consumers of the parent package:

```xml
<PackageReference Include="DiscriminatedUnions.Net.Analyzers" Version="0.1.0.0-beta">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
```

![Project dependency showing the DiscriminatedUnions.Net and DiscriminatedUnions.Net.Analyzers](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/Dependencies.PNG)

### Analyzers have to evolve together with the language

Because analyzers use the code syntax, as new ways of doing something are added to the language, analyzers have to be updated to treat them also. For example:

- An analyzer checking for conditions before pattern matching was release, would need to be updated to consider this new syntax;
- An analyzer using types for its rules, would need to be updated to consider nullable reference types in its logic.

### TDD is your friend

The Roslyn Analyzers project template comes with a VSIX project that can be run to test the analyzers, but I couldn't get them to work and decided it wasn't worth the time.

Turns out the best and easiest way to run and experiment with the analyzer is by running automated tests. Create the test, run the test and change the analyzer until the test passes.

Example of an automated test that expects the error code `DUN001` at the location `0` with the argument `unionBird`:

```csharp
[TestMethod]
        public async Task Notify_If_NotCheckingAllCases_Many()
        {
            var test = @"
using DiscriminatedUnionsNet;

class Program
{
    static string GetValue()
    {
        Union<Duck, Goose, Eagle, Crow> unionBird = new Duck();

        if ({|#0:unionBird|}.Value is Goose)
        {
            return ""Goose"";
        }

        return null;
    }
}" + defineUnion;

            var expected = VerifyCS
                .Diagnostic("DUN001")
                .WithLocation(0)
                .WithArguments("unionBird");

            await VerifyCS.VerifyAnalyzerAsync(test, expected);
        }
```

The location and argument are marked with the `{|#location:argument|}` syntax.

![Automated tests](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/UnitTests.png)

> ℹ️ The project template comes with some basic tests as examples that are easy to change.

### Casts... Casts everywhere

The analyzers methods and properties define general interface types that need to be cast to the specific type before accessing its properties.

This is an excerpt from the DiscriminatedUnions.Net package:

```csharp
var namedType = (INamedTypeSymbol)typeInfo.Type; //typeInfo.Type is ITypeSymbol

if ((!namedType.Name.Equals("UnionValue")) ||
    (!namedType.ContainingNamespace.Name.Equals("DiscriminatedUnionsNet")))
{
    return;
}

...

if (parent is ISwitchOperation switchOperation) //parent is IOperation
{
    if (!(switchOperation.Value is IPropertyReferenceOperation propertyReferenceOperation) || //switchOperation.Value is IOperation
        !(propertyReferenceOperation.Instance is ILocalReferenceOperation parentReferenceOperation))
    {
        parent = parent.Parent;

        continue;
    }
    ...
}
else if (parent is ISwitchExpressionOperation switchExpressionOperation) //parent is IOperation
{
    ...
}
```

### Understanding the syntax tree and finding the correct interfaces

The easiest way to understand the syntax tree is by using the Visual Studio's Syntax Visualizer ([Installation instructions](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/syntax-visualizer?tabs=csharp#installation-instructions---visual-studio-installer)).

Just click anywhere in the code and it will show the syntax tree up to that point:

![Syntax Visualizer](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/SyntaxVisualizer.PNG)

Another tip is to type `ISymbol`, `IOperation`, or any base interface to see all the specific interfaces in the intellisense:

![Intellisense showing all ISymbol interfaces](https://blog.genezini.com/p/lessons-learned-from-building-a-static-code-analyzer-for-csharp/images/ISymbolSuggestions.png)

### Not many material to help beginners

Because Roslyn Analyzers have very specific use cases, there is not a lot of materials and documentation online. Here are some links that helped me learn (specially Josh Varty's and Meziantou's Blog):

[Josh Varty's Blog - Learn Roslyn Now series](https://joshvarty.com/learn-roslyn-now/)

[Meziantou's Blog - Writing a Roslyn analyzer series](https://www.meziantou.net/writing-a-roslyn-analyzer.htm)

[Roslyn docs - How to start](https://roslyn-analyzers.readthedocs.io/en/latest/how-to-start.html)

[Roslyn GitHub docs - How to write a C# Analyzer and Code Fix](https://github.com/dotnet/roslyn/blob/main/docs/wiki/How-To-Write-a-C%23-Analyzer-and-Code-Fix.md)

[Roslyn GitHub docs - Analyzer Actions Semantics](https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md)

## Related

{% embed https://blog.genezini.com/p/compile-time-null-safety-how-to-avoid-nullreferenceexception-in-c/ %}

## Liked this post?

I post extra content in my personal blog. [Click here](https://blog.genezini.com/) to see.

## Follow me

- [On Feedly](https://feedly.com/i/subscription/feed/https://blog.genezini.com/index.xml)
- [RSS](https://blog.genezini.com/index.xml)
- [Linkedin](https://www.linkedin.com/in/danielgenezini/)
- [Twitter](https://twitter.com/GeneziniDaniel)