// 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; }; 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"); 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 }; // Whether a CXCursor_LinkageSpec is `extern "C"` as opposed to // `extern "C++"`. libclang exposes no query, and the spelling is empty, but // the extent starts at the `extern` keyword so the source answers it. bool IsExternCLinkage(std::string_view text) { std::size_t quote = text.find('"'); if (quote == std::string_view::npos) return false; std::size_t close = text.find('"', quote + 1); if (close == std::string_view::npos) return false; return text.substr(quote + 1, close - quote - 1) == "C"; } bool PathInsideRoot(const fs::path& p, const fs::path& root); // True when `cursor` names something declared outside the project — a // system header, libc++, an external dependency. This is what makes the // interop exemption principled rather than a list of names: the question // asked is "whose header dictates this spelling", and the answer comes // from where the declaration actually lives. bool ResolvesOutsideProject(const LibClang& lc, CXCursor cursor, const fs::path& projectRoot) { if (projectRoot.empty()) return false; CXCursor target = lc.GetCursorReferenced(cursor); if (lc.CursorIsNull(target)) return false; CXSourceLocation location = lc.GetCursorLocation(target); if (lc.LocationIsFromMainFile(location)) return false; CXFile file = nullptr; std::uint32_t line = 0; std::uint32_t column = 0; std::uint32_t offset = 0; lc.GetFileLocation(location, &file, &line, &column, &offset); if (!file) return false; std::string path = TakeString(lc, lc.GetFileName(file)); if (path.empty()) return false; return !PathInsideRoot(fs::path(path), projectRoot); } CXChildVisitResult VisitDecl(CXCursor cursor, CXCursor, CXClientData data) { DeclWalk& walk = *static_cast(data); const LibClang& lc = *walk.lc; CXSourceLocation location = lc.GetCursorLocation(cursor); // Every declaration the imported modules bring in arrives here too — // an unfiltered visit of one interface unit walks ~495,000 cursors from // std alone. Prune before doing any work. if (!lc.LocationIsFromMainFile(location)) return CXChildVisit_Continue; CXCursorKind kind = lc.GetCursorKind(cursor); LintDeclKind mapped = MapCursorKind(kind); if (mapped == LintDeclKind::Other) { // A foreign reference inside a VARIABLE, FIELD or PARAMETER is an // initialiser binding that declaration to somebody else's API — // `char* p = getenv(...)`. Deliberately not applied when the // enclosing declaration is a function: a function that merely // touches libc++ somewhere in its body would otherwise exempt its // own signature. if (!walk.stack.empty()) { LintDecl& enclosing = (*walk.out)[walk.stack.back()]; bool initialiserContext = enclosing.kind == LintDeclKind::Variable || enclosing.kind == LintDeclKind::Field || enclosing.kind == LintDeclKind::Parameter; if (initialiserContext && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) { enclosing.isForeignApi = true; } } // Recursed by hand rather than with CXChildVisit_Recurse so the // enclosing-declaration stack stays accurate: the callback is never // told when a subtree ends. if (kind == CXCursor_LinkageSpec) { CXSourceRange extent = lc.GetCursorExtent(cursor); std::uint32_t specBegin = 0; std::uint32_t specEnd = 0; lc.GetFileLocation(lc.GetRangeStart(extent), nullptr, nullptr, nullptr, &specBegin); lc.GetFileLocation(lc.GetRangeEnd(extent), nullptr, nullptr, nullptr, &specEnd); std::string_view text; if (specBegin < walk.content->size()) { text = std::string_view(walk.content->data() + specBegin, std::min(specEnd - specBegin, walk.content->size() - specBegin)); } bool isC = IsExternCLinkage(text); if (isC) ++walk.externCDepth; lc.VisitChildren(cursor, &VisitDecl, &walk); if (isC) --walk.externCDepth; return CXChildVisit_Continue; } lc.VisitChildren(cursor, &VisitDecl, &walk); return CXChildVisit_Continue; } LintDecl decl; decl.kind = mapped; decl.name = TakeString(lc, lc.GetCursorSpelling(cursor)); // For anything callable, `type` is the RESULT type rather than the // whole function type: the parameters arrive as their own Parameter // declarations, so spelling them here too would make every rule // reading `type` report each one twice. bool callable = mapped == LintDeclKind::Function || mapped == LintDeclKind::Method; decl.type = TakeString(lc, lc.GetTypeSpelling(callable ? lc.GetResultType(lc.GetCursorType(cursor)) : lc.GetCursorType(cursor))); CXFile nameFile = nullptr; std::uint32_t nameLine = 0; std::uint32_t nameColumn = 0; std::uint32_t nameOffset = 0; lc.GetFileLocation(location, &nameFile, &nameLine, &nameColumn, &nameOffset); decl.line = nameLine; decl.column = nameColumn; CXSourceRange extent = lc.GetCursorExtent(cursor); std::uint32_t begin = 0; std::uint32_t end = 0; lc.GetFileLocation(lc.GetRangeStart(extent), nullptr, nullptr, nullptr, &begin); lc.GetFileLocation(lc.GetRangeEnd(extent), nullptr, nullptr, nullptr, &end); decl.begin = begin; decl.end = std::min(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(); walk.out->push_back(std::move(decl)); walk.stack.push_back(walk.out->size() - 1); lc.VisitChildren(cursor, &VisitDecl, &walk); walk.stack.pop_back(); return CXChildVisit_Continue; } std::vector 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; }; // 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()) { ctx.Report(0, std::format("rule '{}' needs an AST, which is unavailable: {}", rule->name, ctx.AstUnavailableReason())); ++summary.errors; continue; } } // Snapshot for transform diffing — and the revert point if the // rule throws, so a half-applied transform never reaches disk // and chained rules see clean input. std::string before = ctx.content; try { rule->check(ctx); } catch (const std::exception& e) { // Never let a rule's exception unwind across the project // DLL boundary — surface it as a finding instead. ctx.SetContent(std::move(before)); ctx.Report(0, std::format("rule '{}' threw: {}", rule->name, e.what())); ++summary.errors; if (opts.mode != LintMode::Report) { // Report mode prints it with the findings; the other // modes don't print findings, so surface it here. std::println(std::cerr, "{}: rule '{}' threw: {}", shown(file).string(), rule->name, e.what()); } continue; } // Transform detection is compare-by-value: a rule that SetContents // identical bytes is not a change. The mutated content carries // forward in every mode so chained rules compose identically // whether or not this run writes. if (ctx.content == before) continue; // File-level suppression disables the transform outright — in // every mode, so `format` never rewrites a suppressed file. if (ctx.Suppressed(rule->name, 0)) { ctx.SetContent(std::move(before)); continue; } // Diff before/after. Same line count → per-line handling: // suppressed changed lines are REVERTED (all modes — suppression // must also stop `format`), the rest yield would-reformat // findings in the dry modes. Different count (or no differing // line — SplitLines hides a trailing '\n', the final-newline // case) → one whole-file finding; count-changing transforms // handle per-line suppression themselves (see // LintContext::Suppressed). std::vector 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; } } } } 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; }