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;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// Blank comments and the bodies of string/character literals to spaces,
|
|
|
|
|
// copying '\n' through so byte offsets and line numbers in the result
|
|
|
|
|
// match the original text exactly.
|
|
|
|
|
//
|
|
|
|
|
// Derived from the token stream rather than scanned character by
|
|
|
|
|
// character, which is what makes raw strings, escapes, encoding prefixes
|
|
|
|
|
// and a literal like '"' come out right. Editing a copy of the buffer in
|
|
|
|
|
// place — rather than appending to a fresh string — makes the
|
|
|
|
|
// length-preserving property structural instead of something every branch
|
|
|
|
|
// has to remember.
|
|
|
|
|
std::string StripLiterals(const std::string& content, std::span<const LintToken> tokens) {
|
|
|
|
|
std::string out = content;
|
|
|
|
|
auto blank = [&out](std::size_t from, std::size_t to) {
|
|
|
|
|
for (std::size_t i = from; i < to && i < out.size(); ++i) {
|
|
|
|
|
if (out[i] != '\n') out[i] = ' ';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
for (const LintToken& token : tokens) {
|
|
|
|
|
std::size_t begin = token.offset;
|
|
|
|
|
std::size_t end = token.offset + token.length;
|
|
|
|
|
if (token.kind == LintTokenKind::Comment) {
|
|
|
|
|
blank(begin, end);
|
|
|
|
|
continue;
|
2026-07-23 01:24:42 +02:00
|
|
|
}
|
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
|
|
|
if (token.kind != LintTokenKind::Literal || token.length < 2) continue;
|
|
|
|
|
std::string_view text(content.data() + begin, token.length);
|
|
|
|
|
// Numeric literals are code and stay; only string and character
|
|
|
|
|
// literals have a body to hide. A digit separator makes 1'000 look
|
|
|
|
|
// quote-ish, so require everything before the quote to be an
|
|
|
|
|
// encoding prefix (L, u, U, u8, R and their combinations).
|
|
|
|
|
std::size_t quote = text.find_first_of("\"'");
|
|
|
|
|
if (quote == std::string_view::npos) continue;
|
|
|
|
|
std::string_view prefix = text.substr(0, quote);
|
|
|
|
|
if (!std::ranges::all_of(prefix, [](char c) { return c == 'L' || c == 'u' || c == 'U' || c == '8' || c == 'R'; })) continue;
|
|
|
|
|
// Keep the opening quote and the closing one, blank everything
|
|
|
|
|
// between. For a raw string that also blanks the R"delim( and
|
|
|
|
|
// )delim" scaffolding, leaving exactly two quotes — which is what
|
|
|
|
|
// rules counting quotes to find a literal's extent rely on.
|
|
|
|
|
blank(begin + quote + 1, end - 1);
|
2026-07-23 01:24:42 +02:00
|
|
|
}
|
|
|
|
|
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) {
|
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
|
|
|
commentStrippedCache = StripLiterals(content, Tokens());
|
2026-07-23 01:24:42 +02:00
|
|
|
}
|
|
|
|
|
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; });
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
bool LintContext::LineHasMultiLineToken(std::size_t line) {
|
|
|
|
|
if (!spannedLineCache) {
|
|
|
|
|
std::vector<bool> spanned(lines.size(), false);
|
|
|
|
|
for (const LintToken& token : Tokens()) {
|
|
|
|
|
std::size_t crossed = static_cast<std::size_t>(std::ranges::count(TokenText(token), '\n'));
|
|
|
|
|
if (crossed == 0) continue;
|
|
|
|
|
for (std::size_t n = token.line; n <= token.line + crossed && n <= spanned.size(); ++n) {
|
|
|
|
|
spanned[n - 1] = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spannedLineCache = std::move(spanned);
|
|
|
|
|
}
|
|
|
|
|
return line >= 1 && line <= spannedLineCache->size() && (*spannedLineCache)[line - 1];
|
|
|
|
|
}
|
|
|
|
|
|
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
|
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
|
|
|
spannedLineCache.reset(); // derived from tokenCache
|
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;
|
|
|
|
|
}
|