Better C++ Syntax Highlighting - Part 5: Classes

· Updated Jul 14, 2025

Classes introduce significantly more complexity than enums, namespaces, or functions, but the same core principles apply. Consider the following example:

cpp
1
#include <cmath> // std::sqrt
2
3
struct Vector3 {
4
static const Vector3 zero;
5
6
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
7
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
8
~Vector3() { }
9
10
[[nodiscard]] float length() const {
11
return std::sqrt(x * x + y * y + z * z);
12
}
13
14
float x;
15
float y;
16
float z;
17
};
18
19
// Const static class members must be initialized out of line
20
const Vector3 Vector3::zero = Vector3();
21
22
int main() {
23
Vector3 zero = Vector3::zero;
24
// ...
25
}

And corresponding AST:

text
Show 76 more lines

Classes involve multiple interconnected AST node types, each representing different aspects of the class definition and usage. The nodes we’ll encounter in this section include:

We’ll need to extend our visitor with several new visitor functions to handle these node types:

cpp
bool VisitCXXRecordDecl(clang::CXXRecordDecl* node);
bool VisitCXXConstructorDecl(clang::CXXConstructorDecl* node);
bool VisitCXXDestructorDecl(clang::CXXDestructorDecl* node);
bool VisitFieldDecl(clang::FieldDecl* node);
bool VisitMemberExpr(clang::MemberExpr* node);

Class definitions

Declarations of classes, structs, and unions are represented by CXXRecordDecl nodes. The implementation of this visitor follows the same pattern we’ve seen before:

cpp
1
bool Visitor::VisitCXXRecordDecl(clang::CXXRecordDecl* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
const std::string& name = node->getNameAsString();
6
7
clang::SourceLocation location = node->getLocation();
8
unsigned line = source_manager.getSpellingLineNumber(location);
9
unsigned column = source_manager.getSpellingColumnNumber(location);
10
11
if (!node->isAnonymousStructOrUnion() && !name.empty()) {
12
m_annotator->insert_annotation("class-name", line, column, name.length());
13
}
14
15
return true;
16
}

Clang provides the isAnonymousStructOrUnion() check to help us exclude anonymous classes from being annotated. This removes faulty [[class-name,]]struct { ... } annotations that would have otherwise been inserted.

With this visitor implemented, the tool properly annotates class, struct, and union declarations. This inserts a class-name annotation for each type definition:

text
 
#include <cmath> // std::sqrt
 
+
struct [[class-name,Vector3]] {
 
static const Vector3 zero;
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
 
float x;
 
float y;
 
float z;
 
};
 
 
const Vector3 Vector3::zero = Vector3();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Member variable declarations

Member variable declarations like the x, y, and z fields of our Vector3 class are represented by FieldDecl nodes. The implementation is similar to our previous visitors:

cpp
1
bool Visitor::VisitFieldDecl(clang::FieldDecl* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
const std::string& name = node->getNameAsString();
6
unsigned line = source_manager.getSpellingLineNumber(source_location);
7
unsigned column = source_manager.getSpellingColumnNumber(source_location);
8
9
m_annotator->insert_annotation("member-variable", line, column, name.length());
10
return true;
11
}

With this visitor in place, a member-variable annotation is inserted for each member variable declaration.

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
 
static const Vector3 zero;
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
+
float [[member-variable,x]];
+
float [[member-variable,y]];
+
float [[member-variable,z]];
 
};
 
 
const Vector3 Vector3::zero = Vector3();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Member variable references

The x, y, and z references inside the length() function are captured as MemberExpr nodes. Similar to member variable declarations, these identifiers benefit significantly from semantic highlighting as they’re often indistinguishable from local variables or function parameters without additional context.

