The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a
cumulative paren-depth counter so a wrapped parameter list would not look like
a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess
whose own comment conceded it was heuristic. Storage class came from
lineStr.contains("static "). It is now ~55 lines that ask clang what kind of
declaration each thing is and what encloses it.
The three regressions the old version carried special cases for — a call with
an inline lambda argument, a bare statement call, a one-liner method — need no
handling at all, because a call is not a declaration. Their tests pass
unchanged.
On this repository the exact version found 42 violations the heuristic had
never been able to see, all real:
- 37 members of the libclang function-pointer table added two commits ago
were PascalCase. The old varDecl regex could not match a declaration whose
type is decltype(&f), so they were silently skipped. Renamed to camelCase,
which mirrors clang_createIndex -> createIndex more closely anyway.
- Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible
to the heuristic for the same reason (it declares a const char* array).
- Four extern "C" declarations of libc functions in tests were reported as
badly-named functions. Those are named by the C library, so C language
linkage is now an exemption — the same principled test no-char-pointer
uses, rather than another denylist entry.
New tests cover what the line-based version structurally could not reach: a
signature wrapped over several lines, `static` on its own line above the
declaration it applies to, and a member versus a local inside a method.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1162 lines
56 KiB
C++
1162 lines
56 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#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
|
|
export module Crafter.Build:Lint_impl;
|
|
import std;
|
|
import :Lint;
|
|
import :Clang;
|
|
import :Platform;
|
|
import :Progress;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
namespace {
|
|
// ---------------- 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;
|
|
// AST layer.
|
|
decltype(&clang_getTranslationUnitCursor) getTranslationUnitCursor = nullptr;
|
|
decltype(&clang_visitChildren) visitChildren = nullptr;
|
|
decltype(&clang_getCursorKind) getCursorKind = nullptr;
|
|
decltype(&clang_getCursorSpelling) getCursorSpelling = nullptr;
|
|
decltype(&clang_getCursorType) getCursorType = nullptr;
|
|
decltype(&clang_getTypeSpelling) getTypeSpelling = nullptr;
|
|
decltype(&clang_getCursorLocation) getCursorLocation = nullptr;
|
|
decltype(&clang_getCursorExtent) getCursorExtent = nullptr;
|
|
decltype(&clang_getCursorReferenced) getCursorReferenced = nullptr;
|
|
decltype(&clang_Cursor_getStorageClass) getStorageClass = nullptr;
|
|
decltype(&clang_EnumDecl_isScoped) enumDeclIsScoped = nullptr;
|
|
decltype(&clang_isCursorDefinition) isCursorDefinition = nullptr;
|
|
decltype(&clang_Location_isFromMainFile) locationIsFromMainFile = nullptr;
|
|
decltype(&clang_getNumDiagnostics) getNumDiagnostics = nullptr;
|
|
decltype(&clang_getDiagnostic) getDiagnostic = nullptr;
|
|
decltype(&clang_getDiagnosticSeverity) getDiagnosticSeverity = nullptr;
|
|
decltype(&clang_getDiagnosticSpelling) getDiagnosticSpelling = nullptr;
|
|
decltype(&clang_disposeDiagnostic) disposeDiagnostic = nullptr;
|
|
decltype(&clang_getCString) getCString = nullptr;
|
|
decltype(&clang_disposeString) disposeString = nullptr;
|
|
decltype(&clang_getFileName) getFileName = nullptr;
|
|
decltype(&clang_Cursor_isNull) cursorIsNull = nullptr;
|
|
decltype(&clang_getResultType) getResultType = 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");
|
|
bind(lib.getTranslationUnitCursor, "clang_getTranslationUnitCursor");
|
|
bind(lib.visitChildren, "clang_visitChildren");
|
|
bind(lib.getCursorKind, "clang_getCursorKind");
|
|
bind(lib.getCursorSpelling, "clang_getCursorSpelling");
|
|
bind(lib.getCursorType, "clang_getCursorType");
|
|
bind(lib.getTypeSpelling, "clang_getTypeSpelling");
|
|
bind(lib.getCursorLocation, "clang_getCursorLocation");
|
|
bind(lib.getCursorExtent, "clang_getCursorExtent");
|
|
bind(lib.getCursorReferenced, "clang_getCursorReferenced");
|
|
bind(lib.getStorageClass, "clang_Cursor_getStorageClass");
|
|
bind(lib.enumDeclIsScoped, "clang_EnumDecl_isScoped");
|
|
bind(lib.isCursorDefinition, "clang_isCursorDefinition");
|
|
bind(lib.locationIsFromMainFile, "clang_Location_isFromMainFile");
|
|
bind(lib.getNumDiagnostics, "clang_getNumDiagnostics");
|
|
bind(lib.getDiagnostic, "clang_getDiagnostic");
|
|
bind(lib.getDiagnosticSeverity, "clang_getDiagnosticSeverity");
|
|
bind(lib.getDiagnosticSpelling, "clang_getDiagnosticSpelling");
|
|
bind(lib.disposeDiagnostic, "clang_disposeDiagnostic");
|
|
bind(lib.getCString, "clang_getCString");
|
|
bind(lib.disposeString, "clang_disposeString");
|
|
bind(lib.getFileName, "clang_getFileName");
|
|
bind(lib.cursorIsNull, "clang_Cursor_isNull");
|
|
bind(lib.getResultType, "clang_getResultType");
|
|
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;
|
|
}
|
|
|
|
// ---------------- AST ----------------
|
|
|
|
std::string TakeString(const LibClang& lc, CXString s) {
|
|
// const char* because clang_getCString returns one. no-char-pointer
|
|
// works this out for itself now: the initialiser resolves to a
|
|
// declaration in clang-c/, outside the project, so the declaration is
|
|
// flagged as foreign API and exempt. No suppression comment needed.
|
|
const char* raw = lc.getCString(s);
|
|
std::string out = raw ? raw : "";
|
|
lc.disposeString(s);
|
|
return out;
|
|
}
|
|
|
|
// Overwrite every `export` keyword with spaces, leaving `export module`
|
|
// alone. Byte-length preserving, so every line and column libclang reports
|
|
// still lands on the original file.
|
|
//
|
|
// libclang has no CXCursorKind for a C++20 export declaration: it reports
|
|
// CXCursor_UnexposedDecl and does not descend, so `export namespace X { … }`
|
|
// collapses to one childless node and every declaration inside it becomes
|
|
// invisible. Five of this repo's interfaces are written that way, which is
|
|
// 677 lines including Configuration and LintContext. A plain `namespace X`
|
|
// IS descended into, so removing the keyword is enough — the declarations
|
|
// stop being exported in this parse, which is irrelevant to the names,
|
|
// kinds, types and scopes the rules ask about.
|
|
//
|
|
// The braced `export { … }` form would need the braces kept, which this
|
|
// does not attempt; it does not occur here, and it would show up as a parse
|
|
// error rather than silently wrong output.
|
|
std::string BlankExportKeywords(const std::string& content, std::span<const LintToken> tokens) {
|
|
std::string out = content;
|
|
for (std::size_t i = 0; i < tokens.size(); ++i) {
|
|
const LintToken& token = tokens[i];
|
|
if (token.kind != LintTokenKind::Keyword && token.kind != LintTokenKind::Identifier) continue;
|
|
if (token.length != 6 || out.compare(token.offset, 6, "export") != 0) continue;
|
|
// `export module Crafter.Build:Lint;` must survive: without it the
|
|
// unit stops being a module interface and its own partition
|
|
// imports become ill-formed.
|
|
if (i + 1 < tokens.size() && content.compare(tokens[i + 1].offset, 6, "module") == 0) continue;
|
|
out.replace(token.offset, 6, " ");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
LintDeclKind MapCursorKind(CXCursorKind kind) {
|
|
switch (kind) {
|
|
case CXCursor_Namespace: return LintDeclKind::Namespace;
|
|
case CXCursor_ClassDecl:
|
|
case CXCursor_ClassTemplate: return LintDeclKind::Class;
|
|
case CXCursor_StructDecl: return LintDeclKind::Struct;
|
|
case CXCursor_UnionDecl: return LintDeclKind::Union;
|
|
case CXCursor_EnumDecl: return LintDeclKind::Enum;
|
|
case CXCursor_EnumConstantDecl: return LintDeclKind::EnumConstant;
|
|
case CXCursor_TypedefDecl:
|
|
case CXCursor_TypeAliasDecl:
|
|
case CXCursor_TypeAliasTemplateDecl: return LintDeclKind::TypeAlias;
|
|
case CXCursor_FunctionDecl:
|
|
case CXCursor_FunctionTemplate: return LintDeclKind::Function;
|
|
case CXCursor_CXXMethod: return LintDeclKind::Method;
|
|
case CXCursor_Constructor: return LintDeclKind::Constructor;
|
|
case CXCursor_Destructor: return LintDeclKind::Destructor;
|
|
case CXCursor_FieldDecl: return LintDeclKind::Field;
|
|
case CXCursor_VarDecl: return LintDeclKind::Variable;
|
|
case CXCursor_ParmDecl: return LintDeclKind::Parameter;
|
|
default: return LintDeclKind::Other;
|
|
}
|
|
}
|
|
|
|
// Split a shell command string into argv for libclang, dropping argv[0]
|
|
// and undoing the \" escaping the shell form needs. Whitespace-separated:
|
|
// the build assembles and runs this very string through a shell, so a path
|
|
// containing a space is already unsupported upstream of here.
|
|
std::vector<std::string> CommandToArgs(std::string_view command) {
|
|
std::vector<std::string> args;
|
|
for (std::size_t i = 0; i < command.size();) {
|
|
while (i < command.size() && command[i] == ' ') ++i;
|
|
std::size_t begin = i;
|
|
while (i < command.size() && command[i] != ' ') ++i;
|
|
if (i > begin) args.emplace_back(command.substr(begin, i - begin));
|
|
}
|
|
if (!args.empty()) args.erase(args.begin()); // argv[0] is the compiler
|
|
for (std::string& arg : args) {
|
|
// Only \" unescapes. Erasing every backslash would destroy the
|
|
// Windows include paths GetBaseCommand puts on the command line.
|
|
std::string unescaped;
|
|
unescaped.reserve(arg.size());
|
|
for (std::size_t i = 0; i < arg.size(); ++i) {
|
|
if (arg[i] == '\\' && i + 1 < arg.size() && arg[i + 1] == '"') continue;
|
|
unescaped += arg[i];
|
|
}
|
|
arg = std::move(unescaped);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
struct DeclWalk {
|
|
const LibClang* lc = nullptr;
|
|
const std::string* content = nullptr;
|
|
const fs::path* projectRoot = nullptr;
|
|
std::vector<LintDecl>* out = nullptr;
|
|
std::vector<std::size_t> stack; // indices of the enclosing declarations
|
|
std::int32_t externCDepth = 0; // inside how many extern "C" blocks
|
|
};
|
|
|
|
// Whether a CXCursor_LinkageSpec is `extern "C"` as opposed to
|
|
// `extern "C++"`. libclang exposes no query, and the spelling is empty, but
|
|
// the extent starts at the `extern` keyword so the source answers it.
|
|
bool IsExternCLinkage(std::string_view text) {
|
|
std::size_t quote = text.find('"');
|
|
if (quote == std::string_view::npos) return false;
|
|
std::size_t close = text.find('"', quote + 1);
|
|
if (close == std::string_view::npos) return false;
|
|
return text.substr(quote + 1, close - quote - 1) == "C";
|
|
}
|
|
|
|
bool PathInsideRoot(const fs::path& p, const fs::path& root);
|
|
|
|
// True when `cursor` names something declared outside the project — a
|
|
// system header, libc++, an external dependency. This is what makes the
|
|
// interop exemption principled rather than a list of names: the question
|
|
// asked is "whose header dictates this spelling", and the answer comes
|
|
// from where the declaration actually lives.
|
|
bool ResolvesOutsideProject(const LibClang& lc, CXCursor cursor, const fs::path& projectRoot) {
|
|
if (projectRoot.empty()) return false;
|
|
CXCursor target = lc.getCursorReferenced(cursor);
|
|
if (lc.cursorIsNull(target)) return false;
|
|
CXSourceLocation location = lc.getCursorLocation(target);
|
|
if (lc.locationIsFromMainFile(location)) return false;
|
|
CXFile file = nullptr;
|
|
std::uint32_t line = 0;
|
|
std::uint32_t column = 0;
|
|
std::uint32_t offset = 0;
|
|
lc.getFileLocation(location, &file, &line, &column, &offset);
|
|
if (!file) return false;
|
|
std::string path = TakeString(lc, lc.getFileName(file));
|
|
if (path.empty()) return false;
|
|
return !PathInsideRoot(fs::path(path), projectRoot);
|
|
}
|
|
|
|
CXChildVisitResult VisitDecl(CXCursor cursor, CXCursor, CXClientData data) {
|
|
DeclWalk& walk = *static_cast<DeclWalk*>(data);
|
|
const LibClang& lc = *walk.lc;
|
|
CXSourceLocation location = lc.getCursorLocation(cursor);
|
|
// Every declaration the imported modules bring in arrives here too —
|
|
// an unfiltered visit of one interface unit walks ~495,000 cursors from
|
|
// std alone. Prune before doing any work.
|
|
if (!lc.locationIsFromMainFile(location)) return CXChildVisit_Continue;
|
|
|
|
CXCursorKind kind = lc.getCursorKind(cursor);
|
|
LintDeclKind mapped = MapCursorKind(kind);
|
|
if (mapped == LintDeclKind::Other) {
|
|
// A foreign reference inside a VARIABLE, FIELD or PARAMETER is an
|
|
// initialiser binding that declaration to somebody else's API —
|
|
// `char* p = getenv(...)`. Deliberately not applied when the
|
|
// enclosing declaration is a function: a function that merely
|
|
// touches libc++ somewhere in its body would otherwise exempt its
|
|
// own signature.
|
|
if (!walk.stack.empty()) {
|
|
LintDecl& enclosing = (*walk.out)[walk.stack.back()];
|
|
bool initialiserContext = enclosing.kind == LintDeclKind::Variable || enclosing.kind == LintDeclKind::Field || enclosing.kind == LintDeclKind::Parameter;
|
|
if (initialiserContext && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) {
|
|
enclosing.isForeignApi = true;
|
|
}
|
|
}
|
|
// Recursed by hand rather than with CXChildVisit_Recurse so the
|
|
// enclosing-declaration stack stays accurate: the callback is never
|
|
// told when a subtree ends.
|
|
if (kind == CXCursor_LinkageSpec) {
|
|
CXSourceRange extent = lc.getCursorExtent(cursor);
|
|
std::uint32_t specBegin = 0;
|
|
std::uint32_t specEnd = 0;
|
|
lc.getFileLocation(lc.getRangeStart(extent), nullptr, nullptr, nullptr, &specBegin);
|
|
lc.getFileLocation(lc.getRangeEnd(extent), nullptr, nullptr, nullptr, &specEnd);
|
|
std::string_view text;
|
|
if (specBegin < walk.content->size()) {
|
|
text = std::string_view(walk.content->data() + specBegin, std::min<std::size_t>(specEnd - specBegin, walk.content->size() - specBegin));
|
|
}
|
|
bool isC = IsExternCLinkage(text);
|
|
if (isC) ++walk.externCDepth;
|
|
lc.visitChildren(cursor, &VisitDecl, &walk);
|
|
if (isC) --walk.externCDepth;
|
|
return CXChildVisit_Continue;
|
|
}
|
|
lc.visitChildren(cursor, &VisitDecl, &walk);
|
|
return CXChildVisit_Continue;
|
|
}
|
|
|
|
LintDecl decl;
|
|
decl.kind = mapped;
|
|
decl.name = TakeString(lc, lc.getCursorSpelling(cursor));
|
|
// For anything callable, `type` is the RESULT type rather than the
|
|
// whole function type: the parameters arrive as their own Parameter
|
|
// declarations, so spelling them here too would make every rule
|
|
// reading `type` report each one twice.
|
|
bool callable = mapped == LintDeclKind::Function || mapped == LintDeclKind::Method;
|
|
decl.type = TakeString(lc, lc.getTypeSpelling(callable ? lc.getResultType(lc.getCursorType(cursor)) : lc.getCursorType(cursor)));
|
|
CXFile nameFile = nullptr;
|
|
std::uint32_t nameLine = 0;
|
|
std::uint32_t nameColumn = 0;
|
|
std::uint32_t nameOffset = 0;
|
|
lc.getFileLocation(location, &nameFile, &nameLine, &nameColumn, &nameOffset);
|
|
decl.line = nameLine;
|
|
decl.column = nameColumn;
|
|
CXSourceRange extent = lc.getCursorExtent(cursor);
|
|
std::uint32_t begin = 0;
|
|
std::uint32_t end = 0;
|
|
lc.getFileLocation(lc.getRangeStart(extent), nullptr, nullptr, nullptr, &begin);
|
|
lc.getFileLocation(lc.getRangeEnd(extent), nullptr, nullptr, nullptr, &end);
|
|
decl.begin = begin;
|
|
decl.end = std::min<std::size_t>(end, walk.content->size());
|
|
decl.isDefinition = lc.isCursorDefinition(cursor) != 0;
|
|
decl.isStatic = lc.getStorageClass(cursor) == CX_SC_Static;
|
|
decl.isScopedEnum = mapped == LintDeclKind::Enum && lc.enumDeclIsScoped(cursor) != 0;
|
|
// Inside an extern "C" block, where a C API's spelling is not ours to
|
|
// modernise. NOT clang_getCursorLanguage: its default answer for a
|
|
// plain function, variable or parameter is CXLanguage_C even in a C++
|
|
// translation unit, so trusting it exempted essentially everything.
|
|
decl.isExternC = walk.externCDepth > 0;
|
|
// libclang exposes no constexpr query. The keyword can only appear in
|
|
// this declaration's own specifier list, i.e. between the start of its
|
|
// extent and its name, so a search bounded to that span is exact rather
|
|
// than the line-wide `contains("constexpr ")` it replaces.
|
|
if (nameOffset > decl.begin && decl.begin < walk.content->size()) {
|
|
std::string_view specifiers(walk.content->data() + decl.begin, std::min<std::size_t>(nameOffset - decl.begin, walk.content->size() - decl.begin));
|
|
decl.isConstexpr = specifiers.contains("constexpr");
|
|
}
|
|
decl.parent = walk.stack.empty() ? LintNoParent : walk.stack.back();
|
|
|
|
walk.out->push_back(std::move(decl));
|
|
walk.stack.push_back(walk.out->size() - 1);
|
|
lc.visitChildren(cursor, &VisitDecl, &walk);
|
|
walk.stack.pop_back();
|
|
return CXChildVisit_Continue;
|
|
}
|
|
|
|
std::vector<LintDecl> WalkDecls(const LibClang& lc, CXTranslationUnit tu, const std::string& content, const fs::path& projectRoot) {
|
|
std::vector<LintDecl> decls;
|
|
DeclWalk walk;
|
|
walk.lc = &lc;
|
|
walk.content = &content;
|
|
walk.projectRoot = &projectRoot;
|
|
walk.out = &decls;
|
|
lc.visitChildren(lc.getTranslationUnitCursor(tu), &VisitDecl, &walk);
|
|
// A declaration on an interop boundary makes its parameters and fields
|
|
// interop too — the exemption has to cover the whole signature, not
|
|
// just the node that happened to name the foreign entity.
|
|
for (LintDecl& decl : decls) {
|
|
if (decl.parent == LintNoParent) continue;
|
|
const LintDecl& parent = decls[decl.parent];
|
|
if (parent.isForeignApi) decl.isForeignApi = true;
|
|
if (parent.isExternC) decl.isExternC = true;
|
|
}
|
|
return decls;
|
|
}
|
|
|
|
// libclang locates its builtin headers relative to its own install path,
|
|
// which need not agree with the clang++ on PATH that produced the PCMs.
|
|
// When it disagrees the failure is total and unhelpful — every parse dies
|
|
// on "'stddef.h' file not found" — so ask the driver and pass it
|
|
// explicitly. Cached like HostTarget, and for the same reason.
|
|
const std::string& ClangResourceDir() {
|
|
static const std::string Cached = []() -> std::string {
|
|
CommandResult r = RunCommandChecked("clang++ -print-resource-dir");
|
|
if (r.exitCode != 0) return {};
|
|
std::string out = std::move(r.output);
|
|
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
|
|
return out;
|
|
}();
|
|
return Cached;
|
|
}
|
|
|
|
struct AstResult {
|
|
std::vector<LintDecl> decls;
|
|
std::string error; // empty exactly on success
|
|
};
|
|
|
|
// Parse `content` as `file` with the flags that actually built its PCMs.
|
|
//
|
|
// Unlike the tokenizer this cannot tolerate a failed parse: a fatal
|
|
// diagnostic leaves a fragment, and a fragment is indistinguishable from a
|
|
// file that declares nothing. So a fatal is returned as an error for the
|
|
// driver to surface, never as an empty declaration list.
|
|
AstResult ParseAst(const fs::path& file, const std::string& content, std::string_view compileCommand, const fs::path& projectRoot, std::span<const LintToken> tokens) {
|
|
AstResult result;
|
|
std::string_view language = LexLanguage(file);
|
|
if (language.empty() || language == "c") {
|
|
result.error = std::format("{} is not a C++ translation unit", file.filename().string());
|
|
return result;
|
|
}
|
|
if (compileCommand.empty()) {
|
|
result.error = "no compile command is known for this file";
|
|
return result;
|
|
}
|
|
const LibClang& lc = Clang();
|
|
if (!lc.handle) {
|
|
result.error = lc.error;
|
|
return result;
|
|
}
|
|
|
|
// Byte-length preserving, so cursor line/column land on the original.
|
|
std::string buffer = BlankExportKeywords(content, tokens);
|
|
std::string path = file.string();
|
|
std::vector<std::string> args = CommandToArgs(compileCommand);
|
|
// Required: libclang will not infer a module interface unit from the
|
|
// .cppm extension, and silently treats every flag as a linker input if
|
|
// left to guess.
|
|
args.push_back(std::format("-x{}", language));
|
|
if (!ClangResourceDir().empty()) args.push_back(std::format("-resource-dir={}", ClangResourceDir()));
|
|
std::vector<const char*> argv;
|
|
argv.reserve(args.size());
|
|
for (const std::string& arg : args) argv.push_back(arg.c_str());
|
|
|
|
CXUnsavedFile unsaved{};
|
|
unsaved.Filename = path.c_str();
|
|
unsaved.Contents = buffer.data();
|
|
unsaved.Length = static_cast<std::uint32_t>(buffer.size());
|
|
|
|
CXIndex index = lc.createIndex(0, 0);
|
|
if (!index) {
|
|
result.error = "clang_createIndex failed";
|
|
return result;
|
|
}
|
|
CXTranslationUnit tu = lc.parseTranslationUnit(index, path.c_str(), argv.data(), static_cast<std::int32_t>(argv.size()), &unsaved, 1, CXTranslationUnit_None);
|
|
if (!tu) {
|
|
lc.disposeIndex(index);
|
|
result.error = "clang could not create a translation unit";
|
|
return result;
|
|
}
|
|
|
|
std::string fatal;
|
|
std::uint32_t diagnostics = lc.getNumDiagnostics(tu);
|
|
for (std::uint32_t i = 0; i < diagnostics && fatal.empty(); ++i) {
|
|
CXDiagnostic diagnostic = lc.getDiagnostic(tu, i);
|
|
if (lc.getDiagnosticSeverity(diagnostic) == CXDiagnostic_Fatal) {
|
|
fatal = TakeString(lc, lc.getDiagnosticSpelling(diagnostic));
|
|
}
|
|
lc.disposeDiagnostic(diagnostic);
|
|
}
|
|
if (fatal.empty()) {
|
|
result.decls = WalkDecls(lc, tu, buffer, projectRoot);
|
|
} else {
|
|
result.error = std::move(fatal);
|
|
}
|
|
lc.disposeTranslationUnit(tu);
|
|
lc.disposeIndex(index);
|
|
return result;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// Maps each source to the Configuration that owns it. std::map keeps the
|
|
// deterministic sorted iteration the old std::set gave, and the value is
|
|
// what the AST layer needs: PCMs are flag-locked, so a file parsed with a
|
|
// sibling configuration's flags does not parse at all. Three regimes are in
|
|
// play — the library/executable, each declared test (which carries its own
|
|
// target, defines and -march via the march fan-out), and project.cpp.
|
|
using SourceOwners = std::map<fs::path, const Configuration*>;
|
|
|
|
void CollectConfigSources(const Configuration& c, SourceOwners& files) {
|
|
// First owner wins, matching the root-first rule dedup: a source
|
|
// reachable through two configurations parses with the nearer one.
|
|
auto own = [&files, &c](fs::path file) { files.emplace(std::move(file), &c); };
|
|
for (const std::unique_ptr<Module>& mod : c.interfaces) {
|
|
own(fs::path(std::format("{}.cppm", mod->path.string())));
|
|
for (const std::unique_ptr<ModulePartition>& part : mod->partitions) {
|
|
own(fs::path(std::format("{}.cppm", part->path.string())));
|
|
}
|
|
}
|
|
for (const Implementation& impl : c.implementations) {
|
|
own(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) {
|
|
own(fs::absolute(fs::path(std::format("{}.c", cf.string()))).lexically_normal());
|
|
}
|
|
for (const fs::path& cu : c.cuda) {
|
|
own(fs::absolute(fs::path(std::format("{}.cu", cu.string()))).lexically_normal());
|
|
}
|
|
for (const Shader& shader : c.shaders) {
|
|
own(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 = StripLiterals(content, Tokens());
|
|
}
|
|
return *commentStrippedCache;
|
|
}
|
|
|
|
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; });
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
std::span<const LintDecl> LintContext::Decls() {
|
|
if (!declCache) {
|
|
AstResult parsed = ParseAst(file, content, compileCommand, projectRoot, Tokens());
|
|
astReason = std::move(parsed.error);
|
|
declCache = std::move(parsed.decls);
|
|
}
|
|
return *declCache;
|
|
}
|
|
|
|
bool LintContext::AstAvailable() {
|
|
Decls();
|
|
return astReason.empty();
|
|
}
|
|
|
|
std::string_view LintContext::AstUnavailableReason() {
|
|
Decls();
|
|
return astReason;
|
|
}
|
|
|
|
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
|
|
tokenCache.reset(); // offsets refer to the old buffer — re-lex
|
|
spannedLineCache.reset(); // derived from tokenCache
|
|
declCache.reset(); // extents refer to the old buffer — re-parse
|
|
astReason.clear();
|
|
}
|
|
|
|
void Configuration::AddLintRule(std::string name, std::function<void(LintContext&)> check) {
|
|
lintRules.push_back({std::move(name), std::move(check), false});
|
|
}
|
|
|
|
void Configuration::AddAstLintRule(std::string name, std::function<void(LintContext&)> check) {
|
|
lintRules.push_back({std::move(name), std::move(check), true});
|
|
}
|
|
|
|
LintSummary Crafter::RunLint(Configuration& projectCfg, const RunLintOptions& opts) {
|
|
LintSummary summary;
|
|
|
|
// 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
SourceOwners files;
|
|
for (Configuration* c : localConfigs) {
|
|
CollectConfigSources(*c, files);
|
|
for (const Test& t : c->tests) CollectConfigSources(t.config, files);
|
|
}
|
|
// project.cpp is not built by Build() at all — LoadProject compiles it
|
|
// against the host PCM cache with its own flags, so it is owned by nothing
|
|
// here and gets no compile command. Token rules still cover it.
|
|
if (!opts.projectFile.empty()) files.emplace(opts.projectFile, nullptr);
|
|
|
|
fs::path cwd = fs::current_path();
|
|
auto shown = [&cwd](const fs::path& p) {
|
|
return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p;
|
|
};
|
|
|
|
// Any rule reading Decls() needs this configuration's module PCMs, and a
|
|
// parse without them is fatal rather than degraded. Produce them up front
|
|
// rather than letting each file fail on its own, and cache the assembled
|
|
// command per configuration — GetCompileCommand walks the dependency tree.
|
|
const bool anyRuleNeedsAst = std::ranges::any_of(rules, [](const LintRule* r) { return r->needsAst; });
|
|
std::unordered_map<const Configuration*, std::string> commands;
|
|
if (anyRuleNeedsAst && !opts.noAst) {
|
|
for (Configuration* c : localConfigs) {
|
|
auto ensure = [&](const Configuration& cfg) {
|
|
if (commands.contains(&cfg)) return;
|
|
std::string command;
|
|
try {
|
|
CompileCommand assembled = GetCompileCommand(cfg);
|
|
// Sources that #include an external dependency's headers —
|
|
// Crafter.Build-Shader.cpp and glslang here — need those -I
|
|
// flags to parse at all. Build appends its own authoritative
|
|
// set after the external build; these come straight from the
|
|
// declaration, which is all a parse needs.
|
|
command = assembled.command + assembled.externalIncludeFlags;
|
|
if (!fs::exists(assembled.stdPcmDir/"std.pcm")) {
|
|
Progress::Task task(std::format("Building std PCM ({}-{})", cfg.target, cfg.march));
|
|
fs::create_directories(assembled.stdPcmDir);
|
|
std::string error = BuildStdPcm(cfg, assembled.stdPcmDir/"std.pcm");
|
|
if (!error.empty()) command.clear();
|
|
}
|
|
} catch (const std::exception&) {
|
|
command.clear(); // surfaced per file as an AST reason
|
|
}
|
|
commands.emplace(&cfg, std::move(command));
|
|
};
|
|
ensure(*c);
|
|
for (const Test& t : c->tests) ensure(t.config);
|
|
}
|
|
}
|
|
|
|
for (const auto& [file, owner] : 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;
|
|
ctx.projectRoot = projectRoot;
|
|
if (auto it = commands.find(owner); it != commands.end()) ctx.compileCommand = it->second;
|
|
++summary.filesLinted;
|
|
const std::string original = ctx.content;
|
|
for (const LintRule* rule : rules) {
|
|
ctx.activeRule = rule->name;
|
|
// A semantic rule that cannot see an AST would report nothing,
|
|
// which is indistinguishable from a clean file — and for a
|
|
// transform it would mean rewriting without the information that
|
|
// decides what is safe to touch. Skip it and make the run fail.
|
|
if (rule->needsAst) {
|
|
if (opts.noAst) continue;
|
|
// No compile command means this file is not a translation unit
|
|
// of the build graph — project.cpp, which LoadProject compiles
|
|
// with its own flags, or a header. A semantic rule does not
|
|
// apply there, the same way a rule self-filters by extension,
|
|
// so skip it quietly. That is a different thing from a file we
|
|
// SHOULD have been able to parse and could not, which is an
|
|
// error: reporting nothing for it would be indistinguishable
|
|
// from reporting it clean.
|
|
if (ctx.compileCommand.empty()) continue;
|
|
if (!ctx.AstAvailable()) {
|
|
ctx.Report(0, std::format("rule '{}' needs an AST, which is unavailable: {}", rule->name, ctx.AstUnavailableReason()));
|
|
++summary.errors;
|
|
continue;
|
|
}
|
|
}
|
|
// 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;
|
|
}
|