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>
726 lines
36 KiB
C++
726 lines
36 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
import std;
|
|
import Crafter.Build;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
namespace {
|
|
std::int32_t Failures = 0;
|
|
|
|
void Check(bool cond, std::string_view msg) {
|
|
if (!cond) {
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
|
++Failures;
|
|
}
|
|
}
|
|
|
|
// Scratch-dir fixture for transform tests: Apply mode rewrites files, so
|
|
// these cases must never point at the checked-in fixture/. Each case
|
|
// writes its own sources into a fresh temp dir.
|
|
struct Scratch {
|
|
fs::path dir;
|
|
explicit Scratch(std::string_view name) {
|
|
dir = fs::temp_directory_path() / "crafter-build-lint-format" / name;
|
|
fs::remove_all(dir);
|
|
fs::create_directories(dir);
|
|
}
|
|
void Write(std::string_view stem, std::string_view content) const {
|
|
std::ofstream f(dir / std::format("{}.cpp", stem), std::ios::binary | std::ios::trunc);
|
|
f.write(content.data(), static_cast<std::streamsize>(content.size()));
|
|
}
|
|
std::string Read(std::string_view stem) const {
|
|
std::ifstream f(dir / std::format("{}.cpp", stem), std::ios::binary);
|
|
std::stringstream buffer;
|
|
buffer << f.rdbuf();
|
|
return std::move(buffer).str();
|
|
}
|
|
Configuration Config(std::vector<fs::path> impls) const {
|
|
Configuration cfg;
|
|
cfg.path = dir;
|
|
cfg.name = "fmt-fixture";
|
|
cfg.outputName = "fmt-fixture";
|
|
cfg.target = HostTarget();
|
|
std::array<fs::path, 0> ifaces = {};
|
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
|
return cfg;
|
|
}
|
|
};
|
|
|
|
void AddTrimRule(Configuration& cfg) {
|
|
cfg.AddLintRule("trim", [](LintContext& ctx) {
|
|
std::string out;
|
|
out.reserve(ctx.content.size());
|
|
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
std::string_view line = ctx.Line(n);
|
|
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;
|
|
if (crlf) out += '\r';
|
|
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
|
|
}
|
|
ctx.SetContent(std::move(out));
|
|
});
|
|
}
|
|
|
|
RunLintOptions Mode(LintMode m) {
|
|
RunLintOptions opts;
|
|
opts.mode = m;
|
|
return opts;
|
|
}
|
|
|
|
// Fresh Configuration over the two fixture sources. Rebuilt per case so
|
|
// rule registrations don't leak between them.
|
|
Configuration FixtureConfig() {
|
|
Configuration cfg;
|
|
cfg.path = fs::current_path() / "tests" / "Lint" / "fixture";
|
|
cfg.name = "lint-fixture";
|
|
cfg.outputName = "lint-fixture";
|
|
cfg.target = HostTarget();
|
|
std::array<fs::path, 0> ifaces = {};
|
|
std::array<fs::path, 2> impls = { "clean", "dirty" };
|
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
|
return cfg;
|
|
}
|
|
|
|
void AddTabRule(Configuration& cfg) {
|
|
cfg.AddLintRule("tab-rule", [](LintContext& ctx) {
|
|
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// In-process tests for RunLint: rule registration, file collection over a
|
|
// Configuration, glob filtering, --list, comment stripping, and dedup. Lint
|
|
// only reads sources, so no Build() is needed.
|
|
int main() {
|
|
// No rules registered → noRulesDefined, not Clean.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
LintSummary s = RunLint(cfg, {});
|
|
Check(s.noRulesDefined, "no rules -> noRulesDefined");
|
|
Check(!s.Clean(), "no rules -> not Clean (exit 1)");
|
|
Check(s.findings.empty(), "no rules -> no findings");
|
|
}
|
|
|
|
// tab-rule fires on dirty.cpp line 9 only; never-fires stays silent.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
AddTabRule(cfg);
|
|
cfg.AddLintRule("never-fires", [](LintContext&) {});
|
|
LintSummary s = RunLint(cfg, {});
|
|
Check(s.rulesRun == 2, "both rules run");
|
|
Check(s.filesLinted == 2, "both fixture files linted");
|
|
Check(s.findings.size() == 1, "exactly one finding");
|
|
Check(!s.Clean(), "findings -> not Clean");
|
|
if (!s.findings.empty()) {
|
|
Check(s.findings[0].file.filename() == "dirty.cpp", "finding is in dirty.cpp");
|
|
Check(s.findings[0].line == 9, "tab reported at line 9");
|
|
Check(s.findings[0].rule == "tab-rule", "finding attributed to tab-rule");
|
|
}
|
|
}
|
|
|
|
// Glob filter drops tab-rule → clean run.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
AddTabRule(cfg);
|
|
cfg.AddLintRule("never-fires", [](LintContext&) {});
|
|
RunLintOptions opts;
|
|
opts.globs = { "never-*" };
|
|
LintSummary s = RunLint(cfg, opts);
|
|
Check(s.rulesRun == 1, "glob filters to one rule");
|
|
Check(s.findings.empty(), "filtered run has no findings");
|
|
Check(s.Clean(), "filtered run is Clean");
|
|
}
|
|
|
|
// listOnly enumerates without running any rule.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
AddTabRule(cfg);
|
|
RunLintOptions opts;
|
|
opts.listOnly = true;
|
|
LintSummary s = RunLint(cfg, opts);
|
|
Check(s.findings.empty(), "listOnly records no findings");
|
|
Check(s.filesLinted == 0, "listOnly reads no files");
|
|
Check(s.rulesRun == 1, "listOnly still counts matching rules");
|
|
}
|
|
|
|
// CommentStripped: MARKER in a comment (line 7) and in a string literal
|
|
// (line 12) are blanked; the identifier at line 13 survives, and line
|
|
// numbers computed from the stripped text match the real file.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
cfg.AddLintRule("marker", [](LintContext& ctx) {
|
|
const std::string& code = ctx.CommentStripped();
|
|
for (std::size_t pos = code.find("MARKER"); pos != std::string::npos; pos = code.find("MARKER", pos + 1)) {
|
|
std::size_t line = 1 + std::count(code.begin(), code.begin() + pos, '\n');
|
|
ctx.Report(line, "MARKER in code");
|
|
}
|
|
});
|
|
LintSummary s = RunLint(cfg, {});
|
|
Check(s.findings.size() == 1, "only the code MARKER is found");
|
|
if (!s.findings.empty()) {
|
|
Check(s.findings[0].line == 13, "code MARKER reported at line 13");
|
|
Check(s.findings[0].file.filename() == "dirty.cpp", "MARKER finding is in dirty.cpp");
|
|
}
|
|
}
|
|
|
|
// Duplicate rule name: first registration wins, second never runs.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
AddTabRule(cfg);
|
|
cfg.AddLintRule("tab-rule", [](LintContext& ctx) {
|
|
ctx.Report(1, "duplicate ran");
|
|
});
|
|
LintSummary s = RunLint(cfg, {});
|
|
Check(s.rulesRun == 1, "duplicate name deduplicated");
|
|
Check(s.findings.size() == 1 && s.findings[0].line == 9, "only the first registration ran");
|
|
}
|
|
|
|
// A throwing rule surfaces as a finding, not an unwind.
|
|
{
|
|
Configuration cfg = FixtureConfig();
|
|
cfg.AddLintRule("throws", [](LintContext& ctx) {
|
|
if (ctx.file.filename() == "clean.cpp") throw std::runtime_error("boom");
|
|
});
|
|
LintSummary s = RunLint(cfg, {});
|
|
Check(s.findings.size() == 1, "exception becomes a finding");
|
|
if (!s.findings.empty()) {
|
|
Check(s.findings[0].message.contains("boom"), "finding carries the exception message");
|
|
Check(s.findings[0].line == 0, "exception finding is whole-file (line 0)");
|
|
}
|
|
}
|
|
|
|
// --- Transform (format) cases: scratch dir, never the checked-in fixture ---
|
|
|
|
// Report mode: transform diffs become would-reformat findings; disk untouched.
|
|
{
|
|
Scratch s("report");
|
|
s.Write("a", "hello \nworld\n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
|
|
Check(sum.findings.size() == 1, "one would-reformat finding");
|
|
if (!sum.findings.empty()) {
|
|
Check(sum.findings[0].line == 1, "would-reformat at line 1");
|
|
Check(sum.findings[0].rule == "trim", "attributed to the transform rule");
|
|
Check(sum.findings[0].message == "would reformat", "derived message");
|
|
}
|
|
Check(sum.changedFiles.size() == 1, "changedFiles populated in Report mode");
|
|
Check(!sum.Clean(), "would-reformat gates lint");
|
|
Check(s.Read("a") == "hello \nworld\n", "Report mode never writes");
|
|
}
|
|
|
|
// Apply mode: disk rewritten; a sibling report-only rule's findings are
|
|
// still recorded (the verb, not the driver, ignores them).
|
|
{
|
|
Scratch s("apply");
|
|
s.Write("a", "hello \nworld\n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
cfg.AddLintRule("note", [](LintContext& ctx) { ctx.Report(2, "note"); });
|
|
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");
|
|
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");
|
|
}
|
|
|
|
// Check mode: reported, not written.
|
|
{
|
|
Scratch s("check");
|
|
s.Write("a", "hello \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
LintSummary sum = RunLint(cfg, Mode(LintMode::Check));
|
|
Check(sum.changedFiles.size() == 1, "Check records would-change file");
|
|
Check(!sum.findings.empty(), "Check records would-reformat findings");
|
|
Check(s.Read("a") == "hello \n", "Check mode never writes");
|
|
}
|
|
|
|
// Chaining: rule2 sees rule1's output; both attributed in Report mode.
|
|
{
|
|
Scratch s("chain");
|
|
s.Write("a", "AAA\n");
|
|
Configuration cfg = s.Config({"a"});
|
|
cfg.AddLintRule("one", [](LintContext& ctx) {
|
|
std::string c = ctx.content;
|
|
if (auto p = c.find("AAA"); p != std::string::npos) c.replace(p, 3, "BBB");
|
|
ctx.SetContent(std::move(c));
|
|
});
|
|
cfg.AddLintRule("two", [](LintContext& ctx) {
|
|
std::string c = ctx.content;
|
|
if (auto p = c.find("BBB"); p != std::string::npos) c.replace(p, 3, "CCC");
|
|
ctx.SetContent(std::move(c));
|
|
});
|
|
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
|
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));
|
|
Check(s.Read("a") == "CCC\n", "Apply composes chained transforms");
|
|
Check(app.changedFiles.size() == 1, "chain counts as one changed file");
|
|
}
|
|
|
|
// A throwing transform is reverted — half-applied content never lands.
|
|
{
|
|
Scratch s("throws");
|
|
s.Write("a", "keep\n");
|
|
Configuration cfg = s.Config({"a"});
|
|
cfg.AddLintRule("bad", [](LintContext& ctx) {
|
|
ctx.SetContent("garbage");
|
|
throw std::runtime_error("mid-transform");
|
|
});
|
|
LintSummary sum = RunLint(cfg, Mode(LintMode::Apply));
|
|
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");
|
|
const bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
|
|
[](const LintFinding& f) {
|
|
return f.line == 0 && f.message.contains("mid-transform");
|
|
});
|
|
Check(threw, "exception surfaced as line-0 finding");
|
|
}
|
|
|
|
// CRLF byte fidelity + the final-newline whole-file (line 0) diff edge.
|
|
{
|
|
Scratch s("bytes");
|
|
s.Write("crlf", "x \r\ny\r\n");
|
|
s.Write("noeol", "a\nb");
|
|
Configuration cfg = s.Config({"crlf", "noeol"});
|
|
AddTrimRule(cfg);
|
|
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
|
|
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
|
|
ctx.SetContent(ctx.content + '\n');
|
|
}
|
|
});
|
|
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
|
const bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
|
|
[](const LintFinding& f) {
|
|
return f.rule == "final-newline" && f.line == 0;
|
|
});
|
|
Check(wholeFile, "missing final newline reports as whole-file finding");
|
|
RunLint(cfg, Mode(LintMode::Apply));
|
|
Check(s.Read("crlf") == "x\r\ny\r\n", "trim preserves CRLF endings");
|
|
Check(s.Read("noeol") == "a\nb\n", "final-newline appends exactly one newline");
|
|
}
|
|
|
|
// Mixed rule: Report() and SetContent() from the same rule.
|
|
{
|
|
Scratch s("mixed");
|
|
s.Write("a", "bad \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
cfg.AddLintRule("mixed", [](LintContext& ctx) {
|
|
ctx.Report(1, "flagged");
|
|
std::string c = ctx.content;
|
|
if (auto p = c.find(' '); p != std::string::npos) c.erase(p, 1);
|
|
ctx.SetContent(std::move(c));
|
|
});
|
|
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
|
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");
|
|
}
|
|
|
|
// Idempotency: a second Apply is a no-op; identical SetContent bytes are
|
|
// not a change (compare-by-value, not call-tracking).
|
|
{
|
|
Scratch s("idempotent");
|
|
s.Write("a", "hello \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
LintSummary first = RunLint(cfg, Mode(LintMode::Apply));
|
|
Check(first.changedFiles.size() == 1, "first apply changes the file");
|
|
LintSummary second = RunLint(cfg, Mode(LintMode::Apply));
|
|
Check(second.changedFiles.empty(), "second apply is a no-op");
|
|
Check(second.findings.empty(), "no-op apply has no findings");
|
|
}
|
|
|
|
// --- Suppression directives (engine-level) ---
|
|
|
|
// next-line directive with a rule name suppresses that finding only.
|
|
{
|
|
Scratch s("suppress-next-line");
|
|
s.Write("a", "// lint-disable-next-line flag\nbad\nbad\n");
|
|
Configuration cfg = s.Config({"a"});
|
|
cfg.AddLintRule("flag", [](LintContext& ctx) {
|
|
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
if (ctx.Line(n).contains("bad")) ctx.Report(n, "bad");
|
|
}
|
|
});
|
|
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
|
|
Check(sum.findings.size() == 1, "next-line directive suppresses one finding");
|
|
if (!sum.findings.empty()) Check(sum.findings[0].line == 3, "the unsuppressed line still reports");
|
|
}
|
|
|
|
// next-line suppression also reverts a transform's edit on that line —
|
|
// `format` must not rewrite what lint is told to ignore.
|
|
{
|
|
Scratch s("suppress-transform");
|
|
s.Write("a", "// lint-disable-next-line trim\nkeep \ntrim \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
RunLint(cfg, Mode(LintMode::Apply));
|
|
Check(s.Read("a") == "// lint-disable-next-line trim\nkeep \ntrim\n", "suppressed line keeps its bytes; the unsuppressed one is fixed");
|
|
}
|
|
|
|
// Multiple rule names on one directive (space- or comma-separated).
|
|
{
|
|
Scratch s("suppress-multi");
|
|
s.Write("a", "// lint-disable-next-line flagA, flagB\nbad \nbad \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
cfg.AddLintRule("flagA", [](LintContext& ctx) {
|
|
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
if (ctx.Line(n).contains("bad")) ctx.Report(n, "A");
|
|
}
|
|
});
|
|
cfg.AddLintRule("flagB", [](LintContext& ctx) {
|
|
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
if (ctx.Line(n).contains("bad")) ctx.Report(n, "B");
|
|
}
|
|
});
|
|
AddTrimRule(cfg);
|
|
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
|
|
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");
|
|
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");
|
|
}
|
|
|
|
// file-level all-rules directive silences findings and stops format.
|
|
{
|
|
Scratch s("suppress-file");
|
|
s.Write("a", "// lint-disable-file\nbad \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
|
|
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
|
Check(rep.findings.empty(), "file-level all directive suppresses every finding");
|
|
LintSummary app = RunLint(cfg, Mode(LintMode::Apply));
|
|
Check(app.changedFiles.empty(), "file-level all directive stops format");
|
|
Check(s.Read("a") == "// lint-disable-file\nbad \n", "file bytes untouched");
|
|
}
|
|
|
|
// file-level with a rule name: that rule is dead, others still act.
|
|
{
|
|
Scratch s("suppress-file-rule");
|
|
s.Write("a", "// lint-disable-file flag\nbad \n");
|
|
Configuration cfg = s.Config({"a"});
|
|
AddTrimRule(cfg);
|
|
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
|
|
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
|
|
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");
|
|
}
|
|
|
|
// ---------------- token layer ----------------
|
|
//
|
|
// The source is spelled with escaped literals rather than a raw string so
|
|
// that this file stays lintable by the very rules under test; the scratch
|
|
// file it writes does contain a genuine multi-line raw string.
|
|
{
|
|
constexpr std::string_view Source =
|
|
"#ifdef CRAFTER_LINT_NEVER_DEFINED\n" // 1
|
|
"void HiddenBranch();\n" // 2
|
|
"#endif\n" // 3
|
|
"// a real comment\n" // 4
|
|
"int url = 1; // https://example.com\n" // 5
|
|
"auto raw = R\"raw(spans lines\n" // 6
|
|
" // not a comment\n" // 7
|
|
" int notADecl;\n" // 8
|
|
")raw\";\n"; // 9
|
|
|
|
Scratch s("tokens");
|
|
s.Write("f", Source);
|
|
Configuration cfg = s.Config({"f"});
|
|
cfg.AddLintRule("tokens", [](LintContext& ctx) {
|
|
std::span<const LintToken> toks = ctx.Tokens();
|
|
Check(!toks.empty(), "tokens: file lexes to a non-empty stream");
|
|
|
|
// Every token's offset/length must address its own bytes, or a
|
|
// transform editing at an offset would corrupt the file.
|
|
bool offsetsSound = true;
|
|
for (const LintToken& t : toks) {
|
|
if (t.offset + t.length > ctx.content.size() || ctx.TokenText(t).empty()) offsetsSound = false;
|
|
}
|
|
Check(offsetsSound, "tokens: every offset/length addresses real bytes");
|
|
|
|
// Ordered by offset, so binary search in TokensOnLine is valid.
|
|
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.
|
|
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");
|
|
|
|
// A multi-line raw string is exactly one Literal, comment markers
|
|
// and declarations inside it included.
|
|
auto isRaw = [&](const LintToken& t) { return ctx.TokenText(t).starts_with("R\"raw("); };
|
|
Check(std::ranges::count_if(toks, isRaw) == 1, "tokens: raw string is a single token");
|
|
auto raw = std::ranges::find_if(toks, isRaw);
|
|
if (raw != toks.end()) {
|
|
Check(raw->kind == LintTokenKind::Literal, "tokens: raw string is a Literal");
|
|
Check(raw->line == 6, "tokens: raw string starts on line 6");
|
|
Check(ctx.TokenText(*raw).contains("// not a comment"), "tokens: raw string body kept intact");
|
|
Check(ctx.TokenText(*raw).ends_with(")raw\""), "tokens: raw string spans to its own terminator");
|
|
}
|
|
|
|
// The only comments are the two real ones on lines 4 and 5 — the
|
|
// `//` on line 7 lives inside the raw string.
|
|
std::vector<std::size_t> commentLines;
|
|
for (const LintToken& t : toks) {
|
|
if (t.kind == LintTokenKind::Comment) commentLines.push_back(t.line);
|
|
}
|
|
Check(commentLines == std::vector<std::size_t>{4, 5}, "tokens: only real comments are Comment tokens");
|
|
Check(ctx.LineHasComment(4), "tokens: LineHasComment finds a whole-line comment");
|
|
Check(ctx.LineHasComment(5), "tokens: LineHasComment finds a trailing comment");
|
|
Check(!ctx.LineHasComment(7), "tokens: `//` inside a raw string is not a comment");
|
|
Check(!ctx.LineHasComment(2), "tokens: code-only line has no comment");
|
|
|
|
// TokensOnLine brackets by starting line.
|
|
std::span<const LintToken> line2 = ctx.TokensOnLine(2);
|
|
Check(!line2.empty() && ctx.TokenText(line2.front()) == "void", "tokens: TokensOnLine starts at the line's first token");
|
|
Check(std::ranges::all_of(line2, [](const LintToken& t) { return t.line == 2; }), "tokens: TokensOnLine stays on its line");
|
|
|
|
// SetContent must invalidate the cache, or offsets point into a
|
|
// buffer that no longer exists.
|
|
ctx.SetContent("int replaced;\n");
|
|
std::span<const LintToken> after = ctx.Tokens();
|
|
Check(!after.empty() && ctx.TokenText(after.front()) == "int", "tokens: re-lexed after SetContent");
|
|
Check(std::ranges::none_of(after, [&](const LintToken& t) { return ctx.TokenText(t) == "HiddenBranch"; }), "tokens: stale tokens are dropped after SetContent");
|
|
});
|
|
RunLint(cfg, Mode(LintMode::Report));
|
|
}
|
|
|
|
// CommentStripped over a raw string holding an ODD number of quotes. The
|
|
// character-scanning version treated R"( as an ordinary string open, so the
|
|
// quote inside the body closed it early and every following line was
|
|
// swallowed as literal text — code after the raw string vanished from the
|
|
// stripped view. Lexing gets the extent right.
|
|
{
|
|
constexpr std::string_view Source =
|
|
"auto banner = R\"(he said \"hi)\";\n" // 1: one quote inside the body
|
|
"int afterRaw = 2;\n" // 2: must survive as code
|
|
"// MARKER comment\n" // 3
|
|
"auto plain = \"MARKER text\";\n"; // 4
|
|
|
|
Scratch s("strip-rawstring");
|
|
s.Write("f", Source);
|
|
Configuration cfg = s.Config({"f"});
|
|
cfg.AddLintRule("strip", [](LintContext& ctx) {
|
|
const std::string& code = ctx.CommentStripped();
|
|
Check(code.size() == ctx.content.size(), "strip: byte length preserved");
|
|
Check(std::ranges::count(code, '\n') == std::ranges::count(ctx.content, '\n'), "strip: newlines preserved");
|
|
Check(code.contains("afterRaw"), "strip: code after an odd-quoted raw string survives");
|
|
Check(!code.contains("he said"), "strip: raw string body is blanked");
|
|
Check(!code.contains("MARKER"), "strip: comment and literal bodies are blanked");
|
|
Check(code.contains("auto plain ="), "strip: code around a literal survives");
|
|
// The raw string collapses to R"…" — exactly two quotes, so rules
|
|
// that bracket a literal by counting quotes still work.
|
|
std::string_view line1 = std::string_view(code).substr(0, code.find('\n'));
|
|
Check(std::ranges::count(line1, '"') == 2, "strip: raw string leaves exactly two quotes");
|
|
});
|
|
RunLint(cfg, Mode(LintMode::Report));
|
|
}
|
|
|
|
// Non-C++ extensions are not lexed: GLSL through a C++ lexer would produce
|
|
// plausible-looking nonsense rather than an honest refusal.
|
|
{
|
|
Scratch s("tokens-foreign");
|
|
fs::path shader = s.dir / "f.frag";
|
|
{
|
|
std::ofstream f(shader, std::ios::binary | std::ios::trunc);
|
|
f << "#version 450\nvoid main() { }\n";
|
|
}
|
|
Configuration cfg = s.Config({});
|
|
cfg.shaders.emplace_back(fs::path(shader), "main", ShaderType::Fragment);
|
|
cfg.AddLintRule("no-lex", [](LintContext& ctx) {
|
|
Check(ctx.Tokens().empty(), "tokens: shaders are not lexed as C++");
|
|
});
|
|
RunLint(cfg, Mode(LintMode::Report));
|
|
}
|
|
|
|
// ---------------- AST layer ----------------
|
|
//
|
|
// A standalone source with no imports, so it parses without this project's
|
|
// PCMs and the case stays a unit test.
|
|
{
|
|
constexpr std::string_view Source =
|
|
"#include <string>\n" // 1
|
|
"namespace Demo {\n" // 2
|
|
" enum class Scoped { A, B };\n" // 3
|
|
" enum Plain { C, D };\n" // 4
|
|
" struct Widget {\n" // 5
|
|
" int count;\n" // 6
|
|
" std::string name;\n" // 7
|
|
" };\n" // 8
|
|
" static int GlobalCounter = 0;\n" // 9
|
|
" constexpr int Limit = 10;\n" // 10
|
|
" int Compute(int input) {\n" // 11
|
|
" int local = input;\n" // 12
|
|
" return local;\n" // 13
|
|
" }\n" // 14
|
|
"}\n"; // 15
|
|
|
|
Scratch s("ast");
|
|
s.Write("f", Source);
|
|
Configuration cfg = s.Config({"f"});
|
|
cfg.AddAstLintRule("ast", [](LintContext& ctx) {
|
|
Check(ctx.AstAvailable(), std::format("ast: parse succeeded ({})", ctx.AstUnavailableReason()));
|
|
std::span<const LintDecl> decls = ctx.Decls();
|
|
Check(!decls.empty(), "ast: declarations found");
|
|
|
|
auto find = [&](LintDeclKind kind, std::string_view name) -> const LintDecl* {
|
|
auto it = std::ranges::find_if(decls, [&](const LintDecl& d) { return d.kind == kind && d.name == name; });
|
|
return it == decls.end() ? nullptr : &*it;
|
|
};
|
|
auto parentOf = [&](const LintDecl& d) -> const LintDecl* {
|
|
return d.parent == LintNoParent ? nullptr : &decls[d.parent];
|
|
};
|
|
|
|
// Only this file's declarations: <string> drags in thousands and
|
|
// none of them may appear here.
|
|
Check(std::ranges::none_of(decls, [](const LintDecl& d) { return d.name == "basic_string"; }), "ast: declarations from #included headers are excluded");
|
|
|
|
const LintDecl* demo = find(LintDeclKind::Namespace, "Demo");
|
|
Check(demo != nullptr && demo->line == 2, "ast: namespace found at its own line");
|
|
|
|
// The whole point for enum-class: an exact query, not a regex.
|
|
const LintDecl* scoped = find(LintDeclKind::Enum, "Scoped");
|
|
const LintDecl* plain = find(LintDeclKind::Enum, "Plain");
|
|
Check(scoped != nullptr && scoped->isScopedEnum, "ast: enum class is scoped");
|
|
Check(plain != nullptr && !plain->isScopedEnum, "ast: plain enum is not scoped");
|
|
Check(scoped != nullptr && parentOf(*scoped) == demo, "ast: enum's parent is the namespace");
|
|
|
|
// The whole point for naming: scope without a brace stack.
|
|
const LintDecl* widget = find(LintDeclKind::Struct, "Widget");
|
|
const LintDecl* count = find(LintDeclKind::Field, "count");
|
|
Check(widget != nullptr, "ast: struct found");
|
|
Check(count != nullptr && parentOf(*count) == widget, "ast: field's parent is its struct");
|
|
Check(count != nullptr && count->type == "int", "ast: field carries a resolved type");
|
|
const LintDecl* name = find(LintDeclKind::Field, "name");
|
|
Check(name != nullptr && name->type.contains("string"), "ast: library type resolves");
|
|
|
|
const LintDecl* global = find(LintDeclKind::Variable, "GlobalCounter");
|
|
Check(global != nullptr && global->isStatic, "ast: static storage class reported");
|
|
const LintDecl* limit = find(LintDeclKind::Variable, "Limit");
|
|
Check(limit != nullptr && limit->isConstexpr, "ast: constexpr reported");
|
|
Check(global != nullptr && !global->isConstexpr, "ast: non-constexpr not misreported");
|
|
|
|
const LintDecl* compute = find(LintDeclKind::Function, "Compute");
|
|
const LintDecl* local = find(LintDeclKind::Variable, "local");
|
|
const LintDecl* input = find(LintDeclKind::Parameter, "input");
|
|
Check(compute != nullptr && compute->isDefinition, "ast: function definition reported");
|
|
Check(input != nullptr && parentOf(*input) == compute, "ast: parameter's parent is its function");
|
|
// A local's parent is the function, not the namespace — which is
|
|
// exactly the distinction the brace stack was approximating.
|
|
Check(local != nullptr && parentOf(*local) == compute, "ast: local's parent is its function");
|
|
Check(global != nullptr && parentOf(*global) == demo, "ast: namespace-scope variable's parent is the namespace");
|
|
|
|
// Extents must address the declaration's own bytes so a transform
|
|
// can mark a region untouchable.
|
|
Check(widget != nullptr && widget->end > widget->begin && widget->end <= ctx.content.size(), "ast: extent is in range");
|
|
if (count != nullptr) {
|
|
Check(std::string_view(ctx.content).substr(count->begin, count->end - count->begin) == "int count", "ast: extent brackets exactly the declaration");
|
|
}
|
|
});
|
|
RunLint(cfg, Mode(LintMode::Report));
|
|
}
|
|
|
|
// A module interface unit. libclang reports `export namespace X { … }` as a
|
|
// childless CXCursor_UnexposedDecl and refuses to descend, so without the
|
|
// export-blanking pass every declaration in it would be invisible — which
|
|
// is five of this repo's own interfaces, 677 lines. Blanking the keyword is
|
|
// byte-length preserving, so the lines reported here must match the file.
|
|
//
|
|
// The source lives in fixture/ExportNamespace.cppm.in rather than inline:
|
|
// an `export module` spelled in this file's own text would be picked up by
|
|
// the build's module scanner as a real interface of this project.
|
|
{
|
|
Scratch s("ast-module");
|
|
fs::copy_file(fs::current_path() / "tests" / "Lint" / "fixture" / "ExportNamespace.cppm.in", s.dir / "Demo.cppm", fs::copy_options::overwrite_existing);
|
|
Configuration cfg;
|
|
cfg.path = s.dir;
|
|
cfg.name = "ast-module";
|
|
cfg.outputName = "ast-module";
|
|
cfg.target = HostTarget();
|
|
std::array<fs::path, 1> ifaces = { "Demo" };
|
|
std::array<fs::path, 0> impls = {};
|
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
|
cfg.AddAstLintRule("ast-module", [](LintContext& ctx) {
|
|
Check(ctx.AstAvailable(), std::format("ast-module: parse succeeded ({})", ctx.AstUnavailableReason()));
|
|
std::span<const LintDecl> decls = ctx.Decls();
|
|
auto find = [&](LintDeclKind kind, std::string_view name) -> const LintDecl* {
|
|
auto it = std::ranges::find_if(decls, [&](const LintDecl& d) { return d.kind == kind && d.name == name; });
|
|
return it == decls.end() ? nullptr : &*it;
|
|
};
|
|
const LintDecl* ns = find(LintDeclKind::Namespace, "Demo");
|
|
const LintDecl* mode = find(LintDeclKind::Enum, "Mode");
|
|
const LintDecl* widget = find(LintDeclKind::Struct, "Widget");
|
|
const LintDecl* count = find(LintDeclKind::Field, "count");
|
|
const LintDecl* exported = find(LintDeclKind::Variable, "Exported");
|
|
Check(ns != nullptr, "ast-module: descends into export namespace");
|
|
Check(mode != nullptr && mode->isScopedEnum, "ast-module: enum inside export namespace is visible");
|
|
Check(widget != nullptr && count != nullptr, "ast-module: struct and field inside export namespace are visible");
|
|
Check(exported != nullptr, "ast-module: per-declaration export is visible");
|
|
// Byte fidelity: blanking must not shift a single line.
|
|
Check(ns != nullptr && ns->line == 15, "ast-module: namespace line matches the unblanked file");
|
|
Check(mode != nullptr && mode->line == 16, "ast-module: enum line matches");
|
|
Check(count != nullptr && count->line == 18, "ast-module: field line matches");
|
|
Check(exported != nullptr && exported->line == 20, "ast-module: exported variable line matches");
|
|
});
|
|
RunLint(cfg, Mode(LintMode::Report));
|
|
}
|
|
|
|
// An unavailable AST must fail the run, never look like a clean file. A
|
|
// module unit with no PCMs is the realistic way to hit this.
|
|
{
|
|
Scratch s("ast-unavailable");
|
|
s.Write("f", "import Crafter.DefinitelyNotAModule;\nint Value = 1;\n");
|
|
Configuration cfg = s.Config({"f"});
|
|
bool ran = false;
|
|
cfg.AddAstLintRule("needs-ast", [&ran](LintContext&) { ran = true; });
|
|
LintSummary summary = RunLint(cfg, Mode(LintMode::Report));
|
|
Check(!ran, "ast: rule is skipped when the AST is unavailable");
|
|
Check(summary.errors > 0, "ast: unavailable AST counts as an error");
|
|
Check(!summary.Clean(), "ast: unavailable AST is not Clean");
|
|
// Explained on stderr as one grouped message rather than a finding per
|
|
// (file, rule): a single missing PCM would otherwise bury the one fact
|
|
// that matters. The run still failing is the part that counts, and the
|
|
// two assertions above cover it.
|
|
}
|
|
|
|
// --no-ast skips those rules deliberately and exits normally.
|
|
{
|
|
Scratch s("ast-optout");
|
|
s.Write("f", "import Crafter.DefinitelyNotAModule;\nint Value = 1;\n");
|
|
Configuration cfg = s.Config({"f"});
|
|
cfg.AddAstLintRule("needs-ast", [](LintContext& ctx) { ctx.Report(1, "should not run"); });
|
|
RunLintOptions opts = Mode(LintMode::Report);
|
|
opts.noAst = true;
|
|
LintSummary summary = RunLint(cfg, opts);
|
|
Check(summary.errors == 0, "ast: --no-ast does not error");
|
|
Check(summary.Clean(), "ast: --no-ast run is Clean");
|
|
}
|
|
|
|
if (Failures > 0) {
|
|
std::println(std::cerr, "{} assertions failed", Failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|