cpp
1
bool Visitor::VisitMemberExpr(clang::MemberExpr* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
const clang::ValueDecl* member = node->getMemberDecl();
6
std::string name = member->getNameAsString();
7
8
clang::SourceLocation location = node->getMemberLoc();
9
unsigned line = source_manager.getSpellingLineNumber(location);
10
unsigned column = source_manager.getSpellingColumnNumber(location);
11
12
m_annotator->insert_annotation("member-variable", line, column, name.length());
13
return true;
14
}

The name of the member is retrieved from the underlying declaration using getMemberDecl(). The getMemberLoc() function returns the location of the member name, accounting for access operators like . and ->.

References to member variables are annotated with the member-variable annotation.

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
 
static const Vector3 zero;
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
+
return std::sqrt([[member-variable,x]] * [[member-variable,x]] + [[member-variable,y]] * [[member-variable,y]] + [[member-variable,z]] * [[member-variable,z]]);
 
}
 
 
float x;
 
float y;
 
float z;
 
};
 
 
const Vector3 Vector3::zero = Vector3();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Constructors and initializer lists

Not all references to member variables are handled by the VisitMemberExpr function - the x, y, and z references in the constructor initializer list remain unhandled. This happens because initializer list entries are represented by CXXCtorInitializer nodes, and not MemberExpr. However, CXXCtorInitializer isn’t a node that we can visit directly through the usual traversal of the AST. Instead, we need to access initializers as children of the parent CXXConstructorDecl node, which represents a constructor definition.

cpp
1
bool Visitor::VisitCXXConstructorDecl(clang::CXXConstructorDecl* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
// Skip implicit constructors
6
if (node->isImplicit()) {
7
return true;
8
}
9
10
for (const clang::CXXCtorInitializer* initializer : node->inits()) {
11
location = initializer->getSourceLocation();
12
unsigned line = source_manager.getSpellingLineNumber(location);
13
unsigned column = source_manager.getSpellingColumnNumber(location);
14
15
if (initializer->isMemberInitializer()) {
16
clang::FieldDecl* member = initializer->getMember();
17
std::string name = member->getNameAsString();
18
m_annotator->insert_annotation("member-variable", line, column, name.length());
19
}
20
}
21
22
return true;
23
}

The isImplicit() check is crucial here - compiler-generated constructors don’t exist in our source code, so attempting to annotate them will fail. We also skip base class initializers (for now), since those require different handling that we’ll address when annotating references to types.

Individual initializer expressions can be iterated over using the inits() function: The name of the member variable is retrieved from its declaration using getMember(). As before, initializers for member variables are annotated with member-variable.

This approach works well for typical class members, but fails to deal with anonymous structs or unions. Consider an improvement made to the Vector3 class to allow for representing RGB colors:

text
#include <cmath> // std::sqrt
struct Vector3 {
static const Vector3 zero;
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
~Vector3() { }
[[nodiscard]] float length() const {
return std::sqrt(x * x + y * y + z * z);
}
union {
// For access as coordinates
struct {
float x;
float y;
float z;
};
// For access as color components
struct {
float r;
float g;
float b;
};
};
};
const Vector3 Vector3::zero = Vector3();
int main() {
Vector3 zero = Vector3::zero;
// ...
}

In this case, members get promoted as part of the Vector3 definition. However, getMember() returns null for member variables that originate as members of an anonymous type. To get around this, we’ll introduce a new collect_members() function that collects the names of all available members of a class:

cpp
1
void collect_members(const clang::CXXRecordDecl* record, std::unordered_set<st::string>& members) {
2
if (!record) {
3
return;
4
}
5
6
for (const clang::FieldDecl* field : record->fields()) {
7
if (field->isAnonymousStructOrUnion()) {
8
const clang::CXXRecordDecl* nested = field->getType()->getAsCXXRecordDecl();
9
collect_members(nested, members);
10
}
11
else {
12
members.insert(field->getNameAsString());
13
}
14
}
15
}

This function recurses over all nested type definitions, gathering both explicit members and those implicitly promoted through anonymous structs or unions. We’ll update VisitCXXConstructorDecl to use this when annotating member initializers:

cpp
1 
bool Visitor::VisitCXXConstructorDecl(clang::CXXConstructorDecl* node) {
2 
// Check to ensure this node originates from the file we are annotating
3 
// ...
4 
5 
// Skip implicit constructors
6 
if (node->isImplicit()) {
7 
return true;
8 
}
9 
10+
clang::DeclContext* context = node->getDeclContext();
11+
while (context && !clang::[function,dyn_cast]]<clang::CXXRecordDecl>(context)) {
12+
context = context->getParent();
13+
}
14+
15+
if (!context) {
16+
// Unable to find enclosing class declaration
17+
return true;
18+
}
19+
20+
clang::CXXRecordDecl* parent = clang::[function,dyn_cast]]<clang::CXXRecordDecl>(context);
21+
std::unordered_set<st::string> members;
22+
collect_members(parent, members);
23 
24 
for (const clang::CXXCtorInitializer* initializer : node->inits()) {
25 
location = initializer->getSourceLocation();
26 
unsigned line = source_manager.getSpellingLineNumber(location);
27 
unsigned column = source_manager.getSpellingColumnNumber(location);
28 
29 
if (initializer->isMemberInitializer()) {
30 
clang::FieldDecl* member = initializer->getMember();
31 
std::string name = member->getNameAsString();
32 
m_annotator->insert_annotation("member-variable", line, column, name.length());
33 
}
34+
else if (initializer->isIndirectMemberInitializer()) {
35+
std::string name = m_tokenizer->get_tokens(initializer->getSourceRange())[0].spelling;
36+
if (members.contains(name)) {
37+
m_annotator->insert_annotation("member-variable", line, column, name.length());
38+
}
39+
}
40 
}
41 
42 
return true;
43 
}

The collect_members() function is called with the declaration of the enclosing class. This is retrieved by walking up the declaration hierarchy of a given AST node, accessed through the node’s DeclContext chain. The next parent is accessed through the getParent() function. Initializers for members that originate from an anonymous context are identified with the isIndirectMemberInitializer() check. Instead of getMember(), we’ll retrieve the name of the member through direct tokenization, taking advantage of the fact that the first token in the initializer’s source range will always be the name of the member being initialized.

Annotating the name of the constructor was already done in the FunctionDecl visitor, so we don’t need to do any additional processing here.

With this visitor implemented, both direct member initializations and promoted member initializations are properly annotated in constructor initializer lists:

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
 
static const Vector3 zero;
 
+
Vector3() : [[member-variable,x]](0.0f), [[member-variable,y]](0.0f), [[member-variable,z]](0.0f) { }
+
Vector3(float x, float y, float z) : [[member-variable,x]](x), [[member-variable,y]](y), [[member-variable,z]](z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
 
union {
 
// For access as coordinates
 
struct {
 
float x;
 
float y;
 
float z;
 
};
 
 
// For access as color components
 
struct {
 
float r;
 
float g;
 
float b;
 
};
 
};
 
};
 
 
const Vector3 Vector3::zero = Vector3();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Static class member declarations

Static class members present a unique challenge for syntax highlighting - they are declared within the class but often require separate definitions outside of it. Both scenarios are captured by VarDecl nodes, but we need to distinguish them from regular variable declarations.

Let’s augment our existing VisitVarDecl implementation from a previous post to handle static class members:

cpp
1
bool Visitor::VisitVarDecl(clang::VarDecl* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
const std::string& name = node->getNameAsString();
6
7
clang::SourceLocation location = node->getLocation();
8
unsigned line = source_manager.getSpellingLineNumber(location);
9
unsigned column = source_manager.getSpellingColumnNumber(location);
10
11
if (node->isStaticDataMember()) {
12
m_annotator->insert_annotation("member-variable", line, column, name.length());
13
}
14
else if (node->isDirectInit()) {
15
m_annotator->insert_annotation("plain", line, column, name.length());
16
}
17
18
return true;
19
}

Static class members are annotated with member-variable, just as instance member variables. The isStaticDataMember() check ensures that we only apply the annotation to static class member declarations. With this visitor implemented, here is what our example now looks like:

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
+
static const Vector3 [[member-variable,zero]];
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
 
float x;
 
float y;
 
float z;
 
};
 
 
// Const static class members must be initialized out of line
 
const Vector3 Vector3::zero = Vector3();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Static class member references

Similar to enumeration values from an earlier post, references to static class members are captured by DeclRefExpr nodes. Let’s augment our existing VisitDeclRefExpr visitor to also handle static class members:

References to static class members are caught under DeclRefExpr nodes. We have an existing definition for this visitor from when we annotated enum constants:

cpp
1 
bool Visitor::VisitDeclRefExpr(clang::DeclRefExpr* node) {
2 
// Check to ensure this node originates from the file we are annotating
3 
// ...
4 
5 
const clang::SourceLocation& location = node->getLocation();
6 
unsigned line = source_manager.getSpellingLineNumber(location);
7 
unsigned column = source_manager.getSpellingColumnNumber(location);
8 
9 
if (clang::ValueDecl* decl = node->getDecl()) {
10 
const std::string& name = decl->getNameAsString();
11+
bool is_member_variable = decl->isCXXClassMember();
12+
bool is_static = !decl->isCXXInstanceMember();
13 
14 
if (const clang::EnumConstantDecl* ec = clang::dyn_cast<clang::EnumConstantDecl>(decl)) {
15 
m_annotator->insert_annotation("enum-value", line, column, name.length());
16 
}
17+
else if (is_member_variable && is_static && !decl->isFunctionOrFunctionTemplate()) {
18+
m_annotator->insert_annotation("member-variable", line, column, name.length());
19+
}
20 
}
21 
22 
return true;
23 
}

As before, we retrieve information about the underlying declaration with getDecl(). With a combination of the isCXXClassMember(), isCXXInstanceMember(), and isFunctionOrFunctionTemplate() checks, we can isolate only references to static members variables. As before, these are annotated with the member-variable tag.

This logic needs to come after the check for enum constants to avoid annotating unscoped enum members as member variables.

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
 
static const Vector3 zero;
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
 
float x;
 
float y;
 
float z;
 
};
 
 
// Const static class members must be initialized out of line
+
const Vector3 Vector3::[[member-variable,zero]] = Vector3();
 
 
int main() {
+
Vector3 zero = Vector3::[[member-variable,zero]];
 
// ...
 
}

Temporary objects

The final node type we need to handle is CXXTemporaryObjectExpr, which represents the construction of temporary objects. In the example we’ve been using throughout this post, this applies to the definition of the Vector3::zero static class member.

Generally speaking, these nodes appear in a variety of contexts, such as:

  • Direct temporary construction
cpp
Vector3 v = Vector3(...);
  • Passing a temporary as a function argument
cpp
foo(Vector3(...));
  • Returning a temporary from a function
cpp
return Vector3(...);

Despite appearing as constructor calls, all of these are represented by CXXTemporaryObjectExpr nodes and do not generate CallExpr nodes as one might expect. Without a dedicated visitor, references to these constructors would go unannotated.

The implementation of the VisitCXXTemporaryObjectExpr visitor is straightforward:

