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;