Querying Roslyn syntax trees with XPath

 
 
  • Gérald Barré

In XPath for custom types in .NET, I showed that XPathNavigator is not tied to XML. Any tree can be exposed as a node-set, and the XPath engine of System.Xml.XPath does the rest.

That post used a folder hierarchy as an example. Here is a much more interesting tree: the C# syntax tree produced by Roslyn.

The occasion for it was an issue on Meziantou.Analyzer: Rule to forbid primary constructors? (Opposite of IDE0290). The request was reasonable, and writing a dedicated rule for it would have taken a few dozen lines. But that kind of request never comes alone. Someone else wants to ban goto, or lock, or nested conditional expressions, or methods with too many parameters. Each one is a new rule, a new diagnostic ID, a new documentation page, and a new release, for a check that only one team will ever enable.

So I implemented a generic rule instead of a specific one. MA0240 exposes each file of a project as an XML document and reports the nodes selected by the XPath queries you list in a BannedSyntaxes.txt file. This lets a project ban a language construct and explain what to use instead, without writing a dedicated analyzer. Banning primary constructors on classes, the feature that was originally asked for, becomes one line:

//ClassDeclaration/ParameterList; Do not use primary constructors

#A C# syntax tree as an XML document

The mapping is small enough to fit in two rules:

  • Each syntax node is an element named after its kind, such as ClassDeclaration or ParameterList. The children of the element are the child nodes of the syntax node, in source order.
  • Each token of a node is an attribute of its element, named after the property of the node that returns it, such as Identifier, Keyword, or Modifiers. The value of the attribute is the text of the token. When the property returns several tokens, such as Modifiers, the value is the text of the tokens separated by a space. Missing tokens are not exposed, so //ClassDeclaration[not(@Modifiers)] selects the classes that declare no modifier.

So this file:

C#
class Sample(int value)
{
    void Test() { }
}

is seen by an XPath query as:

XML
<CompilationUnit EndOfFileToken="">
  <ClassDeclaration Keyword="class" Identifier="Sample" OpenBraceToken="{" CloseBraceToken="}">
    <ParameterList OpenParenToken="(" CloseParenToken=")">
      <Parameter Identifier="value">
        <PredefinedType Keyword="int" />
      </Parameter>
    </ParameterList>
    <MethodDeclaration Identifier="Test">
      <PredefinedType Keyword="void" />
      <ParameterList OpenParenToken="(" CloseParenToken=")" />
      <Block OpenBraceToken="{" CloseBraceToken="}" />
    </MethodDeclaration>
  </ClassDeclaration>
</CompilationUnit>

Two details are worth noting. The document element is the CompilationUnit, so a query can be anchored with /CompilationUnit. And punctuation is exposed too, because OpenBraceToken and CloseBraceToken are properties that return tokens, exactly like Identifier.

The element names are the members of the Microsoft.CodeAnalysis.CSharp.SyntaxKind enumeration, and they are case-sensitive. Only the kinds of syntax nodes are exposed, not the kinds of tokens or trivia.

#The BannedSyntaxes.txt file

The rule is enabled by default, but it does nothing until a project has a banned syntax file, so it costs nothing to the projects that do not use it.

Create a BannedSyntaxes.txt file in the folder of the project, or in one of its parent folders, such as the root of the repository to share it with all the projects. The Meziantou.Analyzer package adds the closest BannedSyntaxes.txt file to the additional files of the project, the same way MSBuild finds the closest Directory.Build.props file.

Each line contains a query, optionally followed by ; and the message to report:

# Lines starting with '#' are comments
GotoStatement; Use structured control flow instead
LockStatement
//ClassDeclaration/ParameterList; Do not use primary constructors
//ConditionalExpression//ConditionalExpression; Do not nest conditional expressions
//*[count(ParameterList/Parameter) > 5]/@Identifier; Use a parameter object instead of more than 5 parameters

The query and the message are separated by the first ; that is not in a string literal of the query. Empty lines and the lines starting with # are ignored. The message is optional: the diagnostic is The syntax '<kind>' is banned: <message>, or The syntax '<kind>' is banned without a message.

##Syntax kinds

A query made of a single name, optionally preceded by //, is the name of a member of SyntaxKind, such as GotoStatement. The rule reports all the nodes of this kind.

These queries are faster than the other XPath queries, as all the kinds are found in a single pass over the syntax tree. So prefer GotoStatement over //GotoStatement when you just want to ban a construct everywhere.

##XPath queries

The other queries are XPath 1.0 queries. The rule reports each element or attribute selected by the query. When the query selects an attribute, the diagnostic is reported on the tokens of the attribute, which is handy to report on the name of a method instead of on its whole body.

QueryReported syntax
//ClassDeclaration/ParameterListThe parameter list of the primary constructors of classes
//ClassDeclaration[ParameterList]The classes that declare a primary constructor
//ConditionalExpression//ConditionalExpressionThe conditional expressions nested in another conditional expression
//MethodDeclaration[@Identifier='Execute']The methods named Execute
//MethodDeclaration/@Modifiers[contains(., 'async')]The modifiers of the async methods
//ReturnStatement[ancestor::ConstructorDeclaration]The return statements in constructors
//MethodDeclaration[count(ParameterList/Parameter) > 5]/@IdentifierThe name of the methods with more than 5 parameters
//*[count(ParameterList/Parameter) > 5]/@IdentifierThe name of the methods, constructors, local functions, delegates, and types with a primary constructor that have more than 5 parameters

