Truy vấn cây cú pháp Roslyn bằng XPath

Ngày 20/09/2026 · Gérald Barré

Trong bài viết XPath cho kiểu tùy biến trong .NET, tôi đã trình bày rằng XPathNavigator không bị giới hạn trong XML. Bất kỳ cây nào cũng có thể được biểu diễn thành một tập nút, sau đó bộ máy XPath của System.Xml.XPath sẽ đảm nhiệm phần còn lại.

Bài viết trước dùng cấu trúc thư mục làm ví dụ. Lần này, chúng ta sẽ làm việc với một cây thú vị hơn nhiều: cây cú pháp C# do Roslyn tạo ra.

Ý tưởng này bắt nguồn từ một yêu cầu dành cho Meziantou.Analyzer: cấm primary constructor trên lớp. Viết một quy tắc riêng chỉ cần vài chục dòng, nhưng các yêu cầu kiểu này thường không dừng ở đó. Có người muốn cấm goto, lock, biểu thức điều kiện lồng nhau hoặc phương thức có quá nhiều tham số. Mỗi yêu cầu lại cần một quy tắc mới, một mã chẩn đoán mới, một trang tài liệu mới và một phiên bản phát hành mới.

Vì vậy, thay vì viết từng quy tắc riêng, tôi xây dựng một quy tắc tổng quát. MA0240 biểu diễn mỗi tệp của dự án thành một tài liệu XML và báo cáo các nút được chọn bởi những truy vấn XPath trong tệp BannedSyntaxes.txt. Nhờ đó, dự án có thể cấm một cấu trúc ngôn ngữ và giải thích nên dùng gì thay thế mà không cần viết analyzer riêng.

Ví dụ, cấm primary constructor trên lớp chỉ cần một dòng:

//ClassDeclaration/ParameterList; Do not use primary constructors

Từ phiên bản 3.0.265, namespace semantic cung cấp các thuộc tính của semantic model. Từ phiên bản 3.0.268, namespace operation cung cấp các phần tử của operation tree. Nhờ đó, truy vấn có thể dựa trên ý nghĩa của mã thay vì chỉ dựa trên cách mã được viết.

Cây cú pháp C# dưới dạng tài liệu XML

Việc ánh xạ khá đơn giản:

  • Mỗi syntax node trở thành một phần tử có tên là loại của nó, chẳng hạn ClassDeclaration hoặc ParameterList. Các phần tử con chính là các nút con theo thứ tự xuất hiện trong mã nguồn.
  • Mỗi token của một nút trở thành một thuộc tính. Tên thuộc tính là tên thuộc tính của nút trả về token đó, chẳng hạn Identifier, Keyword hoặc Modifiers. Giá trị là văn bản của token. Các token bị thiếu không được đưa vào.

Mã C# sau:

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

được nhìn thấy bởi XPath như sau:

<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>

Phần tử gốc luôn là CompilationUnit, vì vậy có thể bắt đầu truy vấn bằng /CompilationUnit. Dấu câu cũng được biểu diễn, bởi OpenBraceToken và CloseBraceToken cũng là các thuộc tính trả về token.

Tên phần tử là các thành viên của enum Microsoft.CodeAnalysis.CSharp.SyntaxKind và có phân biệt hoa thường. Chỉ loại của syntax node được đưa vào, không có loại token hoặc trivia.

Tệp BannedSyntaxes.txt

Quy tắc được bật mặc định nhưng không làm gì cho đến khi dự án có tệp cấm cú pháp. Vì vậy các dự án không dùng tính năng này không phải chịu chi phí đáng kể.

Tạo tệp BannedSyntaxes.txt trong thư mục dự án hoặc một thư mục cha, chẳng hạn thư mục gốc của kho mã. Gói Meziantou.Analyzer sẽ thêm tệp gần nhất vào danh sách additional files của dự án, tương tự cách MSBuild tìm Directory.Build.props.

Mỗi dòng chứa một truy vấn, có thể theo sau bởi dấu chấm phẩy và thông báo:

# Dòng bắt đầu bằng # là chú thích
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

Phần truy vấn và thông báo được phân cách bởi dấu ; đầu tiên không nằm trong chuỗi của truy vấn. Dòng trống và dòng bắt đầu bằng # bị bỏ qua. Thông báo là tùy chọn. Khi không có thông báo, chẩn đoán có dạng The syntax '<kind>' is banned.

Loại cú pháp

Một truy vấn chỉ gồm một tên, có thể bắt đầu bằng //, được hiểu là tên một thành viên của SyntaxKind, chẳng hạn GotoStatement. Quy tắc sẽ báo cáo mọi nút thuộc loại này.

Các truy vấn dạng này nhanh hơn XPath tổng quát vì tất cả loại được tìm trong một lần duyệt cây. Do đó, nếu chỉ muốn cấm một cấu trúc ở mọi nơi, hãy ưu tiên GotoStatement thay vì //GotoStatement.

Truy vấn XPath

Các truy vấn còn lại tuân theo XPath 1.0. Quy tắc báo cáo từng phần tử hoặc thuộc tính được chọn.

  • //ClassDeclaration/ParameterList: danh sách tham số của primary constructor trong lớp.
  • //ClassDeclaration[ParameterList]: các lớp có primary constructor.
  • //ConditionalExpression//ConditionalExpression: các biểu thức điều kiện lồng trong một biểu thức điều kiện khác.
  • //MethodDeclaration[@Identifier='Execute']: các phương thức có tên Execute.
  • //MethodDeclaration/@Modifiers[contains(., 'async')]: modifier của các phương thức bất đồng bộ.
  • //ReturnStatement[ancestor::ConstructorDeclaration]: câu lệnh return trong constructor.
  • //MethodDeclaration[count(ParameterList/Parameter) > 5]/@Identifier: tên phương thức có hơn năm tham số.

Toán tử hợp cho phép chọn nhiều cấu trúc với cùng một thông báo:

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

Các trục như ancestor::, hàm count(), contains(), predicate và toán tử hợp đều do bộ máy XPath của BCL cung cấp. Analyzer chỉ cần cung cấp cách duyệt cây.

Truy vấn semantic model

Cây cú pháp chỉ biết mã được viết như thế nào. Chẳng hạn, //InvocationExpression[@Expression='Console.WriteLine'] không phát hiện System.Console.WriteLine(), không phát hiện lời gọi sau using static System.Console, và có thể bắt nhầm một lớp riêng cũng tên Console.

Vì vậy, quy tắc cung cấp semantic model thông qua các thuộc tính trong namespace semantic:

//AddExpression/*[@semantic:TypeMetadataName='System.Nullable`1']; Do not add nullable values
//InvocationExpression[@semantic:SymbolDocumentationId='M:System.Console.WriteLine(System.String)']; Use the logger

Tiền tố luôn là semantic. Namespace riêng giúp tránh xung đột với các thuộc tính token.

Ví dụ với object boxed = 42;, giá trị 42 có thể được biểu diễn như sau:

<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" />

Kiểu ban đầu là System.Int32, còn kiểu sau chuyển đổi ngầm là System.Object. Đây chính là boxing. Vì thế, cấm boxing có thể viết thành:

//*[@semantic:TypeIsValueType='true' and @semantic:ConvertedTypeMetadataName='System.Object']; Do not box values

Các thuộc tính kiểu

Bốn tiền tố biểu diễn kiểu:

  • semantic:Type...: kiểu của nút.
  • semantic:ConvertedType...: kiểu sau chuyển đổi ngầm.
  • semantic:ReturnType...: kiểu trả về của phương thức mà nút tham chiếu hoặc khai báo.
  • semantic:ContainingType...: kiểu chứa symbol mà nút tham chiếu hoặc khai báo.

Mỗi tiền tố có các hậu tố như Name, MetadataName, DocumentationId, ReferenceId, IsValueType, NullableAnnotation và SpecialType.

  • ...Name: chỉ tên riêng của kiểu, chẳng hạn List hoặc Int32.
  • ...MetadataName: tên metadata của kiểu.
  • ...DocumentationId: mã nhận dạng trong tài liệu XML.
  • ...ReferenceId: dạng được dùng trong cref.
  • ...IsValueType: true với value type và false với reference type.
  • ...NullableAnnotation: Annotated hoặc NotAnnotated.
  • ...SpecialType: tên thành viên của enum SpecialType, chẳng hạn System_Int32.

Symbol và hằng số

Một số thuộc tính quan trọng khác:

  • semantic:Symbol: symbol mà nút tham chiếu hoặc khai báo.
  • semantic:SymbolName: chỉ tên symbol.
  • semantic:SymbolDocumentationId: mã tài liệu của symbol, cho phép chọn đúng một overload.
  • semantic:SymbolKind: loại symbol như Method, Field, Property hoặc NamedType.
  • semantic:DeclaredAccessibility: mức truy cập được khai báo.
  • semantic:IsStatic: cho biết symbol có static hay không.
  • semantic:HasConstantValue và semantic:ConstantValue: thông tin về hằng số.

