Better C++ Syntax Highlighting - Part 6: Templates

· Updated Jul 14, 2025

Templates and concepts make up a huge part of modern C++. In this post, we’ll cover how to annotate template declarations, template parameters, and C++20 concepts.

Consider the following example:

cpp
template <typename T>
void print(const T& value);
template <typename ...Ts>
void print(const Ts&... values);
template <typename T>
struct Foo {
// ...
};
template <typename ...Ts>
struct Bar {
// ...
};

And corresponding AST:

text
|-FunctionTemplateDecl 0x29b40467068 <example.cpp:1:1, line:2:26> col:6 print
| |-TemplateTypeParmDecl 0x29b3eb87268 <line:1:11, col:20> col:20 referenced typename depth 0 index 0 T
| `-FunctionDecl 0x29b40466fb8 <line:2:1, col:26> col:6 print 'void (const T &)'
| `-ParmVarDecl 0x29b40466e88 <col:12, col:21> col:21 value 'const T &'
|-FunctionTemplateDecl 0x29b40467528 <line:4:1, line:5:31> col:6 print
| |-TemplateTypeParmDecl 0x29b404671a0 <line:4:11, col:23> col:23 referenced typename depth 0 index 0 ... Ts
| `-FunctionDecl 0x29b40467478 <line:5:1, col:31> col:6 print 'void (const Ts &...)'
| `-ParmVarDecl 0x29b40467350 <col:12, col:25> col:25 values 'const Ts &...' pack
|-ClassTemplateDecl 0x29b40467778 <line:7:1, line:10:1> line:8:8 Foo
| |-TemplateTypeParmDecl 0x29b40467620 <line:7:11, col:20> col:20 typename depth 0 index 0 T
| `-CXXRecordDecl 0x29b404676c8 <line:8:1, line:10:1> line:8:8 struct Foo definition
| |-DefinitionData empty aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
| | |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
| | |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveConstructor exists simple trivial needs_implicit
| | |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveAssignment exists simple trivial needs_implicit
| | `-Destructor simple irrelevant trivial constexpr needs_implicit
| `-CXXRecordDecl 0x29b40467a30 <col:1, col:8> col:8 implicit struct Foo
`-ClassTemplateDecl 0x29b40467c58 <line:12:1, line:15:1> line:13:8 Bar
|-TemplateTypeParmDecl 0x29b40467af8 <line:12:11, col:23> col:23 typename depth 0 index 0 ... Ts
`-CXXRecordDecl 0x29b40467ba8 <line:13:1, line:15:1> line:13:8 struct Bar definition
|-DefinitionData empty aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
| |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
| |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
| |-MoveConstructor exists simple trivial needs_implicit
| |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
| |-MoveAssignment exists simple trivial needs_implicit
| `-Destructor simple irrelevant trivial constexpr needs_implicit
`-CXXRecordDecl 0x29b4045f850 <col:1, col:8> col:8 implicit struct Bar

Template declarations

Template declarations are represented by several node types:

We don’t actually need to define new visitors for the template class declarations themselves. Each ClassTemplateDecl, ClassTemplatePartialSpecializationDecl, and ClassTemplateSpecializationDecl node contains a nested CXXRecordDecl representing the underlying class. This is already handled by our existing VisitCXXRecordDecl visitor.

However, we do need to annotate template parameters, which are represented by TemplateTypeParmDecl nodes. The visitor signature looks like this:

cpp
bool VisitTemplateTypeParmDecl(clang::TemplateTypeParmDecl* node);

And implementation:

cpp
1
bool Visitor::VisitTemplateTypeParmDecl(clang::TemplateTypeParmDecl* 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
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
}
14
15
bool Visitor::VisitTemplateTypeParmDecl(clang::TemplateTypeParmDecl* node) {
16
// Check to ensure this node originates from the file we are annotating
17
// ...
18
19
std::string name = node->getNameAsString();
20
21
clang::SourceLocation location = node->getLocation();
22
unsigned line = source_manager.getSpellingLineNumber(location);
23
unsigned column = source_manager.getSpellingColumnNumber(location);
24
25
m_annotator->insert_annotation("class-name", line, column, name.length());
26
return true;
27
}

Template parameters represent types and are annotated with class-name. This works for both template functions and class declarations:

text
template <typename [[class-name,T]]>
void print(const [[class-name,T]]& value);
template <typename ...[[class-name,Ts]]>
void print(const [[class-name,Ts]]&... values);
template <typename [[class-name,T]]>
struct Foo {
// ...
};
template <typename ...[[class-name,Ts]]>
struct Bar {
// ...
};

Concepts

C++20 introduced concepts for constraining template parameters.

Consider the following example:

text
#include <concepts> // std::same_as
#include <iterator> // std::begin, std::end
template <typename T>
concept Iterable = requires(T container) {
{ std::begin(container) } -> std::same_as<decltype(std::end(container))>;
{ *std::begin(container) };
{ ++std::begin(container) } -> std::same_as<decltype(std::begin(container))&>;
};
template <typename T>
concept Container = Iterable<T> && requires(T container, std::size_t index) {
{ container.size() };
{ container.capacity() };
typename T::value_type;
};
template <Container T>
void print(const T& container) {
// ...
}

And corresponding AST:

text
Show 54 more lines

Concept-related declarations and expressions are represented by several node types:

We’ll need to annotate both the concept declarations and any uses of concepts as constraints within these nodes.

Concept declarations

Concept declarations are represented by ConceptDecl nodes.

We’ll annotate concept declarations and any uses of concepts as constraints using the same structure we’ve seen for other visitors:

cpp
1
bool Visitor::VisitConceptDecl(clang::ConceptDecl* 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("concept", line, column, name.length());
12
return true;
13
}

Concept declarations are annotated with the concept tag:

text
 
#include <concepts> // std::same_as
 
#include <iterator> // std::begin, std::end
 
 
template <typename T>
+
concept [[concept,Iterable]] = requires(T container) {
 
{ std::begin(container) } -> std::same_as<decltype(std::end(container))>;
 
{ *std::begin(container) };
 
{ ++std::begin(container) } -> std::same_as<decltype(std::begin(container))&>;
 
};
 
 
template <typename T>
+
concept [[concept,Container]] = Iterable<T> && requires(T container, std::size_t index) {
 
{ container.size() };
 
{ container.capacity() };
 
typename T::value_type;
 
};
 
 
template <Container T>
 
void print(const T& container) {
 
// ...
 
}

Concept specializations

Constraints on concept definitions are captured by ConceptSpecializationExpr nodes. Examples of these are the Iterable requirement on the Container concept definition and the Container specialization on the print() function. The visitor for this node is straightforward:

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

The location at which to insert the annotation is retrieved via the handy getConceptNameLoc() function. The name of the concept itself is retrieved from the declaration using getNamedConcept(). As with concept declarations, concepts in specialization expressions are annotated with the concept tag:

text
 
#include <concepts> // std::same_as
 
#include <iterator> // std::begin, std::end
 
 
template <typename T>
 
concept Iterable = requires(T container) {
 
{ std::begin(container) } -> std::same_as<decltype(std::end(container))>;
 
{ *std::begin(container) };
 
{ ++std::begin(container) } -> std::same_as<decltype(std::begin(container))&>;
 
};
 
 
template <typename T>
+
concept Container = [[concept,Iterable]]<T> && requires(T container, std::size_t index) {
 
{ container.size() };
 
{ container.capacity() };
 
typename T::value_type;
 
};
 
+
template <[[concept,Container]] T>
 
void print(const T& container) {
 
// ...
 
}

requires expressions

For annotating concepts in type constraint expressions, such as the std::same_as concept in the above example, we’ll need to implement a visitor for RequiresExpr nodes:

cpp
1
bool Visitor::VisitRequiresExpr(clang::RequiresExpr* node) {
2
// Check to ensure this node originates from the file we are annotating
3
// ...
4
5
for (const clang::concepts::Requirement* r : node->getRequirements()) {
6
if (const clang::concepts::ExprRequirement* er = clang::dyn_cast<clang::concepts::ExprRequirement>(r)) {
7
const clang::concepts::ExprRequirement::ReturnTypeRequirement& rtr = er->getReturnTypeRequirement();
8
if (rtr.isTypeConstraint()) {
9
const clang::TypeConstraint* constraint = rtr.getTypeConstraint();
10
std::string name = constraint->getNamedConcept()->getNameAsString();
11
12
location = constraint->getConceptNameLoc();
13
unsigned line = source_manager.getSpellingLineNumber(location);
14
unsigned column = source_manager.getSpellingColumnNumber(location);
15
m_annotator->insert_annotation("concept", line, column, name.length());
16
}
17
}
18
}
19
20
return true;
21
}

These nodes represent the body of a requires clause or expression.

This visitor is a bit more involved than others in this section. We’ll use the getRequirements() function to iterate over each constraint in the requires clause. For each requirement, we check whether it is an ExprRequirement, which is a constraint on an expression that may include a return type requirement. If the expression includes a type constraint (e.g. -> std::same_as<decltype(std::end(container))>) we extract it using getReturnTypeRequirement().getTypeConstraint(). From there, we retrieve the associated concept’s name and source location as in the previous section using getNamedConcept() and getConceptNameLoc(). Concept references in type constraint expressions are also annotated as concepts:

text
 
#include <concepts> // std::same_as
 
#include <iterator> // std::begin, std::end
 
 
template <typename T>
 
concept Iterable = requires(T container) {
+
{ std::begin(container) } -> std::[[concept,same_as]]<decltype(std::end(container))>;
 
{ *std::begin(container) };
+
{ ++std::begin(container) } -> std::[[concept,same_as]]<decltype(std::begin(container))&>;
 
};
 
 
template <typename T>
 
concept Container = Iterable<T> && requires(T container, std::size_t index) {
 
{ container.size() };
 
{ container.capacity() };
 
typename T::value_type;
 
};
 
 
template <Container T>
 
void print(const T& container) {
 
// ...
 
}

Dependent calls and members

In templates and concepts, unresolved expressions cannot always be fully resolved during parsing due to the dependency on an unknown type T. These expressions are represented by:

These often appear as child nodes of a CallExpr. For unresolved calls, getCalleeDecl() returns nullptr, which causes issues with our existing VisitCallExpr implementation. Good examples of this are the std::begin(...) and std::end(...) functions - the function this call resolves to differs based on the type of container that is passed in. We can handle these nodes by extending our existing VisitCallExpr visitor to explicitly account for these types of expressions.

We’ll start off by doing some dynamic casting to determine the kind of function call being processed:

cpp
1 
bool Visitor::VisitCallExpr(clang::CallExpr* node) {
2 
// Check to ensure this node originates from the file we are annotating
3 
// ...
4 
5+
if (const clang::UnresolvedLookupExpr* ule = clang::dyn_cast<clang::UnresolvedLookupExpr>(node->getCallee())) {
6+
clang::SourceLocation location = ule->getNameLoc();
7+
const Token& token = *m_tokenizer->at(location);
8+
m_annotator->insert_annotation("function", token.line, token.column, token.spelling.length());
9+
}
10 
else {
11 
std::span<const Token> tokens = m_tokenizer->get_tokens(node->getSourceRange());
12 
for (const Token& token : tokens) {
13 
if (token.spelling == name) {
14 
m_annotator->insert_annotation("function", token.line, token.column, name.length());
15 
break;
16 
}
17 
}
18 
}
19 
20 
return true;
21 
}

We retrieve the callee with the getCallee() function. Instead of accessing the name of the function through its declaration, we’ll instead annotate just the token at the source location of the call. For UnresolvedLookupExpr nodes, this is done via the getNameLoc() function. Retrieving the name through the function declaration may not always return the same name as the function used at the call site. The function name for a custom dereference operator, for example, returns operator* (and not the expected * of the actual call).

Member calls like T::function() appear as DependentScopeDeclRefExpr nodes. DependentScopeDeclRefExpr represents a qualified reference to a member of a dependent type, where the type of T is unknown during parsing. For example, in templates where T::value_type or T::function() is written, Clang cannot resolve whether this refers to a type, member, or function until the template is instantiated.

Within a CallExpr, however, if the callee is a DependentScopeDeclRefExpr, we know it’s being invoked as a function, making it safe to annotate:

cpp
1 
bool Visitor::VisitCallExpr(clang::CallExpr* node) {
2 
// Check to ensure this node originates from the file we are annotating
3 
// ...
4 
5 
if (const clang::UnresolvedLookupExpr* ule = clang::dyn_cast<clang::UnresolvedLookupExpr>(node->getCallee())) {
6 
// ...
7+
}
8+
else if (const clang::DependentScopeDeclRefExpr* dre = clang::dyn_cast<clang::DependentScopeDeclRefExpr>(node->getCallee())) {
9+
clang::SourceLocation location = dre->getLocation();
10+
const Token& token = *m_tokenizer->at(location);
11+
m_annotator->insert_annotation("function", token.line, token.column, token.spelling.length());
12+
}
13 
else {
14 
// ...
15 
}
16 
17 
return true;
18 
}

We’ll use a similar approach here and annotate just the token at the call location. DependentScopeDeclRefExpr nodes outside of function calls are purposefully left unhandled to avoid ambiguity. The annotation for a type is different from the annotation for a member.

Direct member function calls

Function calls made directly through an object or reference, such as container.size() or container.capacity() from the example above, are represented by CXXDependentScopeMemberExpr nodes. These nodes appear when the compiler cannot resolve the exact type of the object at parse time, but the syntax clearly indicates a member function call. We annotate these the same way as in the earlier sections:

cpp
1 
bool Visitor::VisitCallExpr(clang::CallExpr* node) {
2 
// Check to ensure this node originates from the file we are annotating
3 
// ...
4 
5 
if (const clang::UnresolvedLookupExpr* ule = clang::dyn_cast<clang::UnresolvedLookupExpr>(node->getCallee())) {
6 
// ...
7 
}
8 
else if (const clang::DependentScopeDeclRefExpr* dre = clang::dyn_cast<clang::DependentScopeDeclRefExpr>(node->getCallee())) {
9 
// ...
10 
}
11+
else if (const clang::CXXDependentScopeMemberExpr* dsme = clang::dyn_cast<clang::CXXDependentScopeMemberExpr>(node->getCallee())) {
12+
clang::SourceLocation location = dsme->getMemberLoc();
13+
const Token& token = *m_tokenizer->at(location);
14+
m_annotator->insert_annotation("function", token.line, token.column, token.spelling.length());
15+
}
16 
else {
17 
// ...
18 
}
19 
20 
return true;
21 
}

The source location of the call is retrieved using the getMemberLoc() function. For all other cases, we’ll fall back to our existing approach of annotating the function call using token matching.

As before, functions are annotated with the function tag:

text
 
#include <concepts> // std::same_as
 
#include <iterator> // std::begin, std::end
 
 
template <typename T>
 
concept Iterable = requires(T container) {
+
{ std::[[function,begin]](container) } -> std::same_as<decltype(std::[[function,end]](container))>;
+
{ *std::[[function,begin]](container) };
+
{ ++std::[[function,begin]](container) } -> std::same_as<decltype(std::[[function,begin]](container))&>;
 
};
 
 
template <typename T>
 
concept Container = Iterable<T> && requires(T container, std::size_t index) {
+
{ container.[[function,size]]() };
+
{ container.[[function,capacity]]() };
 
typename T::value_type;
 
};
 
 
template <Container T>
 
void print(const T& container) {
 
// ...
 
}

Dependent member access

The last node for this section is the CXXDependentScopeMemberExpr, which represents a member access where the referenced member cannot be fully resolved. In the previous section, we annotated this case for CallExpr expressions, which represented class member function calls. A standalone VisitCXXDependentScopeMemberExpr visitor catches dependent references to (non-static) class members:

cpp
1
bool Visitor::VisitCXXDependentScopeMemberExpr(clang::CXXDependentScopeMemberExpr* node) {
2
const clang::SourceManager& source_manager = m_context->getSourceManager();
3
clang::SourceLocation location = node->getMemberLoc();
4
5
if (!source_manager.isInMainFile(location)) {
6
return true;
7
}
8
9
const std::string& name = node->getMemberNameInfo().getAsString();
10
unsigned line = source_manager.getSpellingLineNumber(location);
11
unsigned column = source_manager.getSpellingColumnNumber(location);
12
13
m_annotator->insert_annotation("member-variable", line, column, name.length());
14
return true;
15
}

Dependent class members are annotated with member-variable.

For the sake of this example, let’s add a requirement that our Container needs to have a public data member:

text
 
#include <concepts> // std::same_as
 
#include <iterator> // std::begin, std::end
 
 
template <typename T>
 
concept Iterable = requires(T container) {
 
{ std::begin(container) } -> std::same_as<decltype(std::end(container))>;
 
{ *std::begin(container) };
 
{ ++std::begin(container) } -> std::same_as<decltype(std::begin(container))&>;
 
};
 
 
template <typename T>
 
concept Container = Iterable<T> && requires(T container, std::size_t index) {
 
{ container.size() };
 
{ container.capacity() };
+
{ container.[[member-variable,data]] };
 
typename T::value_type;
 
};
 
 
template <Container T>
 
void print(const T& container) {
 
// ...
 
}

Styling

The final step is to add a definition for the concept CSS style:

css
.language-cpp .concept {
color: rgb(181, 182, 227);
}

The other annotations already have existing CSS style implementations.

cpp
#include <concepts> // std::same_as
#include <iterator> // std::begin, std::end
template <typename T>
concept Container = requires(T container, std::size_t index) {
// Ensure that the container supports the std::begin and std::end methods
{ std::begin(container) } -> std::same_as<decltype(std::end(container))>;
// Ensure that the container iterator can be dereferenced
{ *std::begin(container) };
// Ensure that the container iterator can be incremented
{ ++std::begin(container) } -> std::same_as<decltype(std::begin(container))>;
// Ensure that the container has a public 'data' member variable
{ container.data };
// Ensure that the container has 'size' and 'capacity' member functions
{ container.size() };
{ container.capacity() };
// Ensure that the container defines the necessary types
typename T::value_type;
// ...
};
// Concept-constrained function specialization for containers
template <Container T>
void print(const T& container);

In addition to annotating template classes, functions, and parameters, we’ve added support for annotating concept definitions, specializations, and references to functions and concepts in requires clauses. In the <LocalLink text={“next post”} to={“Better C++ Syntax Highlighting - Part 7: Types”}>, we’ll implement adding annotations for type references in variable declarations, function parameters and return values, template arguments, and more. Thanks for reading!

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