The union operator selects several constructs with the same message:

//ClassDeclaration/ParameterList | //StructDeclaration/ParameterList; Do not use primary constructors

This is where exposing the tree as XML pays off. ancestor::, count(), contains(), predicates, and unions are all free: they come from the XPath engine of the BCL, not from the analyzer. Writing the equivalent of //MethodDeclaration[count(ParameterList/Parameter) > 5]/@Identifier as a dedicated analyzer means a new project, a NuGet package, and a release cycle. Here it is one line in a text file.

Records use ParameterList for their positional parameters too, so //RecordDeclaration/ParameterList selects the positional parameters of records.

#A complete example

The sample project uses this BannedSyntaxes.txt file:

# Lines starting with '#' are comments.
# A single name is a member of Microsoft.CodeAnalysis.CSharp.SyntaxKind.
GotoStatement; Use structured control flow instead

# The other queries are XPath 1.0 queries evaluated on the syntax tree of each file.
//ClassDeclaration/ParameterList; Do not use primary constructors on classes
//ConditionalExpression//ConditionalExpression; Do not nest conditional expressions
//MethodDeclaration[count(ParameterList/Parameter) > 5]/@Identifier; Use a parameter object instead of more than 5 parameters
//ReturnStatement[ancestor::ConstructorDeclaration]; Do not return from a constructor

with this file:

C#
namespace Sample;

internal sealed class Report(string title)  // ❌ ParameterList: Do not use primary constructors on classes
{
    public Report()
        : this("untitled")
    {
        return;                             // ❌ ReturnStatement: Do not return from a constructor
    }

    public string Title { get; } = title;

    public static string Describe(int value)
        => value < 0 ? "negative" : value == 0 ? "zero" : "positive"; // ❌ ConditionalExpression: Do not nest conditional expressions

    // ❌ MethodDeclaration/@Identifier: Use a parameter object instead of more than 5 parameters
    public static void Configure(string host, int port, bool secure, int timeout, int retries, string userAgent)
    {
    }

    public static void Find(string[] values, string needle)
    {
        foreach (var value in values)
        {
            if (value == needle)
                goto found;                 // ❌ GotoStatement: Use structured control flow instead
        }

        Console.WriteLine("not found");
        return;

    found:
        Console.WriteLine("found");
    }
}

Building the project reports the 5 violations:

Program.cs(3,29): warning MA0240: The syntax 'ParameterList' is banned: Do not use primary constructors on classes
Program.cs(8,9): warning MA0240: The syntax 'ReturnStatement' is banned: Do not return from a constructor
Program.cs(14,37): warning MA0240: The syntax 'ConditionalExpression' is banned: Do not nest conditional expressions
Program.cs(16,24): warning MA0240: The syntax 'MethodDeclaration/@Identifier' is banned: Use a parameter object instead of more than 5 parameters
Program.cs(25,17): warning MA0240: The syntax 'GotoStatement' is banned: Use structured control flow instead

Note the column of the fourth diagnostic: the query selects an attribute, so the diagnostic is reported on the name of the method, not on the whole declaration.

#Configuration

The package reads the MeziantouIncludeBannedSyntaxesFile property before the project file is evaluated, so setting it in the project file has no effect. Set it in a Directory.Build.props file to stop the package from adding the file:

XML
<Project>
  <PropertyGroup>
    <MeziantouIncludeBannedSyntaxesFile>false</MeziantouIncludeBannedSyntaxesFile>
  </PropertyGroup>
</Project>

The rule also reads the additional files named BannedSyntaxes.txt or BannedSyntaxes.*.txt, such as BannedSyntaxes.Shared.txt, that you add to the project. A file shared by several projects can therefore be combined with a file specific to one project:

XML
<ItemGroup>
  <AdditionalFiles Include="BannedSyntaxes.Project.txt" />
</ItemGroup>

A file added both by the package and by the project is read once.

The lines that are not valid, such as a name that is not a member of SyntaxKind, an invalid XPath query, or a query that does not return a node-set, are reported by MA0241. The other lines of the file are still applied, so a typo does not silently disable the whole file.

#Finding the right kind names

Writing a query means knowing how Roslyn names the construct you want to match. A few tools help:

  • SyntaxKind.cs, the list of all the kinds in the Roslyn source code
  • Roslyn Quoter, which shows the syntax factory calls that create a snippet, including the kind of each node
  • Razor Lab, which shows the syntax tree of a C# snippet
  • SharpLab in the Syntax Tree view
  • The Syntax Visualizer of Visual Studio

#Conclusion

Exposing the C# syntax tree through an XPathNavigator turns "write an analyzer" into "write one line in a text file". The analyzer only provides navigation over the tree; the query language, the axes, and the functions come from System.Xml.XPath.

The same idea applies to any tree you already have in memory. If your users need to describe a subset of that tree, implementing XPathNavigator is often cheaper than designing a query language, and much cheaper than adding a new option every time someone needs a slightly different selection.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?