// 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"); } if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; }