Skip to main content

Writing custom checks

clang-tidy for Unreal Engine allows you to define your own custom checks on C++ code in Lua. You define custom checks in the same ClangTidy.lua file that you use to enable built-in checks.

Writing custom checks broadly involves 3 steps:

  • Identifying the code you want to detect
  • Writing an AST matcher that matches that code pattern
  • Adding the check {} block to ClangTidy.lua.

For an extensive walkthrough of this process, refer to A practical example: GetLifetimeReplicatedProps at the bottom of this document which covers the process in-depth.

Identify the code you want to detect

To write a custom check, you first need to know what kind of code you want to detect and what file it's in. Fairly straight-forward.

Writing an AST matcher

Before you can write your check, you need to write the AST matcher expression. This tells clang-tidy what code it's going to look for to generate warnings or errors.

... with Godbolt

You can use the online compiler explorer Godbolt to inspect the Clang AST and test your AST matcher in your browser.

This has the advantage that you can write small snippets, see what the AST looks like, and then test out your matcher expression in real-time, without having to locate files on your machines or launch clang-query.

This method has the disadvantage that you can't match on any Unreal Engine specific code, such as matching on UCLASS() or UPROPERTY(). You also can't include any Unreal Engine headers, so the code you want to detect has to be fairly self-contained and not relying on Unreal Engine classes or structures.

... with clang-query

clang-tidy for Unreal Engine ships with clang-query, a tool you can use to write and test AST matcher expressions against your Unreal Engine code.

Turn on showing clang-query commands

To get the command you'll need to use to launch clang-query, add the following to the .Build.cs of the module that contains the code you want to detect:

PublicDefinitions.Add("ModuleName_CLANG_TIDY_DISPLAY_QUERY_COMMANDS=1"); 

Replace ModuleName with the name of the module. The next time you build, you'll see output like this:

[clang-query] "C:\ProgramData\ClangTidyForUnrealEngine\clang-query.exe" "-p=C:\PathToYourProject\Intermediate\Build\Win64\UE4Editor\Development\YourModule\.clang\1766557890\compile_commands.json" "--header-filter=.*/YourModule/.*" "C:\PathToYourProject\Source\YourModule\YourFile.cpp"

Locate the command for the file you want to inspect and paste it into Command Prompt.

Use clang-query to write the AST matcher

Once clang-query finishes parsing your file, you'll be presented with this prompt:

clang-query> _

There are a few commands that you need to know:

  • m <expr> (or match <expr>): Evaluates the provided expression and returns the matches. That is, if you were to use the expression as the matcher for a check in ClangTidy.lua, and match would be a warning or error as per the check configuration.
  • set bind-root true/false: Configures whether clang-query automatically binds the root matcher as root in the match results. This is useful for inspection, but clang-tidy does not automatically bind the root node, so you will want to turn if off before finalizing the matcher for use in your check.
  • set output diag/detailed-ast: By default, the output is diag which just shows each bound node and the line of code that it is associated with for each match. If you set it to detailed-ast, it will give the full tree of AST nodes in the match results. This is helpful for adding narrowing or traversal AST matches to more accurately target the code you are trying to match.

As an example, if you wanted to match all field declarations, you could input a command line this:

m fieldDecl()

This is likely to result in a lot of matches because it's so generic. You can refer to the following documents as to what AST matchers you can use in your expression:

  • The Clang AST matcher reference. This has the full set of standard matchers you can use in expressions.
  • The extended AST matcher reference. This covers the clang-tidy for Unreal Engine extended matchers which are useful for matching against Unreal Engine code (including matching against e.g. UCLASS() and specifiers).

Add the check to ClangTidy.lua

Once you have the matcher expression, you can define the check in ClangTidy.lua, like so:

check {
name = "my-check-name",
description = [[
A description to let you know what this check catches. This isn't currently visible anywhere other than the ClangTidy.lua file.
]],
matcher = [[
fieldDecl().bind("main_bound_node")
]],
message = [[the warning message that will appear in the output, on the node that 'main_bound_node' is bound to]],
callsite = "main_bound_node",
hints = {
other_binding_id = [[if you had another .bind("other_binding_id") in the expression, clang-tidy would emit a hint at that node's location with this message]]
}
}

-- Enable the check.
enable_checks("my-check-name")

A practical example: GetLifetimeReplicatedProps

This example will guide you through the process we used to create the check that detects when you've missed a property in GetLifetimeReplicatedProps. It's a fairly advanced example, and demonstrates using clang-query to derive the matcher, as well as a matcher that requires iteration and cross-referencing types.

Write the offending code

First up, we need to write the offending code. That is, we need to explicitly write an example of the code that we want to catch. You might already have this, but it's important to distill it down to the absolute bare minimum required to match it, as this will make traversing the AST with clang-query much easier later on.

In our example project, we created a class like this in the header file:

UCLASS()
class ABadActor : public AActor
{
GENERATED_BODY()

public:
UPROPERTY(Replicated)
int A;

UPROPERTY(Replicated)
int B;
};

And an implementation that looks like this in the source code file:

void ABadActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty> &OutLifetimeProps) const
{
DOREPLIFETIME(ABadActor, A);
// We've forgotten about B though.
}

We're now going to write a matcher that will detect the lack of a DOREPLIFETIME() or equivalent macro call in the body of GetLifetimeReplicatedProps.

Identity the base node to match on

We need to figure out what the base AST node is that we're going to match on. That is, what type of node will clang-tidy look for, that we will then place additional constraints on in order to match. In this case, we could think of the following candidiates:

  • The properties (field declarations) in the class. The problem with this node is that we don't have a reference to the GetLifetimeReplicateProps body, and it may not even be included in this translation unit (if this class was included as a header for an unrelated translation unit). So this candidate is no good.
  • The DOREPLIFETIME() which maps to a RegisterReplicatedLifetimeProperty call underneath. We want to find properties that don't have a DOREPLIFETIME() call at all, so this won't help us discover the missing properties.
  • The implementation of the GetLifetimeReplicatedProps method. Matching on this node allows us to iterate through all of the field declarations on the class it belongs to, and iterate through all of the call expressions in it's body. This is the AST node we will match on.

Therefore, we will try to write a matcher that starts out matching implementations of GetLifetimeReplicatedProps and then we will refine it to match on implementations that miss properties in the class it's declared for.

Launch clang-query to experiment with matchers

We modify the .Build.cs of the module that we've written our offending code in, and we turn on emission of clang-query command lines by adding the following definition:

PublicDefinitions.Add("ModuleName_CLANG_TIDY_DISPLAY_QUERY_COMMANDS=1"); 

Make sure you replace ModuleName with the actual name of the module.

Now when we build, we'll see the lines like the following emitted when the build is configured:

[clang-query] "C:\ProgramData\ClangTidyForUnrealEngine\clang-query.exe" "-p=C:\PathToYourProject\Intermediate\Build\Win64\UE4Editor\Development\YourModule\.clang\1766557890\compile_commands.json" "--header-filter=.*/YourModule/.*" "C:\PathToYourProject\Source\YourModule\YourFile.cpp"

You want to locate the line for the file (in the case above, YourFile.cpp) that you've written the offending code in.

If you don't see [clang-query] lines in the build log, modify the .Build.cs with just a space or some other type of non-impacting character and run the build again. The [clang-query] lines are only emitted when UnrealBuildTool has to evaluate the build rules; they're not shown if UBT just re-uses the existing build configuration.

Copy all of the content after the [clang-query] and paste it into a Command Prompt window, like so:

Launching clang-query from the command line

Then hit Enter to launch clang-query.

info

When clang-query starts, it might take a moment to parse the header files and source code.

You just need to wait until clang-query presents you with a prompt like so:

clang-query> _

Open the AST reference documents

To know what you can match on, you'll want to open the following reference documents:

  • The Clang AST matcher reference. This has the full set of standard matchers you can use in expressions.
  • The extended AST matcher reference. This covers the clang-tidy for Unreal Engine extended matchers which are useful for matching against Unreal Engine code (including matching against e.g. UCLASS() and specifiers).

Write a basic matcher that finds the base node

Let's start out just trying to match all GetLifetimeReplicatedProps implementations. We can get there if put the following command into clang-query:

clang-query> m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt()))

The m command (or match command) runs the AST matcher expression you give it against the file you have loaded. When you eventually copy your AST matcher into ClangTidy.lua, you don't need the m command prefix; you just copy the AST matcher expression.

