// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) #include #else #include #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(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 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 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& 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 missing; auto bind = [&](auto& slot, const std::string& name) { slot = reinterpret_cast>(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 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 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(content.size()); CXIndex index = lc.createIndex(0, 0); if (!index) return {}; CXTranslationUnit tu = lc.parseTranslationUnit(index, path.c_str(), args.data(), static_cast(args.size()), &unsaved, 1, CXTranslationUnit_SingleFileParse | CXTranslationUnit_SkipFunctionBodies | CXTranslationUnit_KeepGoing); if (!tu) { lc.disposeIndex(index); return {}; } std::vector 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(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(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 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 CommandToArgs(std::string_view command) { std::vector 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* out = nullptr; std::vector 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 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*>(data)->push_back(cursor); return CXChildVisit_Continue; } std::vector ChildrenOf(const LibClang& lc, CXCursor cursor) { std::vector 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(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 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 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(params) ? children.size() - static_cast(params) : 0; for (std::size_t c = 0; c < children.size(); ++c) { const bool byWritableRef = c >= firstArg && IsWritableReference(lc, lc.getArgType(callee, static_cast(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 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(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(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(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 WalkDecls(const LibClang& lc, CXTranslationUnit tu, const std::string& content, const fs::path& projectRoot) { std::vector 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 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 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 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 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(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(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 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 SplitLines(std::string_view content) { std::vector 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 CollectLocalConfigs(Configuration& root, const fs::path& projectRoot) { std::vector local; std::unordered_set seen; std::function 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; 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& mod : c.interfaces) { own(fs::path(std::format("{}.cppm", mod->path.string()))); for (const std::unique_ptr& 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 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 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 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(begin - all.begin()), static_cast(end - begin)); } bool LintContext::LineHasComment(std::size_t line) { std::span 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 spanned(lines.size(), false); for (const LintToken& token : Tokens()) { std::size_t crossed = static_cast(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 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 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 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 check) { lintRules.push_back({std::move(name), std::move(check), false}); } void Configuration::AddAstLintRule(std::string name, std::function 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 localConfigs = CollectLocalConfigs(projectCfg, projectRoot); // Collect rules root-first, dedup by name (first registration wins). std::vector rules; std::unordered_set 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 astFailures; std::set 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 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 beforeLines = SplitLines(before); bool anyLineDiffers = false; if (beforeLines.size() == ctx.lines.size()) { std::vector 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(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 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; }