Ví dụ:

//MethodDeclaration[@semantic:DeclaredAccessibility='Public' and @semantic:IsStatic='true']
//EqualsExpression/*[@semantic:TypeSpecialType='System_String']
//IdentifierName[@semantic:TypeNullableAnnotation='Annotated']
//IdentifierName[@semantic:ContainingSymbolDocumentationId='M:Sample.Execute']

Ba dạng tên của một kiểu

Roslyn tạo ra ba dạng tên kiểu:

  • Metadata name: dạng mà Compilation.GetTypeByMetadataName yêu cầu. Generic type dùng hậu tố arity, còn kiểu lồng nhau dùng dấu +.
  • Documentation comment id: dạng được ghi trong tệp tài liệu XML, chẳng hạn T:System.Collections.Generic.List`1 hoặc M:System.String.Substring(System.Int32).
  • Reference id: dạng dùng trong cref, là dạng duy nhất chứa đầy đủ tham số kiểu, chẳng hạn System.Collections.Generic.List{System.String}.

Metadata name và documentation id không biểu diễn tham số kiểu nên giống nhau với List<int> và List<string>. Dùng reference id khi cần chọn đúng một dạng khởi tạo:

//*[@semantic:TypeMetadataName='System.Collections.Generic.List`1']
//*[@semantic:TypeReferenceId='System.Collections.Generic.List{System.Int32}']

Kiểu được khai báo là node, không phải thuộc tính

Kiểu của property, field, parameter hoặc local không phải thuộc tính của phần tử khai báo. Chính node biểu diễn kiểu mang các thuộc tính semantic. Vì vậy, các truy vấn sau chọn node kiểu:

//MethodDeclaration/*[@semantic:TypeMetadataName='System.Threading.Tasks.Task`1']
//Parameter/*[@semantic:TypeMetadataName='System.Collections.Generic.List`1']
//PropertyDeclaration/*[@semantic:TypeMetadataName='System.String']
//FieldDeclaration/VariableDeclaration/*[@semantic:TypeMetadataName='System.String']

Có mặt, vắng mặt và giá trị false

Thuộc tính không tồn tại khi không có giá trị. Vì vậy [@semantic:TypeMetadataName] chọn các node có kiểu, còn [not(@semantic:TypeMetadataName)] chọn các node không có kiểu. Các thuộc tính boolean vẫn tồn tại khi giá trị là false, do đó [@semantic:TypeIsValueType='false'] chọn reference type.

Nếu viết sai tên thuộc tính, truy vấn sẽ âm thầm không khớp gì. Lỗi này được báo bởi quy tắc MA0241.

Chi phí

Truy vấn dùng tiền tố semantic cần semantic model nên tốn chi phí hơn truy vấn cú pháp thuần túy. Những dòng không dùng tiền tố này vẫn được đánh giá mà không cần semantic model, vì vậy có thể trộn cả hai loại trong cùng một tệp.

Truy vấn operation tree

Các thuộc tính semantic trả lời câu hỏi: một tên liên kết tới đâu? Chúng không trả lời câu hỏi: đoạn mã thực hiện điều gì?

Compiler xây dựng operation tree bên cạnh syntax tree và semantic model. Operation tree biểu diễn ý nghĩa của mã: chuyển đổi ngầm là một node, lời gọi thực tế có method nào là một thuộc tính, for, foreach, while đều là loop với loại tương ứng, còn hằng số mang theo giá trị của nó.

Từ phiên bản 3.0.268, namespace operation cung cấp các phần tử tương ứng:

//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

Truy vấn cuối không thể biểu diễn bằng syntax tree. Một lời gọi trong vòng lặp có thể nằm trong if, using, lambda hoặc local function; vòng lặp có thể là for, foreach, while hay do. Trong operation tree, lời gọi chỉ đơn giản là hậu duệ của một node Loop.

Ánh xạ operation tree cũng dựa trên hai quy tắc:

  • Mỗi operation trở thành phần tử trong namespace operation, đặt tên theo OperationKind, chẳng hạn operation:Invocation, operation:Binary hoặc operation:Conversion.
  • Mỗi thuộc tính của operation trở thành thuộc tính XML không có tiền tố, chẳng hạn @IsImplicit hoặc @TargetMethod. Các thuộc tính chứa operation con được biểu diễn thành phần tử con.

