Crafter.Build/implementations/Crafter.Build-Lint.cpp

681 lines
30 KiB
C++
Raw Normal View History

2026-07-23 01:24:42 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
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
#include <clang-c/Index.h>
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
#include <windows.h>
#else
#include <dlfcn.h>
#endif
2026-07-23 01:24:42 +02:00
export module Crafter.Build:Lint_impl;
import std;
import :Lint;
import :Clang;
import :Platform;
import :Progress;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
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
// ---------------- libclang ----------------
//
// libclang is loaded at runtime rather than linked. Linking -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 every call goes through a pointer resolved here. Failure to load is
// a hard error surfaced once by RunLint — there is deliberately no second,
// weaker lexer to fall back to, because two engines disagreeing about what
// is a comment is a worse failure than not running.
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
using LibHandle = HMODULE;
LibHandle OpenLibrary(const std::string& name) { return LoadLibraryA(name.c_str()); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return reinterpret_cast<void*>(GetProcAddress(handle, name.c_str())); }
constexpr std::string_view LibClangNames[] = {"libclang.dll", "clang.dll"};
#else
using LibHandle = void*;
LibHandle OpenLibrary(const std::string& name) { return dlopen(name.c_str(), RTLD_NOW | RTLD_LOCAL); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return dlsym(handle, name.c_str()); }
constexpr std::string_view LibClangNames[] = {
"libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1",
"libclang.so.1", "libclang.dylib",
};
#endif
// Signatures come from decltype on the header's declarations, so they can
// never drift from the real API. decltype is unevaluated, so naming the
// functions here does not create a link-time reference to them.
struct LibClang {
LibHandle handle = nullptr;
std::string error; // non-empty exactly when handle is null
decltype(&clang_createIndex) CreateIndex = nullptr;
decltype(&clang_disposeIndex) DisposeIndex = nullptr;
decltype(&clang_parseTranslationUnit) ParseTranslationUnit = nullptr;
decltype(&clang_disposeTranslationUnit) DisposeTranslationUnit = nullptr;
decltype(&clang_getFile) GetFile = nullptr;
decltype(&clang_getLocationForOffset) GetLocationForOffset = nullptr;
decltype(&clang_getRange) GetRange = nullptr;
decltype(&clang_getRangeStart) GetRangeStart = nullptr;
decltype(&clang_getRangeEnd) GetRangeEnd = nullptr;
decltype(&clang_getFileLocation) GetFileLocation = nullptr;
decltype(&clang_tokenize) Tokenize = nullptr;
decltype(&clang_disposeTokens) DisposeTokens = nullptr;
decltype(&clang_getTokenKind) GetTokenKind = nullptr;
decltype(&clang_getTokenExtent) GetTokenExtent = nullptr;
};
LibClang LoadLibClang() {
LibClang lib;
std::vector<std::string> tried;
// CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the LIBCXX_DIR /
// CRAFTER_MINGW_DIR overrides used elsewhere. It is exclusive: pointing
// it at a broken path must fail loudly rather than quietly succeed with
// some other libclang, or the override is useless for diagnosing which
// library is actually in play.
std::vector<std::string> candidates;
if (const char* pinned = std::getenv("CRAFTER_BUILD_LIBCLANG"); pinned && *pinned) {
candidates.emplace_back(pinned);
} else {
for (std::string_view name : LibClangNames) candidates.emplace_back(name);
}
auto join = [](const std::vector<std::string>& parts) {
std::string joined;
for (const std::string& part : parts) {
if (!joined.empty()) joined += ", ";
joined += part;
}
return joined;
};
for (const std::string& name : candidates) {
lib.handle = OpenLibrary(name);
if (lib.handle) break;
tried.push_back(name);
}
if (!lib.handle) {
lib.error = std::format("could not load libclang (tried {}); install clang, or point CRAFTER_BUILD_LIBCLANG at it", join(tried));
return lib;
}
std::vector<std::string> missing;
auto bind = [&](auto& slot, const std::string& name) {
slot = reinterpret_cast<std::remove_reference_t<decltype(slot)>>(LibrarySymbol(lib.handle, name));
if (!slot) missing.push_back(name);
};
bind(lib.CreateIndex, "clang_createIndex");
bind(lib.DisposeIndex, "clang_disposeIndex");
bind(lib.ParseTranslationUnit, "clang_parseTranslationUnit");
bind(lib.DisposeTranslationUnit, "clang_disposeTranslationUnit");
bind(lib.GetFile, "clang_getFile");
bind(lib.GetLocationForOffset, "clang_getLocationForOffset");
bind(lib.GetRange, "clang_getRange");
bind(lib.GetRangeStart, "clang_getRangeStart");
bind(lib.GetRangeEnd, "clang_getRangeEnd");
bind(lib.GetFileLocation, "clang_getFileLocation");
bind(lib.Tokenize, "clang_tokenize");
bind(lib.DisposeTokens, "clang_disposeTokens");
bind(lib.GetTokenKind, "clang_getTokenKind");
bind(lib.GetTokenExtent, "clang_getTokenExtent");
if (!missing.empty()) {
lib.handle = nullptr;
lib.error = std::format("loaded {} but it is missing {}", candidates.front(), join(missing));
}
return lib;
}
const LibClang& Clang() {
static const LibClang Lib = LoadLibClang();
return Lib;
}
// The -x language for a source file, or empty when we must not lex it.
// .cppm needs c++-module explicitly: libclang does not infer a module unit
// from the extension and silently treats every flag as a linker input if
// left to guess. Shaders and data files return empty — lexing GLSL as C++
// yields plausible-looking nonsense.
std::string_view LexLanguage(const fs::path& file) {
std::string ext = file.extension().string();
if (ext == ".cppm" || ext == ".ixx") return "c++-module";
if (ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".cu") return "c++";
if (ext == ".c") return "c";
return {};
}
LintTokenKind MapTokenKind(CXTokenKind kind) {
switch (kind) {
case CXToken_Punctuation: return LintTokenKind::Punctuation;
case CXToken_Keyword: return LintTokenKind::Keyword;
case CXToken_Identifier: return LintTokenKind::Identifier;
case CXToken_Literal: return LintTokenKind::Literal;
case CXToken_Comment: return LintTokenKind::Comment;
}
return LintTokenKind::Punctuation;
}
// Lex `content` as if it were `file`, returning tokens in source order.
//
// The buffer is handed over as an unsaved file, so a transform's in-memory
// edits are what get lexed — never the stale bytes on disk. The parse is
// expected to fail (a module unit's `import std;` cannot resolve without
// PCMs, and we deliberately do not supply the build's flags here); that
// does not matter, because clang_tokenize re-lexes the buffer and lexing
// has no semantic prerequisites. SingleFileParse keeps it from chasing
// #includes it does not need.
std::vector<LintToken> LexFile(const fs::path& file, const std::string& content) {
std::string_view language = LexLanguage(file);
if (language.empty()) return {};
const LibClang& lc = Clang();
if (!lc.handle) return {};
std::string path = file.string();
std::string languageArg = std::format("-x{}", language);
std::string standardArg = language == "c" ? "-std=c23" : "-std=c++26";
// clang's argv is char* by contract; keep the raw pointers confined to
// this call rather than letting them into any signature of ours.
std::array<const char*, 4> args{languageArg.c_str(), standardArg.c_str(), "-ferror-limit=0", "-w"};
CXUnsavedFile unsaved{};
unsaved.Filename = path.c_str();
unsaved.Contents = content.data();
unsaved.Length = static_cast<std::uint32_t>(content.size());
CXIndex index = lc.CreateIndex(0, 0);
if (!index) return {};
CXTranslationUnit tu = lc.ParseTranslationUnit(index, path.c_str(), args.data(), static_cast<std::int32_t>(args.size()), &unsaved, 1, CXTranslationUnit_SingleFileParse | CXTranslationUnit_SkipFunctionBodies | CXTranslationUnit_KeepGoing);
if (!tu) {
lc.DisposeIndex(index);
return {};
}
std::vector<LintToken> tokens;
if (CXFile cxFile = lc.GetFile(tu, path.c_str())) {
CXSourceRange whole = lc.GetRange(lc.GetLocationForOffset(tu, cxFile, 0), lc.GetLocationForOffset(tu, cxFile, static_cast<std::uint32_t>(content.size())));
CXToken* raw = nullptr;
std::uint32_t count = 0;
lc.Tokenize(tu, whole, &raw, &count);
tokens.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) {
CXSourceRange extent = lc.GetTokenExtent(tu, raw[i]);
std::uint32_t line = 0;
std::uint32_t column = 0;
std::uint32_t begin = 0;
std::uint32_t end = 0;
lc.GetFileLocation(lc.GetRangeStart(extent), nullptr, &line, &column, &begin);
lc.GetFileLocation(lc.GetRangeEnd(extent), nullptr, nullptr, nullptr, &end);
if (end < begin || begin > content.size()) continue;
tokens.push_back({MapTokenKind(lc.GetTokenKind(raw[i])), begin, std::min<std::size_t>(end - begin, content.size() - begin), line, column});
}
if (raw) lc.DisposeTokens(tu, raw, count);
}
lc.DisposeTranslationUnit(tu);
lc.DisposeIndex(index);
return tokens;
}
2026-07-23 01:24:42 +02:00
// 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;
}
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
std::span<const LintToken> LintContext::Tokens() {
if (!tokenCache) tokenCache = LexFile(file, content);
return *tokenCache;
}
std::string_view LintContext::TokenText(const LintToken& token) const {
if (token.offset >= content.size()) return {};
return std::string_view(content).substr(token.offset, token.length);
}
std::span<const LintToken> LintContext::TokensOnLine(std::size_t line) {
// Tokens come back in source order, so one line's tokens are a contiguous
// run and can be bracketed by binary search.
std::span<const LintToken> all = Tokens();
auto begin = std::ranges::lower_bound(all, line, {}, &LintToken::line);
auto end = std::ranges::upper_bound(all, line, {}, &LintToken::line);
return all.subspan(static_cast<std::size_t>(begin - all.begin()), static_cast<std::size_t>(end - begin));
}
bool LintContext::LineHasComment(std::size_t line) {
std::span<const LintToken> onLine = TokensOnLine(line);
return std::ranges::any_of(onLine, [](const LintToken& t) { return t.kind == LintTokenKind::Comment; });
}
2026-07-23 01:24:42 +02:00
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
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
tokenCache.reset(); // offsets refer to the old buffer — re-lex
2026-07-23 01:24:42 +02:00
}
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;
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
// libclang backs the lexer every rule reads through, so a failed load is
// fatal rather than a downgrade: running the rules without it would report
// against a substrate that disagrees with the one they were written for.
if (const LibClang& lc = Clang(); !lc.handle) {
std::println(std::cerr, "lint: {}", lc.error);
++summary.errors;
return summary;
}
2026-07-23 01:24:42 +02:00
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;
}