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
The queries started out purely syntactic. Since version 3.0.265, the attributes of the semantic namespace expose the semantic model as well, so a query can also select a construct by the type or by the symbol it binds to. Since version 3.0.268, the elements of the operation namespace expose the operation tree of the compiler, so a query can be written on what the code means instead of on how it is written.
#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.
This is the whole document as long as a query only asks about the syntax. A query that asks about the semantic model gets a second set of attributes on the same elements, and a query on the operation tree is evaluated on a different document altogether. Both are the subject of a later section.
#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.
| Query | Reported syntax |
|---|
//ClassDeclaration/ParameterList | The parameter list of the primary constructors of classes |
//ClassDeclaration[ParameterList] | The classes that declare a primary constructor |
//ConditionalExpression//ConditionalExpression | The 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]/@Identifier | The name of the methods with more than 5 parameters |
//*[count(ParameterList/Parameter) > 5]/@Identifier | The 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.
#Querying the semantic model
A syntax tree only knows what is written. //InvocationExpression[@Expression='Console.WriteLine'] matches the text Console.WriteLine: it misses System.Console.WriteLine(…), it misses a WriteLine(…) that follows a using static System.Console;, and it happily matches a call to your own class named Console. Anything that depends on what a name binds to is out of reach of the syntax tree.
So the rule also exposes the semantic model, as a second set of attributes in the semantic namespace:
//AddExpression/*[@semantic:TypeMetadataName='System.Nullable`1']; Do not add nullable values
//InvocationExpression[@semantic:SymbolDocumentationId='M:System.Console.WriteLine(System.String)']; Use the logger
The prefix is always semantic. It is a separate namespace so these attributes cannot clash with the token attributes, whose names come from the properties of the syntax nodes and are therefore not the rule's to choose.
The 42 of object boxed = 42; is therefore not only a NumericLiteralExpression with a Token attribute:
XML
<NumericLiteralExpression Token="42"
semantic:TypeMetadataName="System.Int32"
semantic:TypeIsValueType="true"
semantic:TypeSpecialType="System_Int32"
semantic:ConvertedTypeMetadataName="System.Object"
semantic:ConvertedTypeIsValueType="false"
semantic:ConvertedTypeSpecialType="System_Object"
semantic:HasConstantValue="true"
semantic:ConstantValue="42" />
The type of the node is System.Int32 and the type after the implicit conversion is System.Object, which is the definition of boxing. So banning boxing everywhere is one line:
//*[@semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object']; Do not box values
##Types
Four prefixes expose a type:
| Prefix | The type it exposes |
|---|
semantic:Type… | The type of the node |
semantic:ConvertedType… | The type of the node after the implicit conversion, such as System.Object for a boxed value |
semantic:ReturnType… | The return type of the method the node refers to or declares. It is System.Void for a method returning void |
semantic:ContainingType… | The type that contains the symbol the node refers to or declares, such as System.Console |
Each of them comes with the same seven suffixes, so semantic:Type… stands for semantic:TypeName, semantic:TypeMetadataName, semantic:TypeDocumentationId, semantic:TypeReferenceId, semantic:TypeIsValueType, semantic:TypeNullableAnnotation, and semantic:TypeSpecialType:
| Suffix | Value |
|---|
…Name | The name of the type alone, without its namespace, its containing types, and its type arguments, such as List for List<int> or Int32 for int. It is not present for the types that have no name, such as an array or a pointer |
…MetadataName | The metadata name of the type |
…DocumentationId | The documentation comment id of the type |
…ReferenceId | The reference id of the type |
…IsValueType | true for a value type, false for a reference type |
…NullableAnnotation | Annotated when the type can be null, such as string? or int?, and NotAnnotated otherwise. It is the annotation of the flow state of an expression, so it is only present in a file where the nullable context is enabled, and not on the nodes that are a type, such as the string? of a declaration |
…SpecialType | The name of the member of SpecialType, such as System_Int32, System_String, or System_Void. It is not present for the types that are not special. The name uses _ instead of ., as it is the name of the member of the enumeration |
##Symbols and constants
The other attributes are about the symbol itself and about the constants:
| Attribute | Value |
|---|
semantic:Symbol | The symbol the node refers to or declares, qualified by the metadata name of its containing type, such as System.Console.WriteLine. The parameters are not part of the name, so it selects all the overloads |
semantic:SymbolName | The name of the symbol alone, without its containing type, such as WriteLine. It selects the members of this name whatever the type that declares them |
semantic:SymbolDocumentationId | The documentation comment id of the symbol, such as M:System.Console.WriteLine(System.String), which selects a single overload |
semantic:SymbolKind | The kind of the symbol, such as Method, Field, Property, NamedType, or Local |
semantic:ContainingSymbol | The symbol that contains the symbol, in the format of semantic:Symbol. It is the containing type of a member, the method of a local or of a parameter, and the containing namespace of a type that is not nested |
semantic:ContainingSymbolName | The name of the containing symbol alone, such as Execute for a local declared in Execute |
semantic:ContainingSymbolDocumentationId | The documentation comment id of the containing symbol, such as M:Sample.Execute for a local declared in Execute |
semantic:ContainingSymbolKind | The kind of the containing symbol, such as NamedType, Method, or Namespace |
semantic:DeclaredAccessibility | The declared accessibility of the symbol: Private, ProtectedAndInternal for private protected, Protected, Internal, ProtectedOrInternal for protected internal, or Public. It is not present for the symbols that have no accessibility, such as a local or a parameter |
semantic:IsStatic | true when the symbol is static, false otherwise |
semantic:HasConstantValue | true when the node is a constant, whatever its value |
semantic:ConstantValue | The value of the constant, formatted with the invariant culture. true and false for a boolean |
Combining them gives the kind of query that would each need a dedicated analyzer:
| Query | Reported syntax |
|---|
//*[@semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object'] | The boxed values |
//MethodDeclaration[@semantic:DeclaredAccessibility='Public' and @semantic:IsStatic='true'] | The public static methods |
//EqualsExpression/*[@semantic:TypeSpecialType='System_String'] | The operands of the comparisons of strings with == |
//IdentifierName[@semantic:TypeNullableAnnotation='Annotated'] | The references to a value that can be null |
//IdentifierName[@semantic:ContainingSymbolDocumentationId='M:Sample.Execute'] | The names that refer to a local, to a parameter or to a local function of the method Sample.Execute |
##The three names of a type
Every type is named in the three formats Roslyn produces, and none of them is a name invented by the rule:
- The metadata name is the one
Compilation.GetTypeByMetadataName expects. It has the arity suffix instead of the type arguments, and a + before the name of a nested type, such as System.Collections.Generic.Dictionary`2+Enumerator. - The documentation comment id is the one the compiler writes in the XML documentation file, and the one the
BannedSymbols.txt files use, such as T:System.Collections.Generic.List`1 or M:System.String.Substring(System.Int32). The return type is only part of the id of a conversion operator, such as M:Sample.op_Implicit(Sample)~System.Int32. Only the symbols that can be documented have one, so a local, a parameter, an alias, a lambda, and a local function have no semantic:SymbolDocumentationId. - The reference id is the form a
cref uses, and the only one that carries the type arguments: System.Collections.Generic.List{System.String}. It has no T: prefix. A tuple is a System.ValueTuple{System.Int32,System.String}, without the names of its elements, and dynamic is System.Object.
The metadata name and the documentation comment id cannot express the type arguments, so they are always the ones of the definition of the type. They are the same for List<int> and for List<string>, which makes them the way to select a generic type whatever it is constructed with. Use the reference id to select a specific instantiation:
| Query | Reported syntax |
|---|
//*[@semantic:TypeMetadataName='System.Collections.Generic.List`1'] | Anything of type List<T>, whatever the type argument |
//*[@semantic:TypeReferenceId='System.Collections.Generic.List{System.Int32}'] | Anything of type List<int> only |
##Declared types are nodes, not attributes
The semantic:ReturnType attributes are the only ones about a declared type, and they are only present when the symbol is a method. The type of a property, of a field, of a parameter, or of a local is not an attribute of the declaration: the type is a node of its own, so it carries the semantic attributes like any other node. In a method declaration, the return type is the only child that has a type, as the types of the parameters and of the constraints are nested deeper, so /* selects it.
| Query | Reported syntax |
|---|
//MethodDeclaration/*[@semantic:TypeMetadataName='System.Threading.Tasks.Task`1'] | The return type of the methods returning a Task<T>, which //MethodDeclaration[@semantic:ReturnTypeMetadataName='System.Threading.Tasks.Task`1'] reports on the whole method instead |
//Parameter/*[@semantic:TypeMetadataName='System.Collections.Generic.List`1'] | The type of the parameters of type List<T> |
//PropertyDeclaration/*[@semantic:TypeMetadataName='System.String'] | The type of the properties of type string |
//FieldDeclaration/VariableDeclaration/*[@semantic:TypeMetadataName='System.String'] | The type of the fields of type string |
##Present, absent, and false
An attribute is not present when there is no value for it, so [@semantic:TypeMetadataName] selects the nodes that have a type, and [not(@semantic:TypeMetadataName)] the other ones. The boolean attributes are present even when they are false, so [@semantic:TypeIsValueType='false'] selects the nodes whose type is a reference type, whereas [not(@semantic:TypeIsValueType)] selects the nodes that have no type at all. HasConstantValue and ConstantValue are separate for the same reason: [@semantic:HasConstantValue and not(@semantic:ConstantValue)] selects the constants whose value is null.
A misspelled attribute would silently match nothing, which is the worst possible outcome for a rule whose job is to report things. So a name of the semantic namespace that does not exist is reported by MA0241, like any other invalid entry.
An entry that uses the semantic prefix needs the semantic model, so it costs more than a purely syntactic query. The entries that do not use it are still evaluated without it, so mixing the two in the same file is fine.
#Querying the operation tree
The semantic attributes answer what does this name bind to. They do not answer what does this code do, and the two are not the same. The count argument of string.Format(CultureInfo.InvariantCulture, "{0} items", count) is boxed, but nothing in the syntax tree says so: there is no cast, no conversion, not even a token. The boxing exists only because the compiler inserts a conversion to object, and the query that catches it in the previous section, @semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object', reconstructs that conversion from two attributes of the node that gets converted.
The compiler already has the answer. Alongside the syntax tree and the semantic model, it builds an operation tree, which is the meaning of the code: an implicit conversion is a node, the method an invocation really calls is a property, a for, a foreach, and a while are all a loop with a kind, and a compile-time constant carries its value. Since version 3.0.268, the elements of the operation namespace expose it:
//operation:Conversion[@IsImplicit='true' and @TypeMetadataName='System.Object']; Do not convert to object implicitly
//operation:Invocation[@TargetMethodDocumentationId='M:System.Console.WriteLine(System.String)']; Use the logger
//operation:Loop//operation:Invocation[@TargetMethodName='Query']; Do not query in a loop
The last one is the kind of query the syntax tree cannot express at all. "A call in a loop" is not a shape of the source code: the call can be nested in an if, in a using, in a lambda, or in a local function, and the loop can be a for, a foreach, a while, or a do. In the operation tree, it is a descendant of a Loop, whatever the code looks like.
The mapping follows the same two rules as the syntax tree, with the elements and the attributes swapping roles slightly:
- Each operation is an element of the
operation namespace named after its OperationKind, such as operation:Invocation, operation:Binary, or operation:Conversion. The children of the element are the child operations. A few kinds have an obsolete alias, such as BinaryOperator for Binary. The name of the element is always the current one, and the alias is reported by MA0241. - Each property of the operation is an attribute of its element, named after the property and not prefixed, such as
@IsImplicit or @TargetMethod. The kind is not an attribute, as it is the name of the element, and the properties that return the child operations are not attributes either, as they are the child elements.
Two structural differences with the syntax tree are worth knowing before writing a query.
The operations of a file form a forest, not a single tree. The body of each member, the initializer of each field, of each property, and of each parameter, and each attribute is a root of its own. There is no CompilationUnit to anchor a query on, so // is the only sensible way to start one. And only the executable code has operations: a class declaration, a parameter list, or a type constraint exists in the syntax tree only.
An entry uses the syntax tree or the operations, never both. A query that uses the operation prefix is evaluated on the operations, and the semantic attributes are not available in it, as the operations expose their own equivalents. To ban the two forms of a construct, write two entries.
The diagnostic uses the qualified name of the element, so banning a conversion reports The syntax 'operation:Conversion' is banned.
##The attributes of an operation
The attributes are named after the properties of the operation interfaces, so IInvocationOperation.TargetMethod is @TargetMethod. A property that returns a type or a symbol is expanded into the same set of names as in the semantic namespace, minus the prefix:
| The property returns | The attributes of a property named P |
|---|
A type, such as Type or TypeOperand | @PName, @PMetadataName, @PDocumentationId, @PReferenceId, @PIsValueType, @PNullableAnnotation, and @PSpecialType, with the values described in the previous section |
Another symbol, such as TargetMethod, Local, or Parameter | @P is the name qualified by the metadata name of its containing type, and @PName, @PDocumentationId, @PKind, and @PIsStatic are the name alone, the documentation comment id, the kind of the symbol, and whether it is static |
Several symbols, such as Locals or InitializedFields | @P and @PName, the qualified names and the names alone, separated by a space |
A boolean, such as IsImplicit, IsVirtual, or IsChecked | @P, which is true or false |
An enumeration, such as OperatorKind or LoopKind | @P, the name of the member, such as Add or While |
| A string or a number | @P |
ConstantValue | @HasConstantValue and @ConstantValue, like the semantic attributes of the same name |
| Query | Reported operations |
|---|
//operation:Invocation[@TargetMethodName='Parse'] | The calls to a method named Parse, whatever the type that declares it |
//operation:Invocation[@IsVirtual='true'] | The virtual calls |
//operation:Binary[@OperatorKind='Equals']/*[@TypeSpecialType='System_String'] | The operands of the comparisons of strings with == |
//operation:Loop[@LoopKind='ForEach'] | The foreach loops |
//operation:Literal[@HasConstantValue='true' and @ConstantValue='0'] | The literals 0 |
//operation:PropertyReference[@PropertyDocumentationId='P:System.DateTime.Now'] | The uses of DateTime.Now |
The attributes an operation has depend on its kind, so @TargetMethod only exists on an invocation. An attribute that does not exist selects nothing instead of being reported, exactly like an attribute of a syntax node.
The compiler also inserts operations that have no syntax of their own: the conversion of an argument, the receiver of a call to an instance member of the same type, or the wrapper around each argument. Their @IsImplicit is true, and they are reported on the syntax of the construct that contains them, so [@IsImplicit='false'] selects the operations the code writes explicitly. This is why the conversion query above needs @IsImplicit='true': an explicit (object)value is a conversion too, and a team that bans the implicit form usually wants the explicit one to remain the way to say "I meant this".
That query is still not exactly the boxing query, as object o = "text"; is an implicit conversion to object without any boxing. What makes it a boxing is the type of the operand, which is a child operation and therefore a child element:
//operation:Conversion[@IsImplicit='true' and @TypeMetadataName='System.Object']/*[@TypeIsValueType='true']; Do not box values
This is the same condition as the semantic version earlier in the post, @semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object', with the conversion as a node of its own instead of as an attribute of the node being converted. The two select the same code, and the operation one reports on the operand, so the diagnostic reads The syntax 'operation:LocalReference' is banned for a local.
Finally, the text of an element is the source code of its operation, so //operation:Invocation[contains(., '@"')] selects the calls whose code contains a verbatim string. The same is true of the elements of the syntax tree.
##Coming back to the syntax with syntax()
The operation tree drops what the compiler no longer needs. A string is a value: whether it was written as a verbatim string, as an interpolated string, or as a plain literal is not in the operation tree, because it does not change what the code means. Sometimes that is exactly what you want to ban.
The syntax function bridges the two documents. It returns the syntax nodes of the operations it is given, so a query can start on the operations and finish on the syntax:
syntax(//operation:Invocation[@TargetMethodName='Write'])//InterpolatedStringExpression; Do not interpolate the messages
//operation:Invocation[syntax(.)//InterpolatedStringExpression]; Do not interpolate the arguments
The two lines select the same calls and report different things. The first ends on the syntax tree, so the diagnostic is on the interpolated string and its kind is InterpolatedStringExpression. The second uses the syntax only as a predicate and ends on the operation, so the diagnostic is on the whole call and its kind is operation:Invocation. Where the query ends is what gets reported.
| Query | Reported |
|---|
syntax(//operation:Invocation) | The syntax nodes of the invocations, reported as InvocationExpression |
syntax(//operation:Invocation)//StringLiteralExpression/@Token[starts-with(., '@')] | The verbatim strings used in a call |
//operation:Invocation[syntax(.)//InterpolatedStringExpression] | The invocations whose syntax contains an interpolated string, reported as operation:Invocation |
A few rules come with it:
- A function call cannot be a step of a path, so
//operation:Invocation/syntax() is not a valid XPath query. The function takes the operations as its argument, and the path continues after the call. syntax(.) in a predicate is the node of the operation the predicate is evaluated on.- Several operations can share a syntax node, such as an expression and the implicit conversion that wraps it. The node is returned only once.
- The nodes have their token attributes, but not the
semantic attributes, as those are not available in a query on the operations. The operations expose the same data with their own attributes, such as @TypeMetadataName. - The two documents are not merged, so the result of a union such as
//operation:Invocation | syntax(//operation:Binary) is not in a defined order. Use two entries instead.
##Syntax, semantic, or operations?
The three levels overlap, and the cheapest one that answers the question is the right one:
- A syntax kind, such as
GotoStatement, when the construct is a shape of the source code. It is the fastest, as all the kinds are found in a single pass. - An XPath query on the syntax tree when the shape needs a structure, a count, or an ancestor, and the names in the code are enough.
- The
semantic attributes when the query depends on what a name binds to, and the construct is still one node of the source code. - The operations when the construct is not in the source code at all, such as an implicit conversion, or when it is a relation between statements, such as a call inside a loop, whatever the shape the code takes to express it.
A query using the operation prefix needs the semantic model, so it costs the same as a query using the semantic attributes. A project whose files use neither prefix never requests a semantic model at all.
#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
# The attributes of the 'semantic' namespace expose the semantic model.
//InvocationExpression[@semantic:Symbol='System.Console.WriteLine']; Use the logger instead of the console
//*[@semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object']; Do not box values
//MethodDeclaration/*[@semantic:TypeMetadataName='System.Collections.Generic.List`1']; Return an IReadOnlyList<T> instead of a List<T>
# The elements of the 'operation' namespace expose the operation tree.
//operation:PropertyReference[@PropertyDocumentationId='P:System.DateTime.Now']; Use a clock abstraction instead of DateTime.Now
//operation:Loop//operation:Invocation[@TargetMethodName='Load']; Do not call Load in a loop
syntax(//operation:Invocation[@TargetMethodName='Write'])//InterpolatedStringExpression; Do not interpolate the arguments of the logger
with this file:
C#
using System.Globalization;
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 List<string> GetTags() => []; // ❌ GenericName: Return an IReadOnlyList<T> instead of a List<T>
public static string Format(int count)
=> string.Format(CultureInfo.InvariantCulture, "{0} items", count); // ❌ IdentifierName: Do not box values
public static string Stamp()
=> DateTime.Now.ToString("o", CultureInfo.InvariantCulture); // ❌ operation:PropertyReference: Use a clock abstraction instead of DateTime.Now
public static void Preload(string[] names)
{
foreach (var name in names)
Load(name); // ❌ operation:Invocation: Do not call Load in a loop
}
private static void Load(string name)
{
}
public static void Trace(string user)
=> Console.Write($"user {user}"); // ❌ InterpolatedStringExpression: Do not interpolate the arguments of the logger
public static string Find(string[] values, string needle)
{
foreach (var value in values)
{
if (value.Equals(needle, StringComparison.Ordinal))
goto found; // ❌ GotoStatement: Use structured control flow instead
}
return "not found";
found:
return "found";
}
}
internal static class Program
{
private static void Main() => Console.WriteLine(Report.Describe(1)); // ❌ InvocationExpression: Use the logger instead of the console
}
Building the project reports the 11 violations:
Program.cs(5,29): warning MA0240: The syntax 'ParameterList' is banned: Do not use primary constructors on classes
Program.cs(10,9): warning MA0240: The syntax 'ReturnStatement' is banned: Do not return from a constructor
Program.cs(16,37): warning MA0240: The syntax 'ConditionalExpression' is banned: Do not nest conditional expressions
Program.cs(18,24): warning MA0240: The syntax 'MethodDeclaration/@Identifier' is banned: Use a parameter object instead of more than 5 parameters
Program.cs(22,19): warning MA0240: The syntax 'GenericName' is banned: Return an IReadOnlyList<T> instead of a List<T>
Program.cs(25,69): warning MA0240: The syntax 'IdentifierName' is banned: Do not box values
Program.cs(28,12): warning MA0240: The syntax 'operation:PropertyReference' is banned: Use a clock abstraction instead of DateTime.Now
Program.cs(33,13): warning MA0240: The syntax 'operation:Invocation' is banned: Do not call Load in a loop
Program.cs(41,26): warning MA0240: The syntax 'InterpolatedStringExpression' is banned: Do not interpolate the arguments of the logger
Program.cs(48,17): warning MA0240: The syntax 'GotoStatement' is banned: Use structured control flow instead
Program.cs(60,35): warning MA0240: The syntax 'InvocationExpression' is banned: Use the logger instead of the console
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.
Each diagnostic is reported on the node the query selected, not on the node the message is about. In the semantic entries, GenericName is the List<string> of the return type, IdentifierName is the count argument that gets boxed, and InvocationExpression is the whole Console.WriteLine(...) call.
The three operation entries show the three shapes. operation:PropertyReference is reported on DateTime.Now, which is a property reference in the operation tree and a SimpleMemberAccessExpression in the syntax tree. operation:Invocation is the Load(name) call, selected because it is a descendant of a Loop and not because of anything in its own text. And the syntax() entry is reported as InterpolatedStringExpression, since the query starts on the operations to find the calls to Write and ends on the syntax tree to find the interpolation.
#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 unknown attribute of the semantic namespace, 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
The semantic attributes need names instead of kinds. The documentation comment ids are the ones the compiler writes in the XML documentation file, so setting <GenerateDocumentationFile>true</GenerateDocumentationFile> and opening the generated file is the quickest way to get the exact id of a symbol of your own code.
The operation elements need the names of OperationKind, which is a much shorter list than SyntaxKind, and the attributes are the properties of the matching operation interface, such as IInvocationOperation for operation:Invocation. The Syntax Visualizer of Visual Studio shows the operation of the selected node, which is the quickest way to see the shape of the operation tree for a given snippet.
#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. Adding the semantic model was a matter of exposing more attributes, and adding the operation tree was a matter of writing a second XPathNavigator over it: ancestor::, count(), and and keep working on both without a single change to the query engine. Even syntax(), which walks from one document to the other, is an ordinary XPath extension function.
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!