Crafter.Build/implementations/Crafter.Build-Lint.cpp
Jorijn van der Graaf 649d64ae12 fix(lint): constexpr-constant asks clang's evaluator, not the initialiser tokens
The first cut scanned the initialiser's tokens and required every one to be a
literal or an operator. That is exactly the kind of approximation this work has
been removing, and it was wrong in both directions:

  const int A = sizeof(Big);   missed — `sizeof` is a keyword
  const int B = Base + 1;      missed — `Base` is an identifier
  const auto C = 5_notConstexpr;  would have been reported, wrongly

clang_Cursor_Evaluate answers the question directly. Evaluating a variable
declaration evaluates its initialiser, so anything that folds is recognised and
a call result still is not. Costs nothing measurable — lint stays at ~15s.

Found one real case the token version could not see: an EShMessages fold over
three glslang enum constants in Crafter.Build-Shader.cpp. Promoting it to
constexpr then made `naming` ask for constant naming, since a constexpr
variable is a compile-time constant — so it is `Messages` now. Two rules
agreeing on the same declaration is the intended behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:05:37 +02:00

1379 lines
67 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;
decltype(&clang_Cursor_getBinaryOpcode) getBinaryOpcode = nullptr;
decltype(&clang_getCursorUnaryOperatorKind) getUnaryOperatorKind = nullptr;
decltype(&clang_isConstQualifiedType) isConstQualifiedType = nullptr;
decltype(&clang_CXXMethod_isConst) methodIsConst = nullptr;
decltype(&clang_CXXMethod_isStatic) methodIsStatic = nullptr;
decltype(&clang_getArgType) getArgType = nullptr;
decltype(&clang_getNumArgTypes) getNumArgTypes = nullptr;
decltype(&clang_getPointeeType) getPointeeType = nullptr;
decltype(&clang_Cursor_Evaluate) evaluate = nullptr;
decltype(&clang_EvalResult_getKind) evalResultKind = nullptr;
decltype(&clang_EvalResult_dispose) disposeEvalResult = 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");
bind(lib.getBinaryOpcode, "clang_Cursor_getBinaryOpcode");
bind(lib.getUnaryOperatorKind, "clang_getCursorUnaryOperatorKind");
bind(lib.isConstQualifiedType, "clang_isConstQualifiedType");
bind(lib.methodIsConst, "clang_CXXMethod_isConst");
bind(lib.methodIsStatic, "clang_CXXMethod_isStatic");
bind(lib.getArgType, "clang_getArgType");
bind(lib.getNumArgTypes, "clang_getNumArgTypes");
bind(lib.getPointeeType, "clang_getPointeeType");
bind(lib.evaluate, "clang_Cursor_Evaluate");
bind(lib.evalResultKind, "clang_EvalResult_getKind");
bind(lib.disposeEvalResult, "clang_EvalResult_dispose");
bind(lib.getBinaryOpcode, "clang_Cursor_getBinaryOpcode");
bind(lib.getUnaryOperatorKind, "clang_getCursorUnaryOperatorKind");
bind(lib.isConstQualifiedType, "clang_isConstQualifiedType");
bind(lib.methodIsConst, "clang_CXXMethod_isConst");
bind(lib.methodIsStatic, "clang_CXXMethod_isStatic");
bind(lib.getArgType, "clang_getArgType");
bind(lib.getNumArgTypes, "clang_getNumArgTypes");
bind(lib.getPointeeType, "clang_getPointeeType");
bind(lib.evaluate, "clang_Cursor_Evaluate");
bind(lib.evalResultKind, "clang_EvalResult_getKind");
bind(lib.disposeEvalResult, "clang_EvalResult_dispose");
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
// >0 while visiting a subtree whose value is being WRITTEN: the left
// side of an assignment, the operand of ++/--, or anything whose
// address is taken or which binds to a non-const reference.
std::int32_t writeDepth = 0;
// Set while visiting the binding of a range-for.
bool inLoopBinding = false;
// Name offset -> index, so a DeclRefExpr can be resolved back to the
// declaration it names without comparing USR strings.
std::unordered_map<std::size_t, std::size_t> byNameOffset;
};
bool IsScalarTypeKind(CXTypeKind kind) {
switch (kind) {
case CXType_Bool:
case CXType_Char_U:
case CXType_UChar:
case CXType_UShort:
case CXType_UInt:
case CXType_ULong:
case CXType_ULongLong:
case CXType_Char_S:
case CXType_SChar:
case CXType_Short:
case CXType_Int:
case CXType_Long:
case CXType_LongLong:
case CXType_Float:
case CXType_Double:
case CXType_LongDouble:
case CXType_Enum:
case CXType_Pointer:
return true;
default:
return false;
}
}
bool IsAssignmentOpcode(CX_BinaryOperatorKind opcode) {
return opcode >= CX_BO_Assign && opcode <= CX_BO_OrAssign;
}
bool IsWritableReference(const LibClang& lc, CXType type) {
if (type.kind != CXType_LValueReference) return false;
return lc.isConstQualifiedType(lc.getPointeeType(type)) == 0;
}
CXChildVisitResult CollectChild(CXCursor cursor, CXCursor, CXClientData data) {
static_cast<std::vector<CXCursor>*>(data)->push_back(cursor);
return CXChildVisit_Continue;
}
std::vector<CXCursor> ChildrenOf(const LibClang& lc, CXCursor cursor) {
std::vector<CXCursor> children;
lc.visitChildren(cursor, &CollectChild, &children);
return children;
}
// 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);
}
void ProcessCursor(CXCursor cursor, DeclWalk& walk);
CXChildVisitResult VisitDecl(CXCursor cursor, CXCursor, CXClientData data) {
ProcessCursor(cursor, *static_cast<DeclWalk*>(data));
return CXChildVisit_Continue;
}
// Visit a cursor's children with the write-context flag raised, so every
// DeclRefExpr inside counts as a write to what it names.
void ProcessAsWrite(CXCursor cursor, DeclWalk& walk) {
++walk.writeDepth;
ProcessCursor(cursor, walk);
--walk.writeDepth;
}
void ProcessCursor(CXCursor cursor, DeclWalk& walk) {
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;
const CXCursorKind kind = lc.getCursorKind(cursor);
const 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()];
const bool initialiserContext = enclosing.kind == LintDeclKind::Variable || enclosing.kind == LintDeclKind::Field || enclosing.kind == LintDeclKind::Parameter;
if (initialiserContext && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) {
enclosing.isForeignApi = true;
}
}
// ---- mutation analysis ----
// A name used where a value is being written marks that
// declaration mutated. Resolving through getCursorReferenced means
// shadowing and qualified names come out right.
if (kind == CXCursor_DeclRefExpr && walk.writeDepth > 0) {
CXCursor target = lc.getCursorReferenced(cursor);
if (!lc.cursorIsNull(target)) {
std::uint32_t targetOffset = 0;
lc.getFileLocation(lc.getCursorLocation(target), nullptr, nullptr, nullptr, &targetOffset);
if (auto it = walk.byNameOffset.find(targetOffset); it != walk.byNameOffset.end()) {
(*walk.out)[it->second].isMutated = true;
}
}
}
// The left side of an assignment is written; the right side is read.
if (kind == CXCursor_BinaryOperator || kind == CXCursor_CompoundAssignOperator) {
if (IsAssignmentOpcode(lc.getBinaryOpcode(cursor))) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
if (!children.empty()) {
ProcessAsWrite(children.front(), walk);
for (std::size_t c = 1; c < children.size(); ++c) ProcessCursor(children[c], walk);
return;
}
}
}
// ++/-- write their operand; & lets it be written elsewhere, which
// we cannot follow, so it counts as mutated.
if (kind == CXCursor_UnaryOperator) {
const CXUnaryOperatorKind unary = lc.getUnaryOperatorKind(cursor);
const bool writes = unary == CXUnaryOperator_PreInc || unary == CXUnaryOperator_PreDec || unary == CXUnaryOperator_PostInc || unary == CXUnaryOperator_PostDec || unary == CXUnaryOperator_AddrOf;
if (writes) {
for (CXCursor child : ChildrenOf(lc, cursor)) ProcessAsWrite(child, walk);
return;
}
}
// An argument bound to a non-const lvalue reference can be written
// by the callee.
if (kind == CXCursor_CallExpr) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
CXType callee = lc.getCursorType(lc.getCursorReferenced(cursor));
std::int32_t params = lc.getNumArgTypes(callee);
if (params > 0) {
// Children are [callee?, args...]; line them up from the end
// so an implicit callee child does not shift the mapping.
std::size_t firstArg = children.size() > static_cast<std::size_t>(params)
? children.size() - static_cast<std::size_t>(params) : 0;
for (std::size_t c = 0; c < children.size(); ++c) {
const bool byWritableRef = c >= firstArg && IsWritableReference(lc, lc.getArgType(callee, static_cast<std::uint32_t>(c - firstArg)));
if (byWritableRef) ProcessAsWrite(children[c], walk);
else ProcessCursor(children[c], walk);
}
return;
}
}
// The first child of a range-for is its binding; the rest are the
// range expression and the body.
if (kind == CXCursor_CXXForRangeStmt) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
for (std::size_t c = 0; c < children.size(); ++c) {
const bool binding = c == 0 && lc.getCursorKind(children[c]) == CXCursor_VarDecl;
walk.inLoopBinding = binding;
ProcessCursor(children[c], walk);
walk.inLoopBinding = false;
}
return;
}
// 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));
}
const bool isC = IsExternCLinkage(text);
if (isC) ++walk.externCDepth;
lc.visitChildren(cursor, &VisitDecl, &walk);
if (isC) --walk.externCDepth;
return;
}
lc.visitChildren(cursor, &VisitDecl, &walk);
return;
}
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.
const 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;
decl.nameOffset = nameOffset;
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();
CXType declaredType = lc.getCursorType(cursor);
decl.isConst = lc.isConstQualifiedType(declaredType) != 0;
decl.isScalar = IsScalarTypeKind(declaredType.kind);
decl.isLoopVariable = walk.inLoopBinding;
// Ask clang whether the initialiser is a constant expression instead of
// inspecting its tokens. Evaluating a VarDecl evaluates its initialiser,
// so this covers sizeof, a fold over other constants, and anything else
// that folds — none of which a token scan can recognise — and it does
// not mistake a literal with a non-constexpr user-defined suffix for a
// constant.
if (mapped == LintDeclKind::Variable || mapped == LintDeclKind::Field) {
if (CXEvalResult evaluated = lc.evaluate(cursor)) {
decl.isConstantInitialised = lc.evalResultKind(evaluated) != CXEval_UnExposed;
lc.disposeEvalResult(evaluated);
}
}
if (mapped == LintDeclKind::Method) {
decl.isConstMethod = lc.methodIsConst(cursor) != 0;
decl.isStaticMethod = lc.methodIsStatic(cursor) != 0;
}
walk.out->push_back(std::move(decl));
std::size_t index = walk.out->size() - 1;
walk.byNameOffset.emplace(nameOffset, index);
walk.stack.push_back(index);
// Binding a name to a non-const reference — `auto& r = x;` — lets x be
// written through r, which we cannot follow, so x counts as mutated.
if (IsWritableReference(lc, declaredType)) {
for (CXCursor child : ChildrenOf(lc, cursor)) ProcessAsWrite(child, walk);
} else {
lc.visitChildren(cursor, &VisitDecl, &walk);
}
walk.stack.pop_back();
}
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;
};
std::map<fs::path, std::string> astFailures;
std::set<std::string_view> skippedAstRules;
// 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()) {
// Recorded once per file and reported as a single grouped
// error after the run. One missing PCM would otherwise
// produce a finding per (file, rule) and bury the one fact
// that matters — which is that a build has to happen first.
astFailures.emplace(file, ctx.AstUnavailableReason());
skippedAstRules.insert(rule->name);
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;
}
}
}
}
if (!astFailures.empty()) {
std::string rules;
for (std::string_view name : skippedAstRules) {
if (!rules.empty()) rules += ", ";
rules += name;
}
std::println(std::cerr, "lint: {} rule(s) needing an AST ({}) could not run on {} of {} file(s).", skippedAstRules.size(), rules, astFailures.size(), summary.filesLinted);
std::println(std::cerr, " {}: {}", shown(astFailures.begin()->first).string(), astFailures.begin()->second);
std::println(std::cerr, " Build the project first so the module PCMs exist, or pass --no-ast to skip these rules.");
++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;
}