Some checks failed
CI / build-test-release (push) Failing after 7m15s
435 lines
19 KiB
C++
435 lines
19 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");
|
|
}
|
|
|
|
if (Failures > 0) {
|
|
std::println(std::cerr, "{} assertions failed", Failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|