// 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; }; 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"); 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; } // 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; } void CollectConfigSources(const Configuration& c, std::set& files) { for (const std::unique_ptr& mod : c.interfaces) { files.insert(fs::path(std::format("{}.cppm", mod->path.string()))); for (const std::unique_ptr& part : mod->partitions) { files.insert(fs::path(std::format("{}.cppm", part->path.string()))); } } for (const Implementation& impl : c.implementations) { files.insert(fs::path(std::format("{}.cpp", impl.path.string()))); } // cFiles/cuda resolve against cwd at build time (see Build's compile // loops); mirror that here. for (const fs::path& cf : c.cFiles) { files.insert(fs::absolute(fs::path(std::format("{}.c", cf.string()))).lexically_normal()); } for (const fs::path& cu : c.cuda) { files.insert(fs::absolute(fs::path(std::format("{}.cu", cu.string()))).lexically_normal()); } for (const Shader& shader : c.shaders) { files.insert(fs::absolute(shader.path).lexically_normal()); } // files/buildFiles/assets are deliberately excluded: data shipped or // referenced by the build, not source code. } } std::string LintContext::Extension() const { return file.extension().string(); } std::string_view LintContext::Line(std::size_t n) const { if (n == 0 || n > lines.size()) return {}; return lines[n - 1]; } const std::string& LintContext::CommentStripped() { if (!commentStrippedCache) { 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]; } 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 } void Configuration::AddLintRule(std::string name, std::function check) { lintRules.push_back({std::move(name), std::move(check)}); } 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; } std::set files; for (Configuration* c : localConfigs) { CollectConfigSources(*c, files); for (const Test& t : c->tests) CollectConfigSources(t.config, files); } if (!opts.projectFile.empty()) files.insert(opts.projectFile); fs::path cwd = fs::current_path(); auto shown = [&cwd](const fs::path& p) { return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p; }; for (const fs::path& file : files) { std::ifstream in(file, std::ios::binary); if (!in) continue; // config parse already read it; a vanished file fails the build first std::stringstream buffer; buffer << in.rdbuf(); LintContext ctx; ctx.file = file; ctx.content = std::move(buffer).str(); ctx.lines = SplitLines(ctx.content); ctx.sink = &summary.findings; ++summary.filesLinted; const std::string original = ctx.content; for (const LintRule* rule : rules) { ctx.activeRule = rule->name; // Snapshot for transform diffing — and the revert point if the // rule throws, so a half-applied transform never reaches disk // and chained rules see clean input. std::string before = ctx.content; try { rule->check(ctx); } catch (const std::exception& e) { // Never let a rule's exception unwind across the project // DLL boundary — surface it as a finding instead. ctx.SetContent(std::move(before)); ctx.Report(0, std::format("rule '{}' threw: {}", rule->name, e.what())); ++summary.errors; if (opts.mode != LintMode::Report) { // Report mode prints it with the findings; the other // modes don't print findings, so surface it here. std::println(std::cerr, "{}: rule '{}' threw: {}", shown(file).string(), rule->name, e.what()); } continue; } // Transform detection is compare-by-value: a rule that SetContents // identical bytes is not a change. The mutated content carries // forward in every mode so chained rules compose identically // whether or not this run writes. if (ctx.content == before) continue; // File-level suppression disables the transform outright — in // every mode, so `format` never rewrites a suppressed file. if (ctx.Suppressed(rule->name, 0)) { ctx.SetContent(std::move(before)); continue; } // Diff before/after. Same line count → per-line handling: // suppressed changed lines are REVERTED (all modes — suppression // must also stop `format`), the rest yield would-reformat // findings in the dry modes. Different count (or no differing // line — SplitLines hides a trailing '\n', the final-newline // case) → one whole-file finding; count-changing transforms // handle per-line suppression themselves (see // LintContext::Suppressed). std::vector 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; }