[clang-tools-extra] [clang-tidy] Add modernize-use-to-underlying check (PR #210459)
Adrian Vogelsgesang via cfe-commits
cfe-commits at lists.llvm.org
Fri Jul 17 16:55:04 PDT 2026
https://github.com/vogelsgesang updated https://github.com/llvm/llvm-project/pull/210459
>From 55dd3d3785c550bbebf727849c77359d854ff0d9 Mon Sep 17 00:00:00 2001
From: Adrian Vogelsgesang <avogelsgesang at salesforce.com>
Date: Fri, 17 Jul 2026 21:37:31 +0000
Subject: [PATCH 1/3] [clang-tidy] Add modernize-use-to-underlying check
Add a new check that flags casts from a scoped enumeration to an integer
type and replaces them with a call to std::to_underlying (C++23).
Addresses #71543.
Co-Authored-By: Claude Opus 4.8 <noreply at anthropic.com>
---
.../clang-tidy/modernize/CMakeLists.txt | 1 +
.../modernize/ModernizeTidyModule.cpp | 3 +
.../modernize/UseToUnderlyingCheck.cpp | 163 ++++++++++++++++++
.../modernize/UseToUnderlyingCheck.h | 60 +++++++
clang-tools-extra/docs/ReleaseNotes.rst | 6 +
.../docs/clang-tidy/checks/list.rst | 1 +
.../checks/modernize/use-to-underlying.rst | 105 +++++++++++
.../use-to-underlying-custom-function.cpp | 17 ++
.../use-to-underlying-imprecise-casts.cpp | 55 ++++++
.../checkers/modernize/use-to-underlying.cpp | 108 ++++++++++++
10 files changed, 519 insertions(+)
create mode 100644 clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.cpp
create mode 100644 clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.h
create mode 100644 clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
create mode 100644 clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-custom-function.cpp
create mode 100644 clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-imprecise-casts.cpp
create mode 100644 clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying.cpp
diff --git a/clang-tools-extra/clang-tidy/modernize/CMakeLists.txt b/clang-tools-extra/clang-tidy/modernize/CMakeLists.txt
index 2c5c44db587fe..23aeb393ad016 100644
--- a/clang-tools-extra/clang-tidy/modernize/CMakeLists.txt
+++ b/clang-tools-extra/clang-tidy/modernize/CMakeLists.txt
@@ -53,6 +53,7 @@ add_clang_library(clangTidyModernizeModule STATIC
UseStdPrintCheck.cpp
UseStringViewCheck.cpp
UseStructuredBindingCheck.cpp
+ UseToUnderlyingCheck.cpp
UseTrailingReturnTypeCheck.cpp
UseTransparentFunctorsCheck.cpp
UseUncaughtExceptionsCheck.cpp
diff --git a/clang-tools-extra/clang-tidy/modernize/ModernizeTidyModule.cpp b/clang-tools-extra/clang-tidy/modernize/ModernizeTidyModule.cpp
index cc13da7535bcb..62676469aefe7 100644
--- a/clang-tools-extra/clang-tidy/modernize/ModernizeTidyModule.cpp
+++ b/clang-tools-extra/clang-tidy/modernize/ModernizeTidyModule.cpp
@@ -53,6 +53,7 @@
#include "UseStdPrintCheck.h"
#include "UseStringViewCheck.h"
#include "UseStructuredBindingCheck.h"
+#include "UseToUnderlyingCheck.h"
#include "UseTrailingReturnTypeCheck.h"
#include "UseTransparentFunctorsCheck.h"
#include "UseUncaughtExceptionsCheck.h"
@@ -138,6 +139,8 @@ class ModernizeModule : public ClangTidyModule {
"modernize-use-string-view");
CheckFactories.registerCheck<UseStructuredBindingCheck>(
"modernize-use-structured-binding");
+ CheckFactories.registerCheck<UseToUnderlyingCheck>(
+ "modernize-use-to-underlying");
CheckFactories.registerCheck<UseTrailingReturnTypeCheck>(
"modernize-use-trailing-return-type");
CheckFactories.registerCheck<UseTransparentFunctorsCheck>(
diff --git a/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.cpp b/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.cpp
new file mode 100644
index 0000000000000..c60ea02e316f2
--- /dev/null
+++ b/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.cpp
@@ -0,0 +1,163 @@
+//===--- UseToUnderlyingCheck.cpp - clang-tidy ----------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#include "UseToUnderlyingCheck.h"
+#include "clang/AST/ASTContext.h"
+#include "clang/ASTMatchers/ASTMatchFinder.h"
+#include "clang/Lex/Lexer.h"
+
+using namespace clang::ast_matchers;
+
+namespace clang::tidy {
+
+template <>
+struct OptionEnumMapping<modernize::UseToUnderlyingCheck::ImpreciseCastsKind> {
+ static llvm::ArrayRef<
+ std::pair<modernize::UseToUnderlyingCheck::ImpreciseCastsKind, StringRef>>
+ getEnumMapping() {
+ using ImpreciseCastsKind =
+ modernize::UseToUnderlyingCheck::ImpreciseCastsKind;
+ static constexpr std::pair<ImpreciseCastsKind, StringRef> Mapping[] = {
+ {ImpreciseCastsKind::Ignore, "Ignore"},
+ {ImpreciseCastsKind::Warn, "Warn"},
+ {ImpreciseCastsKind::PreserveType, "PreserveType"},
+ {ImpreciseCastsKind::UseUnderlyingType, "UseUnderlyingType"},
+ };
+ return {Mapping};
+ }
+};
+
+} // namespace clang::tidy
+
+namespace clang::tidy::modernize {
+
+UseToUnderlyingCheck::UseToUnderlyingCheck(StringRef Name,
+ ClangTidyContext *Context)
+ : ClangTidyCheck(Name, Context),
+ ImpreciseCasts(Options.get("ImpreciseCasts", ImpreciseCastsKind::Warn)),
+ ReplacementFunction(
+ Options.get("ReplacementFunction", "std::to_underlying")),
+ IncludeInserter(Options.getLocalOrGlobal("IncludeStyle",
+ utils::IncludeSorter::IS_LLVM),
+ areDiagsSelfContained()),
+ MaybeHeaderToInclude(Options.get("ReplacementFunctionHeader")) {
+ if (!MaybeHeaderToInclude && ReplacementFunction == "std::to_underlying")
+ MaybeHeaderToInclude = "<utility>";
+}
+
+bool UseToUnderlyingCheck::isLanguageVersionSupported(
+ const LangOptions &LangOpts) const {
+ // std::to_underlying is a C++23 library facility, but a user-provided
+ // replacement (e.g. llvm::to_underlying) only requires scoped enumerations,
+ // which are available since C++11.
+ if (ReplacementFunction == "std::to_underlying")
+ return LangOpts.CPlusPlus23;
+ return LangOpts.CPlusPlus11;
+}
+
+void UseToUnderlyingCheck::registerPPCallbacks(const SourceManager &SM,
+ Preprocessor *PP,
+ Preprocessor *ModuleExpanderPP) {
+ IncludeInserter.registerPreprocessor(PP);
+}
+
+void UseToUnderlyingCheck::storeOptions(ClangTidyOptions::OptionMap &Opts) {
+ Options.store(Opts, "ImpreciseCasts", ImpreciseCasts);
+ Options.store(Opts, "ReplacementFunction", ReplacementFunction);
+ Options.store(Opts, "IncludeStyle", IncludeInserter.getStyle());
+ if (MaybeHeaderToInclude)
+ Options.store(Opts, "ReplacementFunctionHeader", *MaybeHeaderToInclude);
+}
+
+void UseToUnderlyingCheck::registerMatchers(MatchFinder *Finder) {
+ // Match an explicit cast (``static_cast``, C-style or functional) whose
+ // operand is an expression of scoped-enumeration type. The destination type
+ // is validated in check() so that the same code path handles precise and
+ // imprecise conversions.
+ Finder->addMatcher(
+ explicitCastExpr(
+ unless(isInTemplateInstantiation()),
+ hasSourceExpression(
+ expr(hasType(hasCanonicalType(enumType(
+ hasDeclaration(enumDecl(isScoped()).bind("enum"))))))
+ .bind("operand")))
+ .bind("cast"),
+ this);
+}
+
+void UseToUnderlyingCheck::check(const MatchFinder::MatchResult &Result) {
+ const auto *Cast = Result.Nodes.getNodeAs<ExplicitCastExpr>("cast");
+ const auto *Operand = Result.Nodes.getNodeAs<Expr>("operand");
+ const auto *Enum = Result.Nodes.getNodeAs<EnumDecl>("enum");
+
+ // Ignore dependent contexts we cannot reason about reliably.
+ if (Cast->isValueDependent() || Cast->getType()->isDependentType())
+ return;
+
+ const QualType DestType = Cast->getType().getCanonicalType();
+
+ // We only care about conversions to an integer type. Enum-to-enum casts and
+ // floating-point or pointer destinations are excluded.
+ if (!DestType->isIntegerType() || DestType->isEnumeralType())
+ return;
+
+ const QualType UnderlyingType =
+ Enum->getIntegerType().getCanonicalType().getUnqualifiedType();
+ const QualType DestUnqualified = DestType.getUnqualifiedType();
+ const bool IsPrecise = UnderlyingType == DestUnqualified;
+
+ // A cast to ``bool`` that is not precise (i.e. the underlying type is not
+ // ``bool``) expresses a truthiness test rather than a request for the
+ // underlying value, so it is left untouched.
+ if (DestType->isBooleanType() && !IsPrecise)
+ return;
+
+ // Ignore imprecise casts if asked to do so.
+ if (!IsPrecise && ImpreciseCasts == ImpreciseCastsKind::Ignore)
+ return;
+
+ auto Diag = diag(Cast->getBeginLoc(),
+ "use '%0' to convert a scoped enumeration to its "
+ "underlying type")
+ << ReplacementFunction;
+
+ // In Warn mode an imprecise cast is diagnosed without a fix-it
+ if (!IsPrecise && ImpreciseCasts == ImpreciseCastsKind::Warn)
+ return;
+
+ const StringRef OperandText = Lexer::getSourceText(
+ CharSourceRange::getTokenRange(Operand->getSourceRange()),
+ *Result.SourceManager, getLangOpts());
+ if (OperandText.empty())
+ return;
+
+ const std::string Call =
+ (ReplacementFunction + "(" + OperandText + ")").str();
+
+ const bool ReplaceWholeCast =
+ IsPrecise || ImpreciseCasts == ImpreciseCastsKind::UseUnderlyingType;
+ if (ReplaceWholeCast) {
+ // Replace the whole cast: ``static_cast<int>(e)`` -> ``to_underlying(e)``.
+ // For an imprecise cast this changes the resulting type to the underlying
+ // type.
+ Diag << FixItHint::CreateReplacement(Cast->getSourceRange(), Call);
+ } else {
+ // Preserve the intended (wider or differently-signed) destination type by
+ // wrapping only the operand: ``static_cast<long>(e)`` ->
+ // ``static_cast<long>(to_underlying(e))``.
+ Diag << FixItHint::CreateReplacement(Operand->getSourceRange(), Call);
+ }
+
+ if (MaybeHeaderToInclude)
+ Diag << IncludeInserter.createIncludeInsertion(
+ Result.SourceManager->getFileID(
+ Result.SourceManager->getExpansionLoc(Cast->getBeginLoc())),
+ *MaybeHeaderToInclude);
+}
+
+} // namespace clang::tidy::modernize
diff --git a/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.h b/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.h
new file mode 100644
index 0000000000000..1692978af4ddd
--- /dev/null
+++ b/clang-tools-extra/clang-tidy/modernize/UseToUnderlyingCheck.h
@@ -0,0 +1,60 @@
+//===--- UseToUnderlyingCheck.h - clang-tidy --------------------*- C++ -*-===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_MODERNIZE_USETOUNDERLYINGCHECK_H
+#define LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_MODERNIZE_USETOUNDERLYINGCHECK_H
+
+#include "../ClangTidyCheck.h"
+#include "../utils/IncludeInserter.h"
+#include <optional>
+
+namespace clang::tidy::modernize {
+
+/// Finds casts from a scoped enumeration to its underlying integer type and
+/// replaces them with a call to ``std::to_underlying`` (C++23).
+///
+/// For the user-facing documentation see:
+/// https://clang.llvm.org/extra/clang-tidy/checks/modernize/use-to-underlying.html
+class UseToUnderlyingCheck : public ClangTidyCheck {
+public:
+ /// How to treat an imprecise cast, i.e. a cast whose destination type is an
+ /// integer type other than the enumeration's underlying type.
+ enum class ImpreciseCastsKind {
+ /// Do not diagnose imprecise casts.
+ Ignore,
+ /// Diagnose imprecise casts but do not offer a fix-it.
+ Warn,
+ /// Wrap the operand in a call to the replacement function, preserving the
+ /// original destination type.
+ PreserveType,
+ /// Replace the whole cast with a call to the replacement function, changing
+ /// the resulting type to the underlying type.
+ UseUnderlyingType,
+ };
+
+ UseToUnderlyingCheck(StringRef Name, ClangTidyContext *Context);
+ bool isLanguageVersionSupported(const LangOptions &LangOpts) const override;
+ void registerPPCallbacks(const SourceManager &SM, Preprocessor *PP,
+ Preprocessor *ModuleExpanderPP) override;
+ void storeOptions(ClangTidyOptions::OptionMap &Opts) override;
+ void registerMatchers(ast_matchers::MatchFinder *Finder) override;
+ void check(const ast_matchers::MatchFinder::MatchResult &Result) override;
+ std::optional<TraversalKind> getCheckTraversalKind() const override {
+ return TK_IgnoreUnlessSpelledInSource;
+ }
+
+private:
+ const ImpreciseCastsKind ImpreciseCasts;
+ const StringRef ReplacementFunction;
+ utils::IncludeInserter IncludeInserter;
+ std::optional<StringRef> MaybeHeaderToInclude;
+};
+
+} // namespace clang::tidy::modernize
+
+#endif // LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_MODERNIZE_USETOUNDERLYINGCHECK_H
diff --git a/clang-tools-extra/docs/ReleaseNotes.rst b/clang-tools-extra/docs/ReleaseNotes.rst
index 69c3bcf67b8db..6e5ceb3fe96f4 100644
--- a/clang-tools-extra/docs/ReleaseNotes.rst
+++ b/clang-tools-extra/docs/ReleaseNotes.rst
@@ -97,6 +97,12 @@ Improvements to clang-tidy
New checks
^^^^^^^^^^
+- New :doc:`modernize-use-to-underlying
+ <clang-tidy/checks/modernize/use-to-underlying>` check.
+
+ Finds casts from a scoped enumeration to an integer type and replaces them
+ with a call to ``std::to_underlying``.
+
New check aliases
^^^^^^^^^^^^^^^^^
diff --git a/clang-tools-extra/docs/clang-tidy/checks/list.rst b/clang-tools-extra/docs/clang-tidy/checks/list.rst
index 2a44dc78fbc89..d954e0c1cc38b 100644
--- a/clang-tools-extra/docs/clang-tidy/checks/list.rst
+++ b/clang-tools-extra/docs/clang-tidy/checks/list.rst
@@ -334,6 +334,7 @@ Clang-Tidy Checks
:doc:`modernize-use-std-print <modernize/use-std-print>`, "Yes"
:doc:`modernize-use-string-view <modernize/use-string-view>`, "Yes"
:doc:`modernize-use-structured-binding <modernize/use-structured-binding>`, "Yes"
+ :doc:`modernize-use-to-underlying <modernize/use-to-underlying>`, "Yes"
:doc:`modernize-use-trailing-return-type <modernize/use-trailing-return-type>`, "Yes"
:doc:`modernize-use-transparent-functors <modernize/use-transparent-functors>`, "Yes"
:doc:`modernize-use-uncaught-exceptions <modernize/use-uncaught-exceptions>`, "Yes"
diff --git a/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
new file mode 100644
index 0000000000000..e7d1d8e3519bc
--- /dev/null
+++ b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
@@ -0,0 +1,105 @@
+.. title:: clang-tidy - modernize-use-to-underlying
+
+modernize-use-to-underlying
+===========================
+
+Finds casts from a scoped enumeration (``enum class``) to an integer type and
+replaces them with a call to ``std::to_underlying`` (introduced in C++23).
+
+Converting a scoped enumeration to a hard-coded integer type is error-prone: if
+the enumeration's underlying type is later changed, every such cast silently
+becomes a narrowing, widening or sign-changing conversion. ``std::to_underlying``
+always yields exactly the underlying type and keeps the code correct.
+
+.. code-block:: c++
+
+ enum class Color : unsigned char { Red, Green, Blue };
+
+ void f(Color c) {
+ // Before:
+ auto value = static_cast<unsigned char>(c);
+ // After:
+ auto value = std::to_underlying(c);
+ }
+
+The check matches ``static_cast``, C-style casts and functional-style casts.
+
+Precise and imprecise casts
+---------------------------
+
+A cast is *precise* when its destination type is exactly the underlying type of
+the enumeration. In that case the whole cast is replaced:
+
+.. code-block:: c++
+
+ enum class E : int {};
+
+ int i = static_cast<int>(E{}); // becomes: std::to_underlying(E{})
+
+A cast is *imprecise* when the destination type is an integer type other than
+the underlying type (a different width or signedness), for example
+``static_cast<long>`` or ``static_cast<unsigned>`` on an ``enum : int``. Such a
+cast performs an additional integer conversion, so there is no single correct
+rewrite; how imprecise casts are handled is controlled by the
+:option:`ImpreciseCasts` option.
+
+A cast to a non-integer type (floating point, pointer, another enumeration) is
+never flagged. A cast to ``bool`` is only flagged when ``bool`` is the exact
+underlying type of the enumeration; otherwise it is treated as a truthiness
+test and left untouched.
+
+Options
+-------
+
+.. option:: ImpreciseCasts
+
+ Controls how imprecise casts (whose destination type differs from the
+ underlying type) are handled. Precise casts are always diagnosed and fixed
+ regardless of this option. Possible values:
+
+ ``Ignore``
+ Do not diagnose imprecise casts.
+
+ ``Warn`` *(default)*
+ Diagnose imprecise casts but do not offer a fix-it. Neither automatic
+ rewrite is applied because both change the meaning of the code in ways
+ that may not be intended.
+
+ ``PreserveType``
+ Wrap the operand in a call to the replacement function, keeping the
+ original destination type:
+
+ .. code-block:: c++
+
+ long l = static_cast<long>(E{}); // becomes: static_cast<long>(std::to_underlying(E{}))
+
+ ``UseUnderlyingType``
+ Replace the whole cast with a call to the replacement function. This
+ **changes the type** of the expression from the destination type to the
+ underlying type, so use it only when the wider or differently-signed
+ destination type was itself unintended:
+
+ .. code-block:: c++
+
+ long l = static_cast<long>(E{}); // becomes: std::to_underlying(E{})
+
+.. option:: ReplacementFunction
+
+ The fully qualified name of the function used in the replacement. Defaults to
+ ``std::to_underlying``. Set this to use a hand-rolled equivalent (for example
+ ``llvm::to_underlying``) when targeting a language standard before C++23. When
+ the value is ``std::to_underlying``, the check only runs in C++23 or later;
+ with any other value it runs from C++11 onwards.
+
+.. option:: ReplacementFunctionHeader
+
+ The header to include when the replacement function is used. Defaults to
+ ``<utility>`` when :option:`ReplacementFunction` is ``std::to_underlying``,
+ and is otherwise empty (no include is added). When the value is enclosed in
+ angle brackets the include directive uses angle brackets, otherwise it uses
+ quotes.
+
+.. option:: IncludeStyle
+
+ A string specifying which include-style is used, ``llvm`` or ``google``.
+ Default is ``llvm``.
diff --git a/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-custom-function.cpp b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-custom-function.cpp
new file mode 100644
index 0000000000000..95fb760b27ae0
--- /dev/null
+++ b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-custom-function.cpp
@@ -0,0 +1,17 @@
+// RUN: %check_clang_tidy -std=c++11-or-later %s modernize-use-to-underlying %t \
+// RUN: -- -config="{CheckOptions: { \
+// RUN: modernize-use-to-underlying.ReplacementFunction: 'llvm::to_underlying', \
+// RUN: modernize-use-to-underlying.ReplacementFunctionHeader: 'llvm/ADT/STLExtras.h'}}"
+
+// With a user-provided replacement function the check runs before C++23 and
+// uses the configured name and header.
+
+// CHECK-FIXES: #include "llvm/ADT/STLExtras.h"
+
+enum class E : int { A, B };
+
+int convert(E e) {
+ return static_cast<int>(e);
+ // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: use 'llvm::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: return llvm::to_underlying(e);
+}
diff --git a/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-imprecise-casts.cpp b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-imprecise-casts.cpp
new file mode 100644
index 0000000000000..88f1ed7a29d06
--- /dev/null
+++ b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying-imprecise-casts.cpp
@@ -0,0 +1,55 @@
+// RUN: %check_clang_tidy -check-suffixes=IGNORE -std=c++23-or-later %s \
+// RUN: modernize-use-to-underlying %t -- \
+// RUN: -config="{CheckOptions: {modernize-use-to-underlying.ImpreciseCasts: Ignore}}"
+// RUN: %check_clang_tidy -check-suffixes=WARN -std=c++23-or-later %s \
+// RUN: modernize-use-to-underlying %t -- \
+// RUN: -config="{CheckOptions: {modernize-use-to-underlying.ImpreciseCasts: Warn}}"
+// RUN: %check_clang_tidy -check-suffixes=PRESERVE -std=c++23-or-later %s \
+// RUN: modernize-use-to-underlying %t -- \
+// RUN: -config="{CheckOptions: {modernize-use-to-underlying.ImpreciseCasts: PreserveType}}"
+// RUN: %check_clang_tidy -check-suffixes=UNDERLYING -std=c++23-or-later %s \
+// RUN: modernize-use-to-underlying %t -- \
+// RUN: -config="{CheckOptions: {modernize-use-to-underlying.ImpreciseCasts: UseUnderlyingType}}"
+
+// CHECK-FIXES-WARN: #include <utility>
+// CHECK-FIXES-PRESERVE: #include <utility>
+// CHECK-FIXES-UNDERLYING: #include <utility>
+
+namespace std {
+template <typename T>
+constexpr __underlying_type(T) to_underlying(T value) noexcept {
+ return static_cast<__underlying_type(T)>(value);
+}
+} // namespace std
+
+enum class E : int { A, B };
+
+// A precise cast is always diagnosed and fully replaced, regardless of the
+// ImpreciseCasts option.
+int precise(E e) {
+ return static_cast<int>(e);
+ // CHECK-MESSAGES-IGNORE: :[[@LINE-1]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-MESSAGES-WARN: :[[@LINE-2]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-MESSAGES-PRESERVE: :[[@LINE-3]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-MESSAGES-UNDERLYING: :[[@LINE-4]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES-IGNORE: return std::to_underlying(e);
+ // CHECK-FIXES-WARN: return std::to_underlying(e);
+ // CHECK-FIXES-PRESERVE: return std::to_underlying(e);
+ // CHECK-FIXES-UNDERLYING: return std::to_underlying(e);
+}
+
+// An imprecise cast:
+// - Ignore: no diagnostic, no fix.
+// - Warn: warn but offer no fix-it.
+// - PreserveType: warn and wrap the operand, keeping the destination type.
+// - UseUnderlyingType: warn and replace the whole cast, changing the type.
+long imprecise(E e) {
+ return static_cast<long>(e);
+ // CHECK-MESSAGES-WARN: :[[@LINE-1]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-MESSAGES-PRESERVE: :[[@LINE-2]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-MESSAGES-UNDERLYING: :[[@LINE-3]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES-IGNORE: return static_cast<long>(e);
+ // CHECK-FIXES-WARN: return static_cast<long>(e);
+ // CHECK-FIXES-PRESERVE: return static_cast<long>(std::to_underlying(e));
+ // CHECK-FIXES-UNDERLYING: return std::to_underlying(e);
+}
diff --git a/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying.cpp b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying.cpp
new file mode 100644
index 0000000000000..a1ee85fcb1e53
--- /dev/null
+++ b/clang-tools-extra/test/clang-tidy/checkers/modernize/use-to-underlying.cpp
@@ -0,0 +1,108 @@
+// RUN: %check_clang_tidy -std=c++23-or-later %s modernize-use-to-underlying %t
+
+// CHECK-FIXES: #include <utility>
+
+namespace std {
+template <typename T>
+constexpr __underlying_type(T) to_underlying(T value) noexcept {
+ return static_cast<__underlying_type(T)>(value);
+}
+} // namespace std
+
+enum class ColorInt : int { Red, Green, Blue };
+enum class ByteEnum : unsigned char { A, B };
+enum class DefaultEnum { X, Y }; // underlying type is int
+enum class OtherEnum : int {};
+enum Unscoped : int { U0, U1 }; // not a scoped enum
+
+// A precise cast (destination type equals the underlying type) is always
+// diagnosed and the whole cast is replaced. static_cast, C-style and
+// functional-style casts are all matched.
+void precise(ColorInt c, ByteEnum b, DefaultEnum d) {
+ int A = static_cast<int>(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int A = std::to_underlying(c);
+ unsigned char B = static_cast<unsigned char>(b);
+ // CHECK-MESSAGES: :[[@LINE-1]]:21: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: unsigned char B = std::to_underlying(b);
+ int C = static_cast<int>(d);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int C = std::to_underlying(d);
+ int D = (int)c;
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int D = std::to_underlying(c);
+ int E = int(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int E = std::to_underlying(c);
+}
+
+// With the default ImpreciseCasts=Warn, an imprecise cast (destination type
+// differs from the underlying type in width or signedness) is diagnosed but no
+// fix-it is applied.
+void imprecise(ColorInt c) {
+ long W = static_cast<long>(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:12: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: long W = static_cast<long>(c);
+ unsigned S = static_cast<unsigned>(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:16: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: unsigned S = static_cast<unsigned>(c);
+}
+
+// Cases that must never be flagged.
+void negatives(ColorInt c, Unscoped u) {
+ bool Truthy = static_cast<bool>(c); // truthiness test, not underlying
+ double Dbl = static_cast<double>(c); // non-integer destination
+ OtherEnum Enum = static_cast<OtherEnum>(c); // enum-to-enum
+ int Un = static_cast<int>(u); // unscoped enumeration
+ int Ok = std::to_underlying(c); // already correct
+}
+
+// When bool is the exact underlying type, the cast is precise and flagged.
+enum class BoolEnum : bool { No, Yes };
+bool precise_bool(BoolEnum b) {
+ return static_cast<bool>(b);
+ // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: return std::to_underlying(b);
+}
+
+// The operand can be an arbitrary expression, which is preserved verbatim.
+int operand_expression(ColorInt a, bool cond) {
+ return static_cast<int>(cond ? a : ColorInt::Red);
+ // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: return std::to_underlying(cond ? a : ColorInt::Red);
+}
+
+// A typedef of a scoped enum is seen through (canonical type is the enum).
+using ColorAlias = ColorInt;
+int typedef_enum(ColorAlias c) {
+ return static_cast<int>(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: return std::to_underlying(c);
+}
+
+// The check should not fire on an uninstantiated template.
+template <typename E>
+int template_cast(E e) {
+ return static_cast<int>(e);
+}
+
+void use_template(ColorInt c) { template_cast(c); }
+
+// Casts involving macros. The fix is applied whenever the operand's spelling
+// can be recovered: at the invocation of a function-like macro whose body is
+// the whole cast, when only the destination type comes from a macro, and when
+// the operand itself is a macro.
+#define CAST_TO_INT(x) static_cast<int>(x)
+#define DEST_TYPE int
+#define RED_VALUE ColorInt::Red
+void macros(ColorInt c) {
+ int A = CAST_TO_INT(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int A = std::to_underlying(c);
+ int B = static_cast<DEST_TYPE>(c);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int B = std::to_underlying(c);
+ int C = static_cast<int>(RED_VALUE);
+ // CHECK-MESSAGES: :[[@LINE-1]]:11: warning: use 'std::to_underlying' to convert a scoped enumeration to its underlying type [modernize-use-to-underlying]
+ // CHECK-FIXES: int C = std::to_underlying(RED_VALUE);
+}
>From 90f55a6551dbfe85b99cdfd12c7ef64247c47b96 Mon Sep 17 00:00:00 2001
From: Adrian Vogelsgesang <avogelsgesang at salesforce.com>
Date: Fri, 17 Jul 2026 23:31:56 +0000
Subject: [PATCH 2/3] Apply EugeneZelenko's review feedback
- Use single backticks for option values (Ignore, Warn, PreserveType,
UseUnderlyingType, llvm, google) and for the ReplacementFunction value,
matching the convention used by other clang-tidy option docs.
- Synchronize the Release Notes entry with the first sentence of the
check documentation.
Co-Authored-By: Claude Opus 4.8 <noreply at anthropic.com>
---
clang-tools-extra/docs/ReleaseNotes.rst | 4 ++--
.../checks/modernize/use-to-underlying.rst | 14 +++++++-------
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/clang-tools-extra/docs/ReleaseNotes.rst b/clang-tools-extra/docs/ReleaseNotes.rst
index 6e5ceb3fe96f4..2d923aa567a91 100644
--- a/clang-tools-extra/docs/ReleaseNotes.rst
+++ b/clang-tools-extra/docs/ReleaseNotes.rst
@@ -100,8 +100,8 @@ New checks
- New :doc:`modernize-use-to-underlying
<clang-tidy/checks/modernize/use-to-underlying>` check.
- Finds casts from a scoped enumeration to an integer type and replaces them
- with a call to ``std::to_underlying``.
+ Finds casts from a scoped enumeration (``enum class``) to an integer type and
+ replaces them with a call to ``std::to_underlying`` (introduced in C++23).
New check aliases
^^^^^^^^^^^^^^^^^
diff --git a/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
index e7d1d8e3519bc..b723343cfb19e 100644
--- a/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
+++ b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
@@ -57,15 +57,15 @@ Options
underlying type) are handled. Precise casts are always diagnosed and fixed
regardless of this option. Possible values:
- ``Ignore``
+ `Ignore`
Do not diagnose imprecise casts.
- ``Warn`` *(default)*
+ `Warn` *(default)*
Diagnose imprecise casts but do not offer a fix-it. Neither automatic
rewrite is applied because both change the meaning of the code in ways
that may not be intended.
- ``PreserveType``
+ `PreserveType`
Wrap the operand in a call to the replacement function, keeping the
original destination type:
@@ -73,7 +73,7 @@ Options
long l = static_cast<long>(E{}); // becomes: static_cast<long>(std::to_underlying(E{}))
- ``UseUnderlyingType``
+ `UseUnderlyingType`
Replace the whole cast with a call to the replacement function. This
**changes the type** of the expression from the destination type to the
underlying type, so use it only when the wider or differently-signed
@@ -94,12 +94,12 @@ Options
.. option:: ReplacementFunctionHeader
The header to include when the replacement function is used. Defaults to
- ``<utility>`` when :option:`ReplacementFunction` is ``std::to_underlying``,
+ ``<utility>`` when :option:`ReplacementFunction` is set to `std::to_underlying`,
and is otherwise empty (no include is added). When the value is enclosed in
angle brackets the include directive uses angle brackets, otherwise it uses
quotes.
.. option:: IncludeStyle
- A string specifying which include-style is used, ``llvm`` or ``google``.
- Default is ``llvm``.
+ A string specifying which include-style is used, `llvm` or `google`.
+ Default is `llvm`.
>From 86c242e27a15526f5965ba333de087e7c7456904 Mon Sep 17 00:00:00 2001
From: Adrian Vogelsgesang <avogelsgesang at salesforce.com>
Date: Fri, 17 Jul 2026 23:54:16 +0000
Subject: [PATCH 3/3] Improve before/after examples in
modernize-use-to-underlying docs
Split the inline "// becomes:" examples onto separate lines so the before
and after code are both readable instead of crammed into a trailing comment.
Co-Authored-By: Claude Opus 4.8 <noreply at anthropic.com>
---
.../checks/modernize/use-to-underlying.rst | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
index b723343cfb19e..3c8742d30ecd6 100644
--- a/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
+++ b/clang-tools-extra/docs/clang-tidy/checks/modernize/use-to-underlying.rst
@@ -34,7 +34,9 @@ the enumeration. In that case the whole cast is replaced:
enum class E : int {};
- int i = static_cast<int>(E{}); // becomes: std::to_underlying(E{})
+ int i = static_cast<int>(E{});
+ // becomes:
+ int i = std::to_underlying(E{});
A cast is *imprecise* when the destination type is an integer type other than
the underlying type (a different width or signedness), for example
@@ -71,7 +73,9 @@ Options
.. code-block:: c++
- long l = static_cast<long>(E{}); // becomes: static_cast<long>(std::to_underlying(E{}))
+ long l = static_cast<long>(E{});
+ // becomes:
+ long l = static_cast<long>(std::to_underlying(E{}));
`UseUnderlyingType`
Replace the whole cast with a call to the replacement function. This
@@ -81,7 +85,9 @@ Options
.. code-block:: c++
- long l = static_cast<long>(E{}); // becomes: std::to_underlying(E{})
+ long l = static_cast<long>(E{});
+ // becomes:
+ long l = std::to_underlying(E{});
.. option:: ReplacementFunction
More information about the cfe-commits
mailing list