cpp
1
bool Visitor::VisitCXXTemporaryObjectExpr(clang::CXXTemporaryObjectExpr* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
if (clang::CXXConstructorDecl* constructor = node->getConstructor()) {
6
for (const Token& token : m_tokenizer->get_tokens(node->getSourceRange())) {
7
if (token.spellin == constructor->getNameAsString() && !node->[[functionisListInitialization()) {
8
m_annotator->insert_annotation("function", token.[[member-variable,line]], token.column, token.spelling.length());
9
}
10
}
11
12
visit_qualifiers(constructor->getDeclContext(), node->getSourceRange());
13
}
14
15
return true;
16
}

We retrieve the type name of the object being constructed from the CXXConstructorDecl associated with the expression. As with other function calls, constructor calls are annotated using the function tag. The isListInitialization() check ensures we skip brace-initialized constructors like Vector3 { }, as those should instead be annotated as types. We’ll handle this in a later post.

With this visitor in place, temporary constructor calls are properly annotated:

text
 
#include <cmath> // std::sqrt
 
 
struct Vector3 {
 
static const Vector3 zero;
 
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
[[nodiscard]] float length() const {
 
return std::sqrt(x * x + y * y + z * z);
 
}
 
 
float x;
 
float y;
 
float z;
 
};
 
 
// Const static class members must be initialized out of line
+
const Vector3 Vector3::zero = [[function,Vector3]]();
 
 
int main() {
 
Vector3 zero = Vector3::zero;
 
// ...
 
}

Styling

The final step is to add definitions for the class-name and member-variable CSS styles:

css
.language-cpp .member-variable {
color: rgb(152, 118, 170);
}
.language-cpp .class-name {
color: rgb(181, 182, 227);
}
cpp
#include <cmath> // std::sqrt
struct Vector3 {
static const Vector3 zero;
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
~Vector3() { }
[[nodiscard]] float length() const {
return std::sqrt(x * x + y * y + z * z);
}
float x;
float y;
float z;
};
// Const class static members must be initialized out of line
const Vector3 Vector3::zero = Vector3();
int main() {
Vector3 zero = Vector3::zero;
// ...
}

Type aliases

Type aliases are loosely coupled with classes, so we’ll cover them in this section. typedef declarations are represented by TypedefDecl nodes, while using declarations are represented by TypeAliasDecl nodes. Functionally, both constructs serve the same purpose: defining an alias for an existing type.

For example, we can extend our example from earlier even further and allow our users to reference members of the Vector3 struct through different type aliases altogether:

text
struct Vector3 {
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
~Vector3() { }
union {
// For access as coordinates
struct {
float x;
float y;
float z;
};
// For access as color components
struct {
float r;
float g;
float b;
};
};
};
// Type aliases
typedef Vector3 Color;
using Position = Vector3;

Although typedef and using are represented by different AST nodes, both are annotated in the same way. For this reason, only the implementation of VisitTypedefDecl is shown below:

cpp
1
bool Visitor::VisitTypedefDecl(clang::TypedefDecl* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
const std::string& name = node->getNameAsString();
6
7
const clang::SourceLocation& location = node->getLocation();
8
unsigned line = source_manager.getSpellingLineNumber(location);
9
unsigned column = source_manager.getSpellingColumnNumber(location);
10
11
m_annotator->insert_annotation("class-name", line, column, name.length());
12
return true;
13
}

Type aliases are annotated with the class-name tag. The implementation of VisitTypeAliasDecl is identical and omitted for brevity, but can be found here.

With both visitors implemented, typedef and using declarations are properly annotated:

text
 
struct Vector3 {
 
Vector3() : x(0.0f), y(0.0f), z(0.0f) { }
 
Vector3(float x, float y, float z) : x(x), y(y), z(z) { }
 
~Vector3() { }
 
 
union {
 
// For access as coordinates
 
struct {
 
float x;
 
float y;
 
float z;
 
};
 
 
// For access as color components
 
struct {
 
float r;
 
float g;
 
float b;
 
};
 
};
 
};
 
 
// Type aliases
+
typedef Vector3 [[class-name,Color]];
+
using [[class-name,Position]] = Vector3;

We’ve added support for annotating class declarations, static and class member variable declarations and references, constructor initializer lists, and type aliases. In the <LocalLink text={“next post”} to={“Better C++ Syntax Highlighting - Part 6: Templates”}>, we’ll take a closer look at annotating classes, functions, and parameters in template contexts. We will also revisit some of our existing visitors and add support for C++20 concepts.

Thanks for reading!

Series: Better C++ Syntax Highlighting - Part 5 of 10