refactor(lint): enum-class asks the token stream, not a regex

The regex required the enum's name to follow `enum` on the same line, so a
declaration split over lines went unreported. Asking for the next token instead
makes the split and unsplit spellings read identically, and distinguishes the
`enum` KEYWORD from the same letters appearing elsewhere without needing word
boundaries to stand in for lexing.

Deliberately not moved to the AST, even though LintDecl already carries an exact
isScopedEnum. An AST is one configuration's slice, so a plain enum inside a
preprocessor branch inactive for the host would silently stop being reported;
tokens are lexed without evaluating #if, so every platform stays covered. Being
right about less is not an improvement here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-07-31 00:21:12 +02:00
commit 693e3c7af9
2 changed files with 35 additions and 7 deletions

View file

@ -184,15 +184,24 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
}
});
// Scoped enums only. Kept on tokens rather than moved to the AST, even
// though LintDecl carries an exact isScopedEnum: an AST is one
// configuration's slice, so a plain enum inside a preprocessor branch that
// is inactive for the host would stop being reported. Tokens are lexed
// without evaluating #if, so every platform's code stays covered.
cfg.AddLintRule("enum-class", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
if (std::regex_search(lineStr, plainEnum)) {
ctx.Report(i + 1, "use enum class instead of plain enum");
}
std::span<const Crafter::LintToken> tokens = ctx.Tokens();
for (std::size_t i = 0; i < tokens.size(); ++i) {
if (tokens[i].kind != Crafter::LintTokenKind::Keyword) continue;
if (ctx.TokenText(tokens[i]) != "enum") continue;
// Asking for the next TOKEN rather than the rest of the line means a
// declaration split across lines reads identically to one that is
// not — the regex this replaced required the name to follow `enum`
// on the same line, so `enum\n Color {` went unreported.
std::string_view next = i + 1 < tokens.size() ? ctx.TokenText(tokens[i + 1]) : std::string_view{};
if (next == "class" || next == "struct") continue;
ctx.Report(tokens[i].line, "use enum class instead of plain enum");
}
});

View file

@ -395,6 +395,25 @@ int main() {
Check(r.text.contains("for (std::int32_t i = 0;"), "fixed-width: main's body is not exempt, only its signature");
}
// enum-class asks for the next TOKEN after `enum`, so a declaration split
// over lines reads the same as one that is not. The regex it replaced
// required the name to follow `enum` on the same line.
{
RuleRun r = RunRule("enum Plain { A };\n"
"enum\n"
" Split { B };\n"
"enum class Scoped { C };\n"
"enum\n"
" class SplitScoped { D };\n"
"enum struct AlsoFine { E };\n",
"enum-class", LintMode::Report);
Check(r.summary.findings.size() == 2, std::format("enum-class: exactly the two plain enums ({} found)", r.summary.findings.size()));
bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; });
Check(onFirst, "enum-class: single-line plain enum reported");
Check(onSplit, "enum-class: line-split plain enum reported at the keyword");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;