From 693e3c7af96ee5f66c37607208d3fff4b1537b84 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 31 Jul 2026 00:21:12 +0200 Subject: [PATCH] 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) --- lint-rules.h | 23 ++++++++++++++++------- tests/HouseRules/main.cpp | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lint-rules.h b/lint-rules.h index f4054a7..74d59ee 100644 --- a/lint-rules.h +++ b/lint-rules.h @@ -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 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 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"); } }); diff --git a/tests/HouseRules/main.cpp b/tests/HouseRules/main.cpp index a82871e..f71b46c 100644 --- a/tests/HouseRules/main.cpp +++ b/tests/HouseRules/main.cpp @@ -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;