To breakdown the expression we've just written above, it:

  • Matches on cxxMethodDecl nodes; that is C++ method declarations.
  • For the nodes it matches on, they must also:
    • Have the name GetLifetimeReplicatedProps.
    • Have a body with a block of statements (the { ... } section after the function declaration). The compoundStmt matches the node that represents one or more statements within a function body.

That said, at this point we need a bit more information about the structure of the AST that we're matching on. To get more detailed information about all of the child nodes, tell clang-query to output more detailed information, and then run the matcher again:

set output detailed-ast
m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt()))

At this point, you should see an entry like this:

CXXMethodDecl 0x1fa61a25e38 parent 0x1fa5d97f460 prev 0x1fa5d9816b8 <C:\PathToProject\OffendingCode.cpp:43:1, line:46:1> line:43:17 used GetLifetimeReplicatedProps 'void (TArray<FLifetimeProperty> &) const'
|-Overrides: [ 0x1fa57bb6768 AActor::GetLifetimeReplicatedProps 'void (TArray<FLifetimeProperty> &) const' ]
|-ParmVarDecl 0x1fa61a25d40 <col:44, col:71> col:71 used OutLifetimeProps 'TArray<FLifetimeProperty> &'
|-CompoundStmt 0x1fa61a53f10 <line:44:1, line:46:1>
| |-CompoundStmt 0x1fa61a53ee8 <Runtime\Engine\Public\Net/UnrealNetwork.h:215:47, line:218:84>
| | |-DeclStmt 0x1fa61a2b210 <line:217:2, col:117>
| | | `-VarDecl 0x1fa61a25fd0 <col:2, col:116> col:13 used ReplicatedProperty 'FProperty *' cinit
| | | `-ExprWithCleanups 0x1fa61a2b1f8 <col:34, col:116> 'FProperty *'
| | | `-CallExpr 0x1fa61a2b160 <col:34, col:116> 'FProperty *'
| | | |-ImplicitCastExpr 0x1fa61a2b148 <col:34> 'FProperty *(*)(const UClass *, const UClass *, const FName &)' <FunctionToPointerDecay>
| | | | `-DeclRefExpr 0x1fa61a2b0f8 <col:34> 'FProperty *(const UClass *, const UClass *, const FName &)' lvalue Function 0x1fa7075ed38 'GetReplicatedProperty' 'FProperty *(const UClass *, const UClass *, const FName &)'
| | | |-ImplicitCastExpr 0x1fa61a2b198 <col:56, col:68> 'const UClass *' <NoOp>
| | | | `-CallExpr 0x1fa61a260f0 <col:56, col:68> 'UClass *'
| | | | `-ImplicitCastExpr 0x1fa61a260d8 <col:56> 'UClass *(*)()' <FunctionToPointerDecay>
| | | | `-DeclRefExpr 0x1fa61a260b8 <col:56> 'UClass *()' lvalue CXXMethod 0x1fa5d9803c8 'StaticClass' 'UClass *()'
| | | |-ImplicitCastExpr 0x1fa61a2b1b0 <C:\PathToProject\OffendingCode.cpp:45:19, Runtime\Engine\Public\Net/UnrealNetwork.h:217:86> 'const UClass *' <NoOp>
| | | | `-CallExpr 0x1fa61a26190 <C:\PathToProject\OffendingCode.cpp:45:19, Runtime\Engine\Public\Net/UnrealNetwork.h:217:86> 'UClass *'
| | | | `-ImplicitCastExpr 0x1fa61a26178 <C:\PathToProject\OffendingCode.cpp:45:19, Runtime\Engine\Public\Net/UnrealNetwork.h:217:74> 'UClass *(*)()' <FunctionToPointerDecay>
| | | | `-DeclRefExpr 0x1fa61a26148 <C:\PathToProject\OffendingCode.cpp:45:19, Runtime\Engine\Public\Net/UnrealNetwork.h:217:74> 'UClass *()' lvalue CXXMethod 0x1fa5d9803c8 'StaticClass' 'UClass *()'
| | | `-MaterializeTemporaryExpr 0x1fa61a2b1e0 <Runtime\Core\Public\Misc/AssertionMacros.h:371:2, col:116> 'const FName' lvalue
| | | `-ImplicitCastExpr 0x1fa61a2b1c8 <col:2, col:116> 'const FName' <NoOp>
| | | `-ParenExpr 0x1fa61a2b0d8 <col:2, col:116> 'FName'
| | | `-BinaryOperator 0x1fa61a2b0b8 <col:3, col:115> 'FName' ','
| | | |-CStyleCastExpr 0x1fa61a26ab0 <col:3, col:89> 'void' <ToVoid>
| | | | `-UnaryExprOrTypeTraitExpr 0x1fa61a26a80 <col:9, col:89> 'unsigned long long' sizeof
| | | | `-ParenExpr 0x1fa61a26a60 <col:15, col:89> 'bool'
| | | | `-CallExpr 0x1fa61a26a20 <col:16, col:88> 'bool'
| | | | |-ImplicitCastExpr 0x1fa61a26a08 <col:16, col:36> 'bool (*)(const int &)' <FunctionToPointerDecay>
| | | | | `-DeclRefExpr 0x1fa61a26970 <col:16, col:36> 'bool (const int &)' lvalue Function 0x1fa61a264c8 'GetMemberNameCheckedJunk' 'bool (const int &)' (FunctionTemplate 0x1fa4ee1e670 'GetMemberNameCheckedJunk') non_odr_use_unevaluated
| | | | `-ImplicitCastExpr 0x1fa61a26a48 <col:61, C:\PathToProject\OffendingCode.cpp:45:30> 'const int':'const int' lvalue <NoOp>
| | | | `-MemberExpr 0x1fa61a262c0 <Runtime\Core\Public\Misc/AssertionMacros.h:371:61, C:\PathToProject\OffendingCode.cpp:45:30> 'int' lvalue ->A 0x1fa5d982cf8 non_odr_use_unevaluated
| | | | `-ParenExpr 0x1fa61a262a0 <Runtime\Core\Public\Misc/AssertionMacros.h:371:61, col:75> 'ABadActor *'
| | | | `-CStyleCastExpr 0x1fa61a26278 <col:62, col:74> 'ABadActor *' <NoOp>
| | | | `-ImplicitCastExpr 0x1fa61a26260 <col:74> 'ABadActor *' <NullToPointer> part_of_explicit_cast
| | | | `-IntegerLiteral 0x1fa61a26228 <col:74> 'int' 0
| | | `-CXXFunctionalCastExpr 0x1fa61a2b090 <col:92, col:115> 'FName' functional cast to class FName <ConstructorConversion>
| | | `-CXXConstructExpr 0x1fa61a2b058 <col:92, col:115> 'FName' 'void (const WIDECHAR *, EFindName)'
| | | |-ImplicitCastExpr 0x1fa61a2b020 <<scratch space>:66:1> 'const wchar_t *' <ArrayToPointerDecay>
| | | | `-StringLiteral 0x1fa61a26ae8 <col:1> 'const wchar_t [2]' lvalue L"A"
| | | `-CXXDefaultArgExpr 0x1fa61a2b038 <<invalid sloc>> 'EFindName'
| | `-ExprWithCleanups 0x1fa61a53ed0 <Runtime\Engine\Public\Net/UnrealNetwork.h:218:2, col:81> 'void'
| | `-CallExpr 0x1fa61a53d60 <col:2, col:81> 'void'
| | |-ImplicitCastExpr 0x1fa61a53d48 <col:2> 'void (*)(const FProperty *, TArray<FLifetimeProperty> &, const FDoRepLifetimeParams &)' <FunctionToPointerDecay>
| | | `-DeclRefExpr 0x1fa61a53cd0 <col:2> 'void (const FProperty *, TArray<FLifetimeProperty> &, const FDoRepLifetimeParams &)' lvalue Function 0x1fa70768ae8 'RegisterReplicatedLifetimeProperty' 'void (const FProperty *, TArray<FLifetimeProperty> &, const FDoRepLifetimeParams &)'
| | |-ImplicitCastExpr 0x1fa61a53db0 <col:37> 'const FProperty *' <NoOp>
| | | `-ImplicitCastExpr 0x1fa61a53d98 <col:37> 'FProperty *' <LValueToRValue>
| | | `-DeclRefExpr 0x1fa61a2b280 <col:37> 'FProperty *' lvalue Var 0x1fa61a25fd0 'ReplicatedProperty' 'FProperty *'
| | |-DeclRefExpr 0x1fa61a2b2a0 <col:57> 'TArray<FLifetimeProperty>':'TArray<FLifetimeProperty>' lvalue ParmVar 0x1fa61a25d40 'OutLifetimeProps' 'TArray<FLifetimeProperty> &'
| | `-MaterializeTemporaryExpr 0x1fa61a53de0 <line:221:58, col:79> 'const FDoRepLifetimeParams' lvalue
| | `-ImplicitCastExpr 0x1fa61a53dc8 <col:58, col:79> 'const FDoRepLifetimeParams' <NoOp>
| | `-CXXTemporaryObjectExpr 0x1fa61a2b490 <col:58, col:79> 'FDoRepLifetimeParams' 'void () noexcept' zeroing
| `-NullStmt 0x1fa61a53f08 <C:\PathToProject\OffendingCode.cpp:45:32>
`-OverrideAttr 0x1fa61a25f98