Các operation của một tệp tạo thành một rừng, không phải một cây duy nhất. Thân của mỗi member, initializer của field, property, parameter và mỗi attribute là một gốc riêng. Không có CompilationUnit để neo truy vấn, vì vậy // là cách bắt đầu phù hợp nhất.

Một dòng chỉ dùng syntax tree hoặc operation tree, không dùng cả hai. Truy vấn có tiền tố operation được đánh giá trên operation tree và không có thuộc tính semantic. Muốn cấm cả hai dạng, hãy viết hai dòng riêng.

Thuộc tính của operation

Các thuộc tính được đặt tên theo property của interface operation. Nếu property trả về kiểu hoặc symbol, nó được mở rộng thành các hậu tố tương tự namespace semantic, nhưng bỏ tiền tố.

  • Kiểu: @PName, @PMetadataName, @PDocumentationId, @PReferenceId, @PIsValueType, @PNullableAnnotation, @PSpecialType.
  • Symbol: @P, @PName, @PDocumentationId, @PKind, @PIsStatic.
  • Boolean: @P có giá trị true hoặc false.
  • Enum: @P là tên thành viên, chẳng hạn Add hoặc While.
  • Chuỗi hoặc số: @P.

Một số ví dụ:

//operation:Invocation[@TargetMethodName='Parse']
//operation:Invocation[@IsVirtual='true']
//operation:Binary[@OperatorKind='Equals']/*[@TypeSpecialType='System_String']
//operation:Loop[@LoopKind='ForEach']
//operation:Literal[@HasConstantValue='true' and @ConstantValue='0']
//operation:PropertyReference[@PropertyDocumentationId='P:System.DateTime.Now']

Compiler cũng chèn các operation không có cú pháp riêng, chẳng hạn chuyển đổi của một argument hoặc receiver của lời gọi instance member. Chúng có @IsImplicit='true'. Vì vậy truy vấn sau chọn boxing:

//operation:Conversion[@IsImplicit='true' and @TypeMetadataName='System.Object']/*[@TypeIsValueType='true']; Do not box values

Nội dung văn bản của phần tử là mã nguồn tương ứng với operation. Do đó, //operation:Invocation[contains(., '@"')] có thể chọn các lời gọi chứa chuỗi nguyên văn.

Quay lại syntax bằng syntax()

Operation tree loại bỏ những chi tiết compiler không cần để biểu diễn ý nghĩa. Ví dụ, chuỗi nguyên văn, chuỗi nội suy và chuỗi thường có thể mang cùng một giá trị nên cách viết ban đầu không còn được giữ lại.

Hàm syntax kết nối hai tài liệu:

syntax(//operation:Invocation[@TargetMethodName='Write'])//InterpolatedStringExpression; Do not interpolate the messages
//operation:Invocation[syntax(.)//InterpolatedStringExpression]; Do not interpolate the arguments

Dòng đầu kết thúc trên syntax tree nên báo cáo InterpolatedStringExpression. Dòng thứ hai chỉ dùng syntax tree làm predicate và kết thúc trên operation, nên báo cáo operation:Invocation. Nút cuối cùng mà truy vấn chọn sẽ là nơi chẩn đoán được báo cáo.

  • syntax(//operation:Invocation): các syntax node của lời gọi.
  • syntax(//operation:Invocation)//StringLiteralExpression/@Token[starts-with(., '@')]: chuỗi nguyên văn trong lời gọi.
  • //operation:Invocation[syntax(.)//InterpolatedStringExpression]: lời gọi có chứa chuỗi nội suy.

Không thể viết //operation:Invocation/syntax() vì lời gọi hàm không thể là một bước của đường dẫn. Hàm nhận operation làm tham số và đường dẫn tiếp tục sau lời gọi hàm.

Nên dùng syntax, semantic hay operation?

Ba tầng có phần giao nhau. Hãy dùng tầng rẻ nhất nhưng vẫn trả lời được câu hỏi:

  • Syntax kind, chẳng hạn GotoStatement, khi cấu trúc cần tìm chỉ phụ thuộc hình dạng mã nguồn. Đây là cách nhanh nhất.
  • XPath trên syntax tree khi cần cấu trúc, số lượng, ancestor hoặc tên trong mã là đủ.
  • Thuộc tính semantic khi truy vấn phụ thuộc vào symbol mà tên liên kết tới.
  • Operation tree khi cấu trúc không xuất hiện trực tiếp trong mã nguồn, chẳng hạn chuyển đổi ngầm, hoặc khi cần quan hệ như một lời gọi nằm trong vòng lặp.

Truy vấn dùng operation cần semantic model nên có chi phí tương đương truy vấn dùng thuộc tính semantic. Nếu không dùng tiền tố nào trong hai tiền tố này, dự án không cần yêu cầu semantic model.

Ví dụ hoàn chỉnh

Tệp BannedSyntaxes.txt có thể chứa:

GotoStatement; Use structured control flow instead
//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
//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>
//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

Với một tệp C# chứa primary constructor, goto, biểu thức điều kiện lồng nhau, phương thức có sáu tham số, boxing, DateTime.Now, lời gọi trong vòng lặp và lời gọi tới Console.WriteLine, khi build dự án sẽ nhận được 11 cảnh báo MA0240.

Điểm đáng chú ý là nếu truy vấn chọn thuộc tính, chẩn đoán được báo cáo trên token của thuộc tính. Vì vậy truy vấn /@Identifier báo cáo trên tên phương thức thay vì toàn bộ khai báo.

Với các truy vấn semantic, GenericName là List<string> trong kiểu trả về, còn IdentifierName là argument count bị boxing. Với operation tree, operation:PropertyReference báo cáo trên DateTime.Now, còn operation:Invocation tìm lời gọi Load(name) nhờ quan hệ với node Loop.

Cấu hình

Gói đọc thuộc tính MeziantouIncludeBannedSyntaxesFile trước khi tệp dự án được đánh giá. Vì vậy, đặt thuộc tính này trực tiếp trong tệp dự án không có tác dụng. Hãy đặt nó trong Directory.Build.props:

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

Quy tắc cũng đọc các additional file có tên BannedSyntaxes.txt hoặc BannedSyntaxes.*.txt, chẳng hạn BannedSyntaxes.Shared.txt. Một tệp dùng chung có thể kết hợp với tệp riêng của dự án:

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

Các dòng không hợp lệ, như tên không thuộc SyntaxKind, thuộc tính semantic không tồn tại, XPath sai hoặc truy vấn không trả về node-set, sẽ được báo bởi MA0241. Các dòng hợp lệ khác vẫn được áp dụng.

Tìm đúng tên kind

Để viết truy vấn, bạn cần biết Roslyn đặt tên cho cấu trúc cần tìm như thế nào. Một số công cụ hữu ích:

  • SyntaxKind.cs: danh sách mọi kind trong mã nguồn Roslyn.
  • Roslyn Quoter: hiển thị lời gọi syntax factory và kind của từng node.
  • Razor Lab: hiển thị syntax tree của đoạn C#.
  • SharpLab: dùng chế độ Syntax Tree.
  • Syntax Visualizer của Visual Studio.

Các thuộc tính semantic cần tên thay vì kind. Documentation comment id do compiler tạo ra trong tệp XML. Bật <GenerateDocumentationFile>true</GenerateDocumentationFile> là cách nhanh để tìm đúng id của symbol trong mã của bạn.

Các phần tử operation dùng tên của OperationKind. Thuộc tính tương ứng với property của interface operation, chẳng hạn IInvocationOperation cho operation:Invocation.

Kết luận

Đưa cây cú pháp C# qua XPathNavigator biến việc “viết một analyzer” thành việc “viết một dòng trong tệp văn bản”. Analyzer chỉ cung cấp khả năng duyệt cây; ngôn ngữ truy vấn, các trục và hàm đều đến từ System.Xml.XPath.

Việc bổ sung semantic model chỉ cần thêm các thuộc tính. Việc bổ sung operation tree chỉ cần một XPathNavigator khác. Các trục như ancestor::, hàm count() và toán tử and tiếp tục hoạt động mà không cần thay đổi bộ máy XPath. Ngay cả syntax() cũng chỉ là một hàm mở rộng XPath thông thường.

Ý tưởng này có thể áp dụng cho bất kỳ cây nào đã tồn tại trong bộ nhớ. Nếu người dùng cần mô tả một tập con của cây, triển khai XPathNavigator thường rẻ hơn việc thiết kế một ngôn ngữ truy vấn riêng, và rẻ hơn nhiều so với việc thêm một tùy chọn mới cho mỗi nhu cầu lọc khác nhau.

Tài nguyên thêm