feat(lint): const-local and constexpr-constant rules
Two rules the AST makes possible, plus the mutation analysis behind them.
const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction is what makes
the answer exact rather than a guess: a scalar has no member functions, so the
only ways to write one are assignment, ++/--, having its address taken, or
binding to a non-const reference. All four are now tracked in the walk:
- assignment and compound assignment visit their LEFT operand in a write
context, the right one normally;
- ++/-- and & write their operand;
- a call argument is checked against the callee's parameter type, so passing
to `const int&` or by value is a read while `int&` is a write;
- initialising a non-const reference writes what it binds to.
For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.
constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.
On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.
Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ca2b3e1df
commit
651720e494
10 changed files with 370 additions and 39 deletions
|
|
@ -64,7 +64,7 @@ int main() {
|
|||
fs::path projectFile = root / "project.cpp";
|
||||
std::ofstream(projectFile) << "\n";
|
||||
fs::path cwdBin = fs::current_path() / "bin";
|
||||
bool cwdBinExisted = fs::exists(cwdBin);
|
||||
const bool cwdBinExisted = fs::exists(cwdBin);
|
||||
|
||||
CleanProject(projectFile);
|
||||
Check(!fs::exists(root / "bin"), "the named project's bin/ is gone");
|
||||
|
|
|
|||
|
|
@ -430,12 +430,72 @@ int main() {
|
|||
"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; });
|
||||
const bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
|
||||
const 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");
|
||||
}
|
||||
|
||||
// const-local's mutation analysis is exact for scalars: assignment, ++/--,
|
||||
// address-of and binding to a non-const reference are the only ways to
|
||||
// write one, and all four are tracked. Both directions matter — a missed
|
||||
// write means advising const on something that cannot be const.
|
||||
{
|
||||
RuleRun r = RunRule("void Mutate(int& out);\n"
|
||||
"void ReadOnly(const int& in);\n"
|
||||
"void ByValue(int v);\n"
|
||||
"int Compute();\n"
|
||||
"void F() {\n"
|
||||
" int neverWritten = 1;\n"
|
||||
" int assigned = 1; assigned = 2;\n"
|
||||
" int incremented = 1; ++incremented;\n"
|
||||
" int compound = 1; compound += 2;\n"
|
||||
" int addressed = 1; int* taken = &addressed;\n"
|
||||
" int toMutatingRef = 1; Mutate(toMutatingRef);\n"
|
||||
" int toConstRef = 1; ReadOnly(toConstRef);\n"
|
||||
" int toByValue = 1; ByValue(toByValue);\n"
|
||||
" int boundToRef = 1; int& alias = boundToRef;\n"
|
||||
" for (int loop = 0; loop < 1; ++loop) { (void)loop; }\n"
|
||||
" (void)taken; (void)alias;\n"
|
||||
"}\n",
|
||||
"const-local", LintMode::Report);
|
||||
Check(HasFinding(r.summary, "'neverWritten'"), "const-local: an unwritten local is reported");
|
||||
Check(HasFinding(r.summary, "'toConstRef'"), "const-local: passing to a const& is not a write");
|
||||
Check(HasFinding(r.summary, "'toByValue'"), "const-local: passing by value is not a write");
|
||||
Check(!HasFinding(r.summary, "'assigned'"), "const-local: assignment is a write");
|
||||
Check(!HasFinding(r.summary, "'incremented'"), "const-local: ++ is a write");
|
||||
Check(!HasFinding(r.summary, "'compound'"), "const-local: += is a write");
|
||||
Check(!HasFinding(r.summary, "'addressed'"), "const-local: taking an address counts as a write");
|
||||
Check(!HasFinding(r.summary, "'toMutatingRef'"), "const-local: binding to a non-const& parameter is a write");
|
||||
Check(!HasFinding(r.summary, "'boundToRef'"), "const-local: binding to a non-const& local is a write");
|
||||
Check(!HasFinding(r.summary, "'loop'"), "const-local: a mutated loop counter is not reported");
|
||||
// Pointers and range-for bindings are excluded: `T* const p` and
|
||||
// `for (T* const x : …)` are not spellings anybody writes.
|
||||
Check(!HasFinding(r.summary, "'taken'"), "const-local: pointer locals are out of scope");
|
||||
}
|
||||
{
|
||||
RuleRun r = RunRule("void F() {\n" " for (int each : Range()) { (void)each; }\n" "}\n", "const-local", LintMode::Report);
|
||||
Check(!HasFinding(r.summary, "'each'"), "const-local: a range-for binding is not reported");
|
||||
}
|
||||
|
||||
// constexpr-constant only promotes a constant whose initialiser is made of
|
||||
// literals and operators, so a call result is left alone.
|
||||
{
|
||||
RuleRun r = RunRule("int Compute();\n"
|
||||
"void F() {\n"
|
||||
" const int literal = 4;\n"
|
||||
" const int folded = 1 << 4;\n"
|
||||
" const int fromCall = Compute();\n"
|
||||
" constexpr int already = 8;\n"
|
||||
" (void)literal; (void)folded; (void)fromCall; (void)already;\n"
|
||||
"}\n",
|
||||
"constexpr-constant", LintMode::Report);
|
||||
Check(HasFinding(r.summary, "'literal'"), "constexpr: a literal constant is reported");
|
||||
Check(HasFinding(r.summary, "'folded'"), "constexpr: an operator fold over literals is reported");
|
||||
Check(!HasFinding(r.summary, "'fromCall'"), "constexpr: a call result is not a constant expression");
|
||||
Check(!HasFinding(r.summary, "'already'"), "constexpr: an existing constexpr is not re-reported");
|
||||
}
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ namespace {
|
|||
out.reserve(ctx.content.size());
|
||||
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
||||
std::string_view line = ctx.Line(n);
|
||||
bool crlf = line.ends_with('\r');
|
||||
const bool crlf = line.ends_with('\r');
|
||||
if (crlf) line.remove_suffix(1);
|
||||
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
|
||||
out += line;
|
||||
|
|
@ -226,7 +226,7 @@ int main() {
|
|||
LintSummary sum = RunLint(cfg, Mode(LintMode::Apply));
|
||||
Check(s.Read("a") == "hello\nworld\n", "Apply rewrites the file");
|
||||
Check(sum.changedFiles.size() == 1, "one file formatted");
|
||||
bool hasNote = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.rule == "note"; });
|
||||
const bool hasNote = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.rule == "note"; });
|
||||
Check(hasNote, "report-only findings still recorded in Apply mode");
|
||||
Check(sum.errors == 0, "clean apply has no errors");
|
||||
}
|
||||
|
|
@ -259,8 +259,8 @@ int main() {
|
|||
ctx.SetContent(std::move(c));
|
||||
});
|
||||
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
||||
bool one = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "one"; });
|
||||
bool two = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "two"; });
|
||||
const bool one = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "one"; });
|
||||
const bool two = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "two"; });
|
||||
Check(one && two, "chained transforms both attributed");
|
||||
Check(s.Read("a") == "AAA\n", "Report leaves chain input untouched");
|
||||
LintSummary app = RunLint(cfg, Mode(LintMode::Apply));
|
||||
|
|
@ -281,7 +281,7 @@ int main() {
|
|||
Check(s.Read("a") == "keep\n", "throwing transform reverted, disk untouched");
|
||||
Check(sum.errors == 1, "exception counted as error");
|
||||
Check(sum.changedFiles.empty(), "reverted transform is not a change");
|
||||
bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
|
||||
const bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
|
||||
[](const LintFinding& f) {
|
||||
return f.line == 0 && f.message.contains("mid-transform");
|
||||
});
|
||||
|
|
@ -301,7 +301,7 @@ int main() {
|
|||
}
|
||||
});
|
||||
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
||||
bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
|
||||
const bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
|
||||
[](const LintFinding& f) {
|
||||
return f.rule == "final-newline" && f.line == 0;
|
||||
});
|
||||
|
|
@ -323,8 +323,8 @@ int main() {
|
|||
ctx.SetContent(std::move(c));
|
||||
});
|
||||
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
||||
bool flagged = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "flagged"; });
|
||||
bool reformat = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "would reformat"; });
|
||||
const bool flagged = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "flagged"; });
|
||||
const bool reformat = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "would reformat"; });
|
||||
Check(flagged && reformat, "mixed rule records both finding kinds");
|
||||
RunLint(cfg, Mode(LintMode::Apply));
|
||||
Check(s.Read("a") == "bad\n", "mixed rule's transform applied");
|
||||
|
|
@ -389,10 +389,10 @@ int main() {
|
|||
});
|
||||
AddTrimRule(cfg);
|
||||
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
|
||||
bool line2Silent = std::none_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule != "trim"; });
|
||||
bool line3Loud = std::count_if(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 3; }) >= 2;
|
||||
const bool line2Silent = std::none_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule != "trim"; });
|
||||
const bool line3Loud = std::count_if(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 3; }) >= 2;
|
||||
Check(line2Silent, "both named rules suppressed on the target line");
|
||||
bool trimStillFires = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule == "trim"; });
|
||||
const bool trimStillFires = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule == "trim"; });
|
||||
Check(trimStillFires, "unnamed rule still fires on the target line");
|
||||
Check(line3Loud, "unsuppressed line reports from both rules");
|
||||
}
|
||||
|
|
@ -419,7 +419,7 @@ int main() {
|
|||
AddTrimRule(cfg);
|
||||
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
|
||||
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
||||
bool onlyTrim = !rep.findings.empty() && std::all_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "trim"; });
|
||||
const bool onlyTrim = !rep.findings.empty() && std::all_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "trim"; });
|
||||
Check(onlyTrim, "file-level rule directive kills that rule, trim still fires");
|
||||
RunLint(cfg, Mode(LintMode::Apply));
|
||||
Check(s.Read("a") == "// lint-disable-file flag\nbad\n", "other rules still format");
|
||||
|
|
@ -458,12 +458,12 @@ int main() {
|
|||
Check(offsetsSound, "tokens: every offset/length addresses real bytes");
|
||||
|
||||
// Ordered by offset, so binary search in TokensOnLine is valid.
|
||||
bool ordered = std::ranges::is_sorted(toks, {}, &LintToken::offset);
|
||||
const bool ordered = std::ranges::is_sorted(toks, {}, &LintToken::offset);
|
||||
Check(ordered, "tokens: stream is in source order");
|
||||
|
||||
// Inactive #ifdef branch is still lexed — this is what keeps token
|
||||
// rules covering every platform, unlike an AST.
|
||||
bool sawHidden = std::ranges::any_of(toks, [&](const LintToken& t) {
|
||||
const bool sawHidden = std::ranges::any_of(toks, [&](const LintToken& t) {
|
||||
return t.kind == LintTokenKind::Identifier && ctx.TokenText(t) == "HiddenBranch";
|
||||
});
|
||||
Check(sawHidden, "tokens: inactive #ifdef branch is lexed");
|
||||
|
|
|
|||
Loading…
Reference in a new issue