Now this is a lot. That's because the DOREPLIFETIME macro gets expanded into the AST, so what we're seeing here is all of the code that's generated from the DOREPLIFETIME macro.

Locating the fields that we do call DOREPLIFETIME for

If we look at the above output, we can see that Unreal calls GetMemberNameCheckedJunk with the member access as it's first and only parameter. Now this call doesn't actually happen at runtime; it's passed into a sizeof() expression and has no body, as it's basically just a trick that DOREPLIFETIME uses to ensure a given property actually exists. But we can use the presence of this member access in the AST to figure out all of the properties that are accessed in the GetLifetimeReplicatedProps method.

So let's try to match the GetMemberNameCheckedJunk method using the following expression:

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt(forEachDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk")))).bind("zzz_member_access")))))

We've modified compoundStmt and we're using forEachDescendant (a traversal matcher) to find all of the invocations of GetMemberNameCheckedJunk:

  • Find all of the call expressions (callExpr) ...
  • ... which call (callee) ...
  • ... a named declaration (namedDecl) ...
  • ... whose name is "GetMemberNameCheckedJunk" (hasName) ...
  • ... and bind those call expressions to a given name (.bind("..."))

If you've done this right, you should now see the following additional binding in the output when you run this in clang-query:

Binding for "zzz_member_access":
CallExpr 0x1fa61a26a20 <Runtime\Core\Public\Misc/AssertionMacros.h:371:16, col:88> 'bool'
|-ImplicitCastExpr 0x1fa61a26a08 <col:16, col:36> 'bool (*)(const int &)' <FunctionToPointerDecay>
| `-DeclRefExpr 0x1fa61a26970 <col:16, col:36> 'bool (const int &)' lvalue Function 0x1fa61a264c8 'GetMemberNameCheckedJunk' 'bool (const int &)' (FunctionTemplate 0x1fa4ee1e670 'GetMemberNameCheckedJunk') non_odr_use_unevaluated
`-ImplicitCastExpr 0x1fa61a26a48 <col:61, C:\PathToProject\OffendingCode.cpp:45:30> 'const int':'const int' lvalue <NoOp>
`-MemberExpr 0x1fa61a262c0 <Runtime\Core\Public\Misc/AssertionMacros.h:371:61, C:\PathToProject\OffendingCode.cpp:45:30> 'int' lvalue ->A 0x1fa5d982cf8 non_odr_use_unevaluated
`-ParenExpr 0x1fa61a262a0 <Runtime\Core\Public\Misc/AssertionMacros.h:371:61, col:75> 'ABadActor *'
`-CStyleCastExpr 0x1fa61a26278 <col:62, col:74> 'ABadActor *' <NoOp>
`-ImplicitCastExpr 0x1fa61a26260 <col:74> 'ABadActor *' <NullToPointer> part_of_explicit_cast
`-IntegerLiteral 0x1fa61a26228 <col:74> 'int' 0

That said, the interesting part for us is not the GetMemberNameCheckedJunk but the member access that happens inside it. So let's modify the expression again to bind on what we care about:

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt(forEachDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk"))), hasArgument(0, memberExpr(member(fieldDecl().bind("zzz_member")))))))))

In this case, we've added hasArgument as another filter on the callExpr, and inside this we locate the member access and bind to the member being accessed. In our case, we're interested in member access operations that point to fields, which is why we use and bind on the fieldDecl().

If you've done this right, you should see the field declaration in the clang-query output:

Binding for "zzz_member":
FieldDecl 0x1fa5d982cf8 <C:\PathToProject\OffendingCode.h:55:5, col:9> col:9 referenced A 'int'

Locating all of the replicated fields that were declared

We've got a list of all of the fields that we're accessing as part of DOREPLIFETIME calls and we've bound them as results for the matcher. Now we also need to get a list of all of the properties declared on the class so that we have a list of the fields that we should be calling DOREPLIFETIME on.

The cxxMethodDecl has an inner matcher called ofClass, which will let us evaluate the cxxRecordDecl it is a part of (records are for both structs and classes).

For the moment, let's just focus on trying to match all of the fields that were declared replicated and then we'll combine the working expression with the one we used to find all of the DOREPLIFETIME calls.

If we use this matching expression, we'll should be able to find the class (the output might be long for this, depending on how many members the class has):

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt()), ofClass(cxxRecordDecl().bind("zzz_class")))

We can then find all of the field declarations using has. Note that we don't use hasDescendant because we only want to match the field declarations inside this class, and not any nested structures or classes.

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt()), ofClass(cxxRecordDecl(has(fieldDecl().bind("zzz_field")))))

You should be able to see the field in the output:

Binding for "zzz_field":
FieldDecl 0x1fa5d982cf8 <C:\PathToProject\OffendingCode.h:55:5, col:9> col:9 referenced A 'int'

We actually need to filter fields so that we only include replicated properties, so add the relevant narrowing matchers onto the fieldDecl:

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), hasBody(compoundStmt()), ofClass(cxxRecordDecl(has(fieldDecl(isUProperty(), hasUSpecifier("replicated")).bind("zzz_field")))))

You should still see the field declaration in the output when you run this.

Combining both expressions together into a single matcher

Now that we've both expressions, we can combine them into a single matcher so we get all of the bindings we care about at once. We can use set bind-root to hide the root node in each match that we don't actually care about:

set bind-root false
m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), ofClass(cxxRecordDecl(forEach(fieldDecl(isUProperty(), hasUSpecifier("replicated")).bind("declared_property")))), hasBody(compoundStmt(forEachDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk"))), hasArgument(0, memberExpr(member(fieldDecl().bind("registered_property")))))))))

This will give us results that match every pair of registered and declared properties:

Match #1:

Binding for "declared_property":
FieldDecl 0x1fa5d982cf8 <C:\PathToProject\OffendingCode.h:55:5, col:9> col:9 referenced A 'int'

Binding for "registered_property":
FieldDecl 0x1fa5d982cf8 <C:\PathToProject\OffendingCode.h:55:5, col:9> col:9 referenced A 'int'


Match #2:

Binding for "declared_property":
FieldDecl 0x1fa5d982da0 <C:\PathToProject\OffendingCode.h:58:5, col:9> col:9 B 'int'

Binding for "registered_property":
FieldDecl 0x1fa5d982cf8 <C:\PathToProject\OffendingCode.h:55:5, col:9> col:9 referenced A 'int'

Now if we expand our offending code to the following snippets, restart clang-query and run the matcher again, we can see we're getting a combination of every possibility:

UCLASS()
class ABadActor : public AActor
{
GENERATED_BODY()

public:
UPROPERTY(Replicated)
int A;

UPROPERTY(Replicated)
int B;

UPROPERTY(Replicated)
int C;

UPROPERTY(Replicated)
int D;
};
void ABadActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty> &OutLifetimeProps) const
{
DOREPLIFETIME(ABadActor, A);
DOREPLIFETIME(ABadActor, D);
}
set bind-root false
m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), ofClass(cxxRecordDecl(forEach(fieldDecl(isUProperty(), hasUSpecifier("replicated")).bind("declared_property")))), hasBody(compoundStmt(forEachDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk"))), hasArgument(0, memberExpr(member(fieldDecl().bind("registered_property")))))))))
Match #1:

C:\PathToProject\OffendingCode.h(55,5): note: "declared_property" binds here
int A;
^~~~~
C:\PathToProject\OffendingCode.h(55,5): note: "registered_property" binds here
int A;
^~~~~

Match #2:

C:\PathToProject\OffendingCode.h(58,5): note: "declared_property" binds here
int B;
^~~~~
C:\PathToProject\OffendingCode.h(55,5): note: "registered_property" binds here
int A;
^~~~~

.....

Match #8:

C:\PathToProject\OffendingCode.h(64,5): note: "declared_property" binds here
int D;
^~~~~
C:\PathToProject\OffendingCode.h(64,5): note: "registered_property" binds here
int D;
^~~~~
8 matches.

If we were to constrain the second forEach to match only named nodes with the equalsBoundNode narrowing matcher in this expression:

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), ofClass(cxxRecordDecl(forEach(fieldDecl(isUProperty(), hasUSpecifier("replicated")).bind("declared_property")))), hasBody(compoundStmt(forEachDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk"))), hasArgument(0, memberExpr(member(fieldDecl(equalsBoundNode("declared_property")).bind("registered_property")))))))))

We'd get the following results:

Match #1:

C:\PathToProject\OffendingCode.h(55,5): note: "declared_property" binds here
int A;
^~~~~
C:\PathToProject\OffendingCode.h(55,5): note: "registered_property" binds here
int A;
^~~~~

Match #2:

C:\PathToProject\OffendingCode.h(64,5): note: "declared_property" binds here
int D;
^~~~~
C:\PathToProject\OffendingCode.h(64,5): note: "registered_property" binds here
int D;
^~~~~
2 matches.

What we actually want is to match when there is no matching registered property. To do this, we use the forNoDescendant extended AST matcher. For a full explaination on how this works, refer to forNone.

When we switch over to forNoDescendant and remove the .bind("registered_property"):

m cxxMethodDecl(hasName("GetLifetimeReplicatedProps"), ofClass(cxxRecordDecl(forEach(fieldDecl(isUProperty(), hasUSpecifier("replicated")).bind("declared_property")))), hasBody(compoundStmt(forNoDescendant(callExpr(callee(namedDecl(hasName("GetMemberNameCheckedJunk"))), hasArgument(0, memberExpr(member(fieldDecl(equalsBoundNode("declared_property"))))))))))

We now get the expected results:

Match #1:

C:\PathToProject\OffendingCode.h(58,5): note: "declared_property" binds here
int B;
^~~~~

Match #2:

C:\PathToProject\OffendingCode.h(61,5): note: "declared_property" binds here
int C;
^~~~~
2 matches.

Adding it to ClangTidy.lua

We can now use the matcher expression and add our check to ClangTidy.lua:

check {
name = "my-doreplifetime-check",
description = [[
Detects when you forget to call DOREPLIFETIME() for a replicated property.
]],
matcher = [[
cxxMethodDecl(
hasName("GetLifetimeReplicatedProps"),
ofClass(
cxxRecordDecl(
forEach(
fieldDecl(
isUProperty(),
hasUSpecifier("replicated")
).bind("declared_property")
)
)
),
hasBody(
compoundStmt(
forNoDescendant(
callExpr(
callee(
namedDecl(
hasName("GetMemberNameCheckedJunk")
)
),
hasArgument(
0,
memberExpr(
member(
fieldDecl(
equalsBoundNode("declared_property")
)
)
)
)
)
)
)
)
).bind("configuration_site")
]],
message = [[missing call to DOREPLIFETIME() or equivalent macro in GetLifetimeReplicatedProps for a replicated property.]],
callsite = "declared_property",
hints = {
configuration_site = "this implementation needs to call DOREPLIFETIME() or an similar macro to configure this property for replication"
}
}

-- Actually enable the check.
enable_checks("my-doreplifetime-check")

Note that in ClangTidy.lua we can format our matcher with newlines and indentation, which makes them much more readable.

danger

Don't forget the commas (,) after each entry in the check { } block, or your ClangTidy.lua file won't load.

Now when you build your project, you'll get warnings about properties that don't have DOREPLIFETIME() calls:

Screenshot of warnings in Visual Studio

info

We actually include this check in clang-tidy for Unreal Engine, so you don't need to define it yourself. You can just enable it with enable_checks("unreal-missing-doreplifetime-for-replicated-property").

However, we hope that this has been an extensive example on how build custom checks for your own scenarios.