Crafter.Build/tests/Lint/main.cpp
Jorijn van der Graaf 55ad128b79
test: lint fails when a configuration's std module cannot be built
A host configuration pointed at a sysroot without libc++ cannot get its
std module built, so its files cannot be parsed. With an AST rule
registered, the run has to skip the rule and fail instead of reporting
the files clean, as it did before the fix. The cache is redirected
around this run only: the std module's directory is made before its
build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:59:38 +01:00

754 lines
38 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;
extern "C" int setenv(const char* name, const char* value, int overwrite);
extern "C" int unsetenv(const char* name);
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.
}
// A configuration whose std module cannot be built is in the build graph
// all the same: its files cannot be parsed, which has to fail the run.
// Its command was cleared, which read as "not a translation unit", and
// every semantic rule passed the files. A sysroot without libc++ is one
// way there. The std module's directory is made before its build fails,
// so the cache is redirected around this run.
{
Scratch s("ast-no-std-module");
s.Write("f", "int Value = 1;\n");
Configuration cfg = s.Config({"f"});
cfg.sysroot = (s.dir / "empty-sysroot").string();
fs::create_directories(cfg.sysroot);
bool ran = false;
cfg.AddAstLintRule("needs-ast", [&ran](LintContext&) { ran = true; });
const char* realCache = std::getenv("XDG_CACHE_HOME");
const std::string saved = realCache ? realCache : "";
setenv("XDG_CACHE_HOME", (s.dir / "cache").c_str(), 1);
LintSummary summary = RunLint(cfg, Mode(LintMode::Report));
if (saved.empty()) unsetenv("XDG_CACHE_HOME");
else setenv("XDG_CACHE_HOME", saved.c_str(), 1);
Check(!ran, "ast: rule is skipped when the std module cannot be built");
Check(summary.errors > 0, "ast: a std module that cannot be built fails the run");
Check(!summary.Clean(), "ast: a std module that cannot be built is not Clean");
}
// --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;
}