// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; export module Crafter.Build:Lint_impl; import std; import :Lint; import :Clang; import :Platform; import :Progress; namespace fs = std::filesystem; using namespace Crafter; namespace { // Blank //-comments, /*...*/ comments and string/char literal bodies to // spaces while copying '\n' through, so byte offsets and line numbers in // the result match the original text. Raw string literals are not // recognized (documented v1 limitation) — their bodies pass through as // ordinary string content until the first '"'. std::string StripComments(std::string_view src) { enum class State { Code, LineComment, BlockComment, String, Char }; std::string out; out.reserve(src.size()); State state = State::Code; for (std::size_t i = 0; i < src.size(); ++i) { char c = src[i]; char next = i + 1 < src.size() ? src[i + 1] : '\0'; switch (state) { case State::Code: if (c == '/' && next == '/') { state = State::LineComment; out += " "; ++i; } else if (c == '/' && next == '*') { state = State::BlockComment; out += " "; ++i; } else if (c == '"') { state = State::String; out += c; // keep the delimiter so quoting stays visible } else if (c == '\'') { state = State::Char; out += c; } else { out += c; } break; case State::LineComment: if (c == '\n') { state = State::Code; out += c; } else { out += ' '; } break; case State::BlockComment: if (c == '*' && next == '/') { state = State::Code; out += " "; ++i; } else { out += c == '\n' ? '\n' : ' '; } break; case State::String: case State::Char: { char delim = state == State::String ? '"' : '\''; if (c == '\\' && next != '\0') { out += " "; ++i; if (next == '\n') out.back() = '\n'; } else if (c == delim) { state = State::Code; out += c; } else { out += c == '\n' ? '\n' : ' '; } break; } } } 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 = StripComments(content); } return *commentStrippedCache; } 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 } 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; 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; }