// 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(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 impls) const { Configuration cfg; cfg.path = dir; cfg.name = "fmt-fixture"; cfg.outputName = "fmt-fixture"; cfg.target = HostTarget(); std::array 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 ifaces = {}; std::array 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 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 commentLines; for (const LintToken& t : toks) { if (t.kind == LintTokenKind::Comment) commentLines.push_back(t.line); } Check(commentLines == std::vector{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 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 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 \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 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: 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 ifaces = { "Demo" }; std::array impls = {}; cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.AddAstLintRule("ast-module", [](LintContext& ctx) { Check(ctx.AstAvailable(), std::format("ast-module: parse succeeded ({})", ctx.AstUnavailableReason())); std::span 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; }