This commit is contained in:
parent
a25b0a1ded
commit
8892154b28
70 changed files with 2780 additions and 596 deletions
444
implementations/Crafter.Build-Lint.cpp
Normal file
444
implementations/Crafter.Build-Lint.cpp
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
export module Crafter.Build:Lint_impl;
|
||||
import std;
|
||||
import :Lint;
|
||||
import :Clang;
|
||||
import :Platform;
|
||||
import :Progress;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
// Blank //-comments, /*...*/ comments and string/char literal bodies to
|
||||
// spaces while copying '\n' through, so byte offsets and line numbers in
|
||||
// the result match the original text. Raw string literals are not
|
||||
// recognized (documented v1 limitation) — their bodies pass through as
|
||||
// ordinary string content until the first '"'.
|
||||
std::string StripComments(std::string_view src) {
|
||||
enum class State { Code, LineComment, BlockComment, String, Char };
|
||||
std::string out;
|
||||
out.reserve(src.size());
|
||||
State state = State::Code;
|
||||
for (std::size_t i = 0; i < src.size(); ++i) {
|
||||
char c = src[i];
|
||||
char next = i + 1 < src.size() ? src[i + 1] : '\0';
|
||||
switch (state) {
|
||||
case State::Code:
|
||||
if (c == '/' && next == '/') {
|
||||
state = State::LineComment;
|
||||
out += " ";
|
||||
++i;
|
||||
} else if (c == '/' && next == '*') {
|
||||
state = State::BlockComment;
|
||||
out += " ";
|
||||
++i;
|
||||
} else if (c == '"') {
|
||||
state = State::String;
|
||||
out += c; // keep the delimiter so quoting stays visible
|
||||
} else if (c == '\'') {
|
||||
state = State::Char;
|
||||
out += c;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
break;
|
||||
case State::LineComment:
|
||||
if (c == '\n') {
|
||||
state = State::Code;
|
||||
out += c;
|
||||
} else {
|
||||
out += ' ';
|
||||
}
|
||||
break;
|
||||
case State::BlockComment:
|
||||
if (c == '*' && next == '/') {
|
||||
state = State::Code;
|
||||
out += " ";
|
||||
++i;
|
||||
} else {
|
||||
out += c == '\n' ? '\n' : ' ';
|
||||
}
|
||||
break;
|
||||
case State::String:
|
||||
case State::Char: {
|
||||
char delim = state == State::String ? '"' : '\'';
|
||||
if (c == '\\' && next != '\0') {
|
||||
out += " ";
|
||||
++i;
|
||||
if (next == '\n') out.back() = '\n';
|
||||
} else if (c == delim) {
|
||||
state = State::Code;
|
||||
out += c;
|
||||
} else {
|
||||
out += c == '\n' ? '\n' : ' ';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string_view> SplitLines(std::string_view content) {
|
||||
std::vector<std::string_view> lines;
|
||||
std::size_t start = 0;
|
||||
while (start <= content.size()) {
|
||||
std::size_t end = content.find('\n', start);
|
||||
if (end == std::string_view::npos) {
|
||||
// Skip a phantom empty final line after a trailing '\n'.
|
||||
if (start < content.size()) lines.push_back(content.substr(start));
|
||||
break;
|
||||
}
|
||||
lines.push_back(content.substr(start, end - start));
|
||||
start = end + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
bool PathInsideRoot(const fs::path& p, const fs::path& root) {
|
||||
fs::path rel = fs::weakly_canonical(p).lexically_relative(fs::weakly_canonical(root));
|
||||
return !rel.empty() && *rel.begin() != "..";
|
||||
}
|
||||
|
||||
// Depth-first walk over the dependency graph keeping only Configurations
|
||||
// whose path lies inside the project root — GitProject / external deps
|
||||
// live under the global cache and are foreign code: they contribute
|
||||
// neither rules nor files. Root-first order so the root's rules win the
|
||||
// by-name dedup.
|
||||
std::vector<Configuration*> CollectLocalConfigs(Configuration& root, const fs::path& projectRoot) {
|
||||
std::vector<Configuration*> local;
|
||||
std::unordered_set<Configuration*> seen;
|
||||
std::function<void(Configuration*)> walk = [&](Configuration* c) {
|
||||
if (!seen.insert(c).second) return;
|
||||
if (PathInsideRoot(fs::absolute(c->path), projectRoot)) {
|
||||
local.push_back(c);
|
||||
}
|
||||
for (Configuration* dep : c->dependencies) walk(dep);
|
||||
};
|
||||
walk(&root);
|
||||
return local;
|
||||
}
|
||||
|
||||
void CollectConfigSources(const Configuration& c, std::set<fs::path>& files) {
|
||||
for (const std::unique_ptr<Module>& mod : c.interfaces) {
|
||||
files.insert(fs::path(std::format("{}.cppm", mod->path.string())));
|
||||
for (const std::unique_ptr<ModulePartition>& part : mod->partitions) {
|
||||
files.insert(fs::path(std::format("{}.cppm", part->path.string())));
|
||||
}
|
||||
}
|
||||
for (const Implementation& impl : c.implementations) {
|
||||
files.insert(fs::path(std::format("{}.cpp", impl.path.string())));
|
||||
}
|
||||
// cFiles/cuda resolve against cwd at build time (see Build's compile
|
||||
// loops); mirror that here.
|
||||
for (const fs::path& cf : c.cFiles) {
|
||||
files.insert(fs::absolute(fs::path(std::format("{}.c", cf.string()))).lexically_normal());
|
||||
}
|
||||
for (const fs::path& cu : c.cuda) {
|
||||
files.insert(fs::absolute(fs::path(std::format("{}.cu", cu.string()))).lexically_normal());
|
||||
}
|
||||
for (const Shader& shader : c.shaders) {
|
||||
files.insert(fs::absolute(shader.path).lexically_normal());
|
||||
}
|
||||
// files/buildFiles/assets are deliberately excluded: data shipped or
|
||||
// referenced by the build, not source code.
|
||||
}
|
||||
}
|
||||
|
||||
std::string LintContext::Extension() const {
|
||||
return file.extension().string();
|
||||
}
|
||||
|
||||
std::string_view LintContext::Line(std::size_t n) const {
|
||||
if (n == 0 || n > lines.size()) return {};
|
||||
return lines[n - 1];
|
||||
}
|
||||
|
||||
const std::string& LintContext::CommentStripped() {
|
||||
if (!commentStrippedCache) {
|
||||
commentStrippedCache = StripComments(content);
|
||||
}
|
||||
return *commentStrippedCache;
|
||||
}
|
||||
|
||||
void LintContext::Report(std::size_t line, std::string message) {
|
||||
sink->push_back({file, line, activeRule, std::move(message)});
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Scan raw lines for suppression comments. Raw, not stripped: the
|
||||
// directives ARE comments. A next-line directive on (0-based) line i
|
||||
// targets 1-based line i + 2 — the line below it.
|
||||
LintSuppressions ParseSuppressions(std::span<const std::string_view> lines) {
|
||||
LintSuppressions s;
|
||||
constexpr std::string_view NextLineMarker = "lint-disable-next-line";
|
||||
constexpr std::string_view FileMarker = "lint-disable-file";
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
||||
std::size_t slash = lines[i].find("//");
|
||||
if (slash == std::string_view::npos) continue;
|
||||
bool nextLine = true;
|
||||
std::size_t marker = lines[i].find(NextLineMarker, slash);
|
||||
std::size_t markerLen = NextLineMarker.size();
|
||||
if (marker == std::string_view::npos) {
|
||||
nextLine = false;
|
||||
marker = lines[i].find(FileMarker, slash);
|
||||
markerLen = FileMarker.size();
|
||||
}
|
||||
if (marker == std::string_view::npos) continue;
|
||||
// Everything after the marker is rule names; none = all rules.
|
||||
std::string_view rest = lines[i].substr(marker + markerLen);
|
||||
std::vector<std::string> names;
|
||||
std::size_t pos = 0;
|
||||
while (pos < rest.size()) {
|
||||
if (rest[pos] == ' ' || rest[pos] == '\t' || rest[pos] == ',' || rest[pos] == '\r') { ++pos; continue; }
|
||||
std::size_t end = rest.find_first_of(" \t,\r", pos);
|
||||
if (end == std::string_view::npos) end = rest.size();
|
||||
names.emplace_back(rest.substr(pos, end - pos));
|
||||
pos = end;
|
||||
}
|
||||
if (nextLine) {
|
||||
if (names.empty()) s.lineAll.insert(i + 2);
|
||||
else for (std::string& n : names) s.lineRules[i + 2].insert(std::move(n));
|
||||
} else {
|
||||
if (names.empty()) s.fileAll = true;
|
||||
else for (std::string& n : names) s.fileRules.insert(std::move(n));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
bool LintContext::Suppressed(std::string_view rule, std::size_t line) {
|
||||
if (!suppressionsCache) suppressionsCache = ParseSuppressions(lines);
|
||||
const LintSuppressions& s = *suppressionsCache;
|
||||
if (s.fileAll || s.fileRules.contains(std::string(rule))) return true;
|
||||
if (line == 0) return false;
|
||||
if (s.lineAll.contains(line)) return true;
|
||||
if (auto it = s.lineRules.find(line); it != s.lineRules.end()) return it->second.contains(std::string(rule));
|
||||
return false;
|
||||
}
|
||||
|
||||
void LintContext::SetContent(std::string newContent) {
|
||||
content = std::move(newContent);
|
||||
lines = SplitLines(content);
|
||||
commentStrippedCache.reset();
|
||||
suppressionsCache.reset(); // line numbers may have shifted — re-parse
|
||||
}
|
||||
|
||||
void Configuration::AddLintRule(std::string name, std::function<void(LintContext&)> check) {
|
||||
lintRules.push_back({std::move(name), std::move(check)});
|
||||
}
|
||||
|
||||
LintSummary Crafter::RunLint(Configuration& projectCfg, const RunLintOptions& opts) {
|
||||
LintSummary summary;
|
||||
|
||||
fs::path projectRoot = opts.projectFile.empty()
|
||||
? fs::absolute(projectCfg.path)
|
||||
: opts.projectFile.parent_path();
|
||||
|
||||
std::vector<Configuration*> localConfigs = CollectLocalConfigs(projectCfg, projectRoot);
|
||||
|
||||
// Collect rules root-first, dedup by name (first registration wins).
|
||||
std::vector<const LintRule*> rules;
|
||||
std::unordered_set<std::string_view> ruleNames;
|
||||
for (Configuration* c : localConfigs) {
|
||||
for (const LintRule& rule : c->lintRules) {
|
||||
if (ruleNames.insert(rule.name).second) rules.push_back(&rule);
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.empty()) {
|
||||
summary.noRulesDefined = true;
|
||||
std::println(std::cerr,
|
||||
R"msg(No lint rules defined.
|
||||
Register rules in project.cpp before returning the Configuration:
|
||||
|
||||
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {{
|
||||
if (ctx.Extension() != ".cpp" && ctx.Extension() != ".cppm") return;
|
||||
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {{
|
||||
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
|
||||
}}
|
||||
}});
|
||||
|
||||
A rule that calls ctx.SetContent(newContent) is a transform: `crafter-build
|
||||
format` applies it to disk, and `crafter-build lint` reports where it would.
|
||||
`crafter-build lint` runs every rule over the project's own sources.)msg");
|
||||
return summary;
|
||||
}
|
||||
|
||||
std::erase_if(rules, [&](const LintRule* r) { return !MatchAny(opts.globs, r->name); });
|
||||
summary.rulesRun = rules.size();
|
||||
|
||||
if (opts.listOnly) {
|
||||
for (const LintRule* rule : rules) std::println("{}", rule->name);
|
||||
return summary;
|
||||
}
|
||||
if (rules.empty()) {
|
||||
std::println("No lint rules matched.");
|
||||
return summary;
|
||||
}
|
||||
|
||||
std::set<fs::path> files;
|
||||
for (Configuration* c : localConfigs) {
|
||||
CollectConfigSources(*c, files);
|
||||
for (const Test& t : c->tests) CollectConfigSources(t.config, files);
|
||||
}
|
||||
if (!opts.projectFile.empty()) files.insert(opts.projectFile);
|
||||
|
||||
fs::path cwd = fs::current_path();
|
||||
auto shown = [&cwd](const fs::path& p) {
|
||||
return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p;
|
||||
};
|
||||
|
||||
for (const fs::path& file : files) {
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
if (!in) continue; // config parse already read it; a vanished file fails the build first
|
||||
std::stringstream buffer;
|
||||
buffer << in.rdbuf();
|
||||
LintContext ctx;
|
||||
ctx.file = file;
|
||||
ctx.content = std::move(buffer).str();
|
||||
ctx.lines = SplitLines(ctx.content);
|
||||
ctx.sink = &summary.findings;
|
||||
++summary.filesLinted;
|
||||
const std::string original = ctx.content;
|
||||
for (const LintRule* rule : rules) {
|
||||
ctx.activeRule = rule->name;
|
||||
// Snapshot for transform diffing — and the revert point if the
|
||||
// rule throws, so a half-applied transform never reaches disk
|
||||
// and chained rules see clean input.
|
||||
std::string before = ctx.content;
|
||||
try {
|
||||
rule->check(ctx);
|
||||
} catch (const std::exception& e) {
|
||||
// Never let a rule's exception unwind across the project
|
||||
// DLL boundary — surface it as a finding instead.
|
||||
ctx.SetContent(std::move(before));
|
||||
ctx.Report(0, std::format("rule '{}' threw: {}", rule->name, e.what()));
|
||||
++summary.errors;
|
||||
if (opts.mode != LintMode::Report) {
|
||||
// Report mode prints it with the findings; the other
|
||||
// modes don't print findings, so surface it here.
|
||||
std::println(std::cerr, "{}: rule '{}' threw: {}", shown(file).string(), rule->name, e.what());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Transform detection is compare-by-value: a rule that SetContents
|
||||
// identical bytes is not a change. The mutated content carries
|
||||
// forward in every mode so chained rules compose identically
|
||||
// whether or not this run writes.
|
||||
if (ctx.content == before) continue;
|
||||
// File-level suppression disables the transform outright — in
|
||||
// every mode, so `format` never rewrites a suppressed file.
|
||||
if (ctx.Suppressed(rule->name, 0)) {
|
||||
ctx.SetContent(std::move(before));
|
||||
continue;
|
||||
}
|
||||
// Diff before/after. Same line count → per-line handling:
|
||||
// suppressed changed lines are REVERTED (all modes — suppression
|
||||
// must also stop `format`), the rest yield would-reformat
|
||||
// findings in the dry modes. Different count (or no differing
|
||||
// line — SplitLines hides a trailing '\n', the final-newline
|
||||
// case) → one whole-file finding; count-changing transforms
|
||||
// handle per-line suppression themselves (see
|
||||
// LintContext::Suppressed).
|
||||
std::vector<std::string_view> beforeLines = SplitLines(before);
|
||||
bool anyLineDiffers = false;
|
||||
if (beforeLines.size() == ctx.lines.size()) {
|
||||
std::vector<std::size_t> reverted;
|
||||
for (std::size_t i = 0; i < beforeLines.size(); ++i) {
|
||||
if (beforeLines[i] == ctx.lines[i]) continue;
|
||||
anyLineDiffers = true;
|
||||
if (ctx.Suppressed(rule->name, i + 1)) {
|
||||
reverted.push_back(i);
|
||||
} else if (opts.mode != LintMode::Apply) {
|
||||
summary.findings.push_back({file, i + 1, rule->name, "would reformat"});
|
||||
}
|
||||
}
|
||||
if (!reverted.empty()) {
|
||||
std::string rebuilt;
|
||||
rebuilt.reserve(ctx.content.size());
|
||||
std::size_t next = 0;
|
||||
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
|
||||
rebuilt += (next < reverted.size() && reverted[next] == i) ? beforeLines[i] : ctx.lines[i];
|
||||
if (next < reverted.size() && reverted[next] == i) ++next;
|
||||
if (i + 1 < ctx.lines.size() || ctx.content.ends_with('\n')) rebuilt += '\n';
|
||||
}
|
||||
ctx.SetContent(std::move(rebuilt));
|
||||
}
|
||||
}
|
||||
if (!anyLineDiffers && opts.mode != LintMode::Apply && ctx.content != before) {
|
||||
summary.findings.push_back({file, 0, rule->name, "would reformat"});
|
||||
}
|
||||
}
|
||||
// Drop findings the file's directives suppress — covers Report()
|
||||
// calls from any rule (custom ones included) plus the derived
|
||||
// would-reformat findings above. Line-0 findings only match
|
||||
// file-level directives.
|
||||
std::erase_if(summary.findings, [&](const LintFinding& f) {
|
||||
return f.file == file && ctx.Suppressed(f.rule, f.line);
|
||||
});
|
||||
if (ctx.content != original) {
|
||||
summary.changedFiles.push_back(file);
|
||||
if (opts.mode == LintMode::Apply) {
|
||||
std::ofstream out(file, std::ios::binary | std::ios::trunc);
|
||||
out.write(ctx.content.data(), static_cast<std::streamsize>(ctx.content.size()));
|
||||
out.close();
|
||||
if (!out) {
|
||||
std::println(std::cerr, "failed to write {}", shown(file).string());
|
||||
++summary.errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(summary.findings.begin(), summary.findings.end(),
|
||||
[](const LintFinding& a, const LintFinding& b) {
|
||||
return std::tie(a.file, a.line) < std::tie(b.file, b.line);
|
||||
});
|
||||
|
||||
Progress::Clear();
|
||||
switch (opts.mode) {
|
||||
case LintMode::Report: {
|
||||
std::unordered_set<std::string> filesWithFindings;
|
||||
for (const LintFinding& f : summary.findings) {
|
||||
filesWithFindings.insert(f.file.string());
|
||||
std::println("{}:{}: warning: {} [{}]", shown(f.file).string(), f.line, f.message, f.rule);
|
||||
}
|
||||
if (summary.findings.empty()) {
|
||||
std::println("Lint clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
|
||||
} else {
|
||||
std::println("{} finding(s) in {} of {} files ({} rules)", summary.findings.size(), filesWithFindings.size(), summary.filesLinted, summary.rulesRun);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LintMode::Check: {
|
||||
// gofmt -l style: the paths alone, then a one-line verdict.
|
||||
// Report-only findings are lint's business, not printed here.
|
||||
for (const fs::path& f : summary.changedFiles) {
|
||||
std::println("{}", shown(f).string());
|
||||
}
|
||||
if (summary.changedFiles.empty()) {
|
||||
std::println("Format check clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
|
||||
} else {
|
||||
std::println("{} file(s) would be reformatted", summary.changedFiles.size());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LintMode::Apply: {
|
||||
for (const fs::path& f : summary.changedFiles) {
|
||||
std::println("formatted: {}", shown(f).string());
|
||||
}
|
||||
if (summary.changedFiles.empty()) {
|
||||
std::println("Nothing to format: {} files, {} rules", summary.filesLinted, summary.rulesRun);
|
||||
} else {
|
||||
std::println("Formatted {} of {} files ({} rules)", summary.changedFiles.size(), summary.filesLinted, summary.rulesRun);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue