2026-07-23 01:24:42 +02:00
|
|
|
// 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();
|
fix(lint): make the reflow guards token-accurate instead of textual
The transforms guard themselves against comments and raw strings before
joining or rewriting a line, because pulling text up past a `//` buries it
and reflowing a multi-line literal changes the string. Those guards were
substring probes over the raw line, so they answered the wrong question:
Line(n).contains("//") fires on // inside a string literal
Line(n).contains("R\"") fires on the characters R" inside a literal, and
MISSES a raw string opened on an earlier line
Both misfire on this repo's own sources. "MARKER" ends in R", and any string
mentioning a lint-disable directive contains //. Two wrapped call sites in
tests/Lint were being left unjoined for exactly these reasons; they join now,
and the results are in this commit.
Replaced by LineHasComment() (added with the token layer) and a new
LineHasMultiLineToken(), which reports whether any token actually covering
that line spans a line boundary — a raw string or a block comment. Backed by
a per-line bitmap derived from the token cache and invalidated with it.
The guards themselves stay: joining across a real comment or a real
multi-line literal is still unsafe, and there are tests for both. What
changes is that they now fire on comments and literals rather than on the
characters that spell them.
format-concat gets narrower as a result. It used to refuse any line
containing R" and ask for a manual fix; now only a literal that genuinely
spans lines does that, because a single-line raw string reduces to R"…" in
the stripped view, fails the plain-literal test, and travels through as an
argument with its spelling intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
|
|
|
for (std::size_t pos = code.find("MARKER"); pos != std::string::npos; pos = code.find("MARKER", pos + 1)) {
|
2026-07-23 01:24:42 +02:00
|
|
|
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));
|
fix(lint): make the reflow guards token-accurate instead of textual
The transforms guard themselves against comments and raw strings before
joining or rewriting a line, because pulling text up past a `//` buries it
and reflowing a multi-line literal changes the string. Those guards were
substring probes over the raw line, so they answered the wrong question:
Line(n).contains("//") fires on // inside a string literal
Line(n).contains("R\"") fires on the characters R" inside a literal, and
MISSES a raw string opened on an earlier line
Both misfire on this repo's own sources. "MARKER" ends in R", and any string
mentioning a lint-disable directive contains //. Two wrapped call sites in
tests/Lint were being left unjoined for exactly these reasons; they join now,
and the results are in this commit.
Replaced by LineHasComment() (added with the token layer) and a new
LineHasMultiLineToken(), which reports whether any token actually covering
that line spans a line boundary — a raw string or a block comment. Backed by
a per-line bitmap derived from the token cache and invalidated with it.
The guards themselves stay: joining across a real comment or a real
multi-line literal is still unsafe, and there are tests for both. What
changes is that they now fire on comments and literals rather than on the
characters that spell them.
format-concat gets narrower as a result. It used to refuse any line
containing R" and ask for a manual fix; now only a literal that genuinely
spans lines does that, because a single-line raw string reduces to R"…" in
the stripped view, fails the plain-literal test, and travels through as an
argument with its spelling intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
|
|
|
Check(s.Read("a") == "// lint-disable-next-line trim\nkeep \ntrim\n", "suppressed line keeps its bytes; the unsuppressed one is fixed");
|
2026-07-23 01:24:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
feat(lint): libclang-backed token layer
Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the
substrate the rules will move onto. Nothing consumes it yet.
libclang is dlopen'd rather than linked: -lclang would break the mingw and
MSVC cross-builds at link time and would put a libclang.so.NN runtime
dependency into the otherwise self-contained release tarballs. The clang-c
header is used for its declarations only, and the function-pointer table is
typed with decltype so the signatures cannot drift from the real API.
Three properties this buys that the hand-rolled scanners could not have:
- a raw string literal or block comment is ONE token, so the documented
"raw string literals are not recognized" limitation goes away;
- `//` inside a literal is not a comment, so LineHasComment() replaces the
Line(n).contains("//") probes that false-positive on it;
- tokens cover preprocessor branches that are inactive for the host, since
clang_tokenize lexes rather than evaluates #if. Token rules therefore
keep seeing every platform's code, which an AST could not offer.
The parse backing the tokenizer is expected to fail on module units — no
PCMs, no build flags — and that is fine, because lexing has no semantic
prerequisites. Verified in the new tests.
LintSummary::Clean() now counts `errors`. It previously ignored them, so an
infrastructure failure that produced no findings reported clean and exited
0; a missing libclang would have been exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
|
|
|
// ---------------- 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");
|
refactor(lint): derive CommentStripped from tokens, drop the char scanner
StripComments hand-scanned five states over raw characters and could not see
a raw string literal, which was documented as a v1 limitation. It was worse
than "does not recognise them": an odd number of quotes inside a raw string
desynchronised the scanner for the rest of the file. Simulated on the new
fixture, the old output was
auto banner = R" "hi)" \n \n ... "MARKER text"
— the code after the raw string blanked away, and the *contents* of a later
string literal left standing as if it were code. Every rule reading
CommentStripped() saw that. There are 33 raw strings across 6 files here,
including the two largest.
The replacement projects the token stream onto a copy of the buffer, blanking
comment tokens whole and the bodies of string/character literals. Editing a
copy in place makes the length-preserving contract structural rather than
something each branch has to remember, and the token extents make raw
strings, escapes, encoding prefixes and '"' all fall out for free. A raw
string reduces to R"…" so rules that bracket a literal by counting quotes
keep working. Digit separators (1'000) are excluded by requiring the text
before the quote to be an encoding prefix.
The public contract is unchanged, so no rule needed editing, and `lint` over
this repo produces byte-identical output to the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:59:11 +02:00
|
|
|
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");
|
feat(lint): libclang-backed token layer
Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the
substrate the rules will move onto. Nothing consumes it yet.
libclang is dlopen'd rather than linked: -lclang would break the mingw and
MSVC cross-builds at link time and would put a libclang.so.NN runtime
dependency into the otherwise self-contained release tarballs. The clang-c
header is used for its declarations only, and the function-pointer table is
typed with decltype so the signatures cannot drift from the real API.
Three properties this buys that the hand-rolled scanners could not have:
- a raw string literal or block comment is ONE token, so the documented
"raw string literals are not recognized" limitation goes away;
- `//` inside a literal is not a comment, so LineHasComment() replaces the
Line(n).contains("//") probes that false-positive on it;
- tokens cover preprocessor branches that are inactive for the host, since
clang_tokenize lexes rather than evaluates #if. Token rules therefore
keep seeing every platform's code, which an AST could not offer.
The parse backing the tokenizer is expected to fail on module units — no
PCMs, no build flags — and that is fine, because lexing has no semantic
prerequisites. Verified in the new tests.
LintSummary::Clean() now counts `errors`. It previously ignored them, so an
infrastructure failure that produced no findings reported clean and exited
0; a missing libclang would have been exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
|
|
|
});
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 01:24:42 +02:00
|
|
|
if (Failures > 0) {
|
|
|
|
|
std::println(std::cerr, "{} assertions failed", Failures);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|