Linting a march whose PCMs are not built produced 102 findings — 34 files times three AST rules — for a single fact: the project has not been built for that configuration. The one actionable sentence was buried. Now recorded per file and reported once, naming the rules that could not run, how many files were affected, one example reason, and what to do about it. Still one error, so the run still fails; --no-ast remains the way to proceed without building. 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);
|
|
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");
|
|
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));
|
|
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"; });
|
|
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");
|
|
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));
|
|
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));
|
|
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"; });
|
|
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));
|
|
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;
|
|
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"; });
|
|
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));
|
|
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.
|
|
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) {
|
|
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;
|
|
}
|