Crafter.Build/tests/Lint/main.cpp
Jorijn van der Graaf 29888fc5ba feat(lint): AST layer over libclang cursors
Adds LintContext::Decls() — clang's view of the declarations written in the
file — plus AddAstLintRule to register a rule that reads it. No rule uses it
yet; the three that will are migrated separately.

The declarations come back as a flat vector with parent indices rather than an
opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback
hops back into project.so per node, and "is this at namespace scope or inside a
function?" becomes an index lookup instead of a hand-rolled brace stack.

Three things had to be solved for this to work at all on this codebase.

libclang cannot see through `export`. A C++20 export declaration has no
CXCursorKind, so `export namespace Crafter { … }` arrives as a childless
CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the
eleven interfaces here are written that way — 677 lines, including
Configuration and LintContext, yielding zero usable cursors. A plain
`namespace` IS descended into, so the fix is to blank the keyword before
parsing, byte-length preserving so every line and column still lands on the
original file. `export module` is left alone or the unit stops being a module
interface. Verified end-to-end against a fixture whose asserted line numbers
match the unblanked file.

PCMs are flag-locked, so each file has to parse with the flags that built it.
CollectConfigSources now records which Configuration owns each source instead
of flattening to a set, because three regimes coexist: the library, each test
(carrying its own target, defines and -march), and project.cpp, which Build
never touches and which therefore gets no command at all.

libclang resolves its builtin headers relative to its own install path, which
need not match the clang++ that wrote the PCMs. When it doesn't, every parse
dies on "'stddef.h' file not found", so -resource-dir is passed explicitly
from `clang++ -print-resource-dir`.

Two flags on each declaration replace what would otherwise become more
substring denylists: isExternC, and isForeignApi for a declaration that binds
to an entity declared outside the project root — resolved through
clang_getCursorReferenced and the same inside-the-root test the dependency
walk already uses. Parameters and fields inherit it, so an exemption covers a
whole signature rather than the one node that named the foreign entity.

Failure is never silent. A fatal diagnostic leaves a fragment that is
indistinguishable from a file declaring nothing, so it is reported as an error
instead: the rule is skipped, a finding explains why, and summary.errors makes
the run fail. --no-ast opts out deliberately and exits normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00

723 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");
Check(std::any_of(summary.findings.begin(), summary.findings.end(), [](const LintFinding& f) { return f.message.contains("needs an AST"); }), "ast: a finding explains why");
}
// --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;
}