// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #pragma once // This repo's house-style lint rules. Deliberately project-local — the rule // set is NOT part of the crafter-build library, so downstream consumers are // never nudged toward one team's style. Include from project.cpp AFTER // `import std;` and `import Crafter.Build;` (the header uses both and has no // includes of its own), then call AddProjectLintRules(cfg). // // Report-only rules are registered first so their file:line numbers always // match the on-disk file; transforms run last because each one sees the // previous transform's output, which can shift line numbers. // // All detection runs on ctx.CommentStripped() — comments and string/char // literal bodies are blanked to spaces with newlines preserved, so byte // offsets and line numbers in the stripped text are valid in ctx.content. // Transforms use that property directly: find in stripped, edit in content. namespace ProjectLint { inline bool IsCppFile(const Crafter::LintContext& ctx) { std::string ext = ctx.file.extension().string(); return ext == ".cpp" || ext == ".cppm" || ext == ".h"; } inline std::vector Lines(std::string_view text) { std::vector lines; std::size_t start = 0; while (start <= text.size()) { std::size_t end = text.find('\n', start); if (end == std::string_view::npos) { if (start < text.size()) lines.push_back(text.substr(start)); break; } lines.push_back(text.substr(start, end - start)); start = end + 1; } return lines; } inline std::string_view Trim(std::string_view s) { while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1); while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1); return s; } inline bool IsWordChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; } // Net '(' minus ')' on one stripped line. inline std::int64_t ParenDelta(std::string_view s) { std::int64_t d = 0; for (char c : s) { if (c == '(') ++d; if (c == ')') --d; } return d; } inline bool IsPascalCase(std::string_view name) { return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_'); } inline bool IsCamelCase(std::string_view name) { // One trailing underscore is the member-shadowing-a-keyword convention // (requires_, label_) — allowed. if (name.ends_with('_')) name.remove_suffix(1); return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos; } // clang spells a pointer type "int *"; house style attaches the star to the // type. Only affects spelling, never which type is meant. inline std::string NormalisePointerSpelling(std::string_view type) { std::string out; out.reserve(type.size()); for (char c : type) { if ((c == '*' || c == '&') && out.ends_with(' ')) out.pop_back(); out += c; } return out; } // True for a pointer to char, as clang spells a type: "char *", // "const char *", "char **". Deliberately not signed/unsigned char, which are // byte buffers rather than text and were never the target. inline bool IsCharPointer(std::string_view type) { std::size_t star = type.find('*'); if (star == std::string_view::npos) return false; std::string_view base = Trim(type.substr(0, star)); if (base.starts_with("const ")) base.remove_prefix(6); if (base.starts_with("volatile ")) base.remove_prefix(9); return base == "char"; } inline void AddProjectLintRules(Crafter::Configuration& cfg) { using Crafter::LintContext; // ---------------- report-only rules ---------------- // License header: SPDX identifier on line 1, copyright on line 2, blank // line 3. cfg.AddLintRule("spdx-header", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) { ctx.Report(1, "first line must be // SPDX-License-Identifier: ..."); } if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) { ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ..."); } if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) { ctx.Report(3, "third line must be blank (separates the license header)"); } }); cfg.AddLintRule("no-tabs", [](LintContext& ctx) { if (!IsCppFile(ctx)) 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)"); } }); // Naming: types and functions PascalCase, variables camelCase, with // statics, namespace-scope globals and constexpr constants PascalCase. // // Reads the AST. The previous version needed four std::regex, a hand-rolled // {/} scope stack, a cumulative paren-depth counter to avoid mistaking a // wrapped parameter list for a declaration, a keyword denylist, and a // "function-shaped line" guess whose own comment conceded it was heuristic. // All of it existed to answer two questions clang answers directly: what // kind of declaration is this, and what encloses it. cfg.AddAstLintRule("naming", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; using Kind = Crafter::LintDeclKind; std::span decls = ctx.Decls(); for (const Crafter::LintDecl& decl : decls) { if (decl.name.empty()) continue; // anonymous namespace, unnamed struct // A declaration with C language linkage is named by the C library // it mirrors — `extern "C" int setenv(...)` is not ours to rename. if (decl.isExternC) continue; // Namespace scope is now a lookup rather than a brace count, and it // is exact for a local declared inside a function body. bool atNamespaceScope = decl.parent == Crafter::LintNoParent || decls[decl.parent].kind == Kind::Namespace; switch (decl.kind) { case Kind::Class: case Kind::Struct: case Kind::Union: case Kind::Enum: if (!IsPascalCase(decl.name)) { ctx.Report(decl.line, std::format("type '{}' should be PascalCase", decl.name)); } break; case Kind::TypeAlias: if (!IsPascalCase(decl.name)) { ctx.Report(decl.line, std::format("type alias '{}' should be PascalCase", decl.name)); } break; case Kind::Function: case Kind::Method: // main is spelled by the language; operators by their // symbol. Constructors and destructors take their type's // name and are separate kinds, so they never arrive here. if (decl.name == "main" || decl.name.starts_with("operator")) break; if (!IsPascalCase(decl.name)) { ctx.Report(decl.line, std::format("function '{}' should be PascalCase", decl.name)); } break; case Kind::Variable: // constexpr variables are compile-time constants and take // constant naming, like statics and globals. if (decl.isStatic || decl.isConstexpr || atNamespaceScope) { if (!IsPascalCase(decl.name)) { ctx.Report(decl.line, std::format("{} '{}' should be PascalCase", decl.isStatic ? "static variable" : decl.isConstexpr ? "constexpr constant" : "global variable", decl.name)); } } else if (!IsCamelCase(decl.name)) { ctx.Report(decl.line, std::format("variable '{}' should be camelCase", decl.name)); } break; case Kind::Field: if (decl.isStatic || decl.isConstexpr) { if (!IsPascalCase(decl.name)) { ctx.Report(decl.line, std::format("static member '{}' should be PascalCase", decl.name)); } } else if (!IsCamelCase(decl.name)) { ctx.Report(decl.line, std::format("member '{}' should be camelCase", decl.name)); } break; default: break; } } }); // Scoped enums only. Kept on tokens rather than moved to the AST, even // though LintDecl carries an exact isScopedEnum: an AST is one // configuration's slice, so a plain enum inside a preprocessor branch that // is inactive for the host would stop being reported. Tokens are lexed // without evaluating #if, so every platform's code stays covered. cfg.AddLintRule("enum-class", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::span tokens = ctx.Tokens(); for (std::size_t i = 0; i < tokens.size(); ++i) { if (tokens[i].kind != Crafter::LintTokenKind::Keyword) continue; if (ctx.TokenText(tokens[i]) != "enum") continue; // Asking for the next TOKEN rather than the rest of the line means a // declaration split across lines reads identically to one that is // not — the regex this replaced required the name to follow `enum` // on the same line, so `enum\n Color {` went unreported. std::string_view next = i + 1 < tokens.size() ? ctx.TokenText(tokens[i + 1]) : std::string_view{}; if (next == "class" || next == "struct") continue; ctx.Report(tokens[i].line, "use enum class instead of plain enum"); } }); cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::vector lines = Lines(ctx.CommentStripped()); for (std::size_t i = 0; i < lines.size(); ++i) { if (lines[i].contains("std::cout")) { ctx.Report(i + 1, "use std::println instead of std::cout"); } else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) { ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr"); } } }); // Prefer std::string / std::string_view over char* in our own declarations. // // Reads the AST rather than the text, which retires the substring denylist // this rule used to carry (argv, getenv, setenv, dlerror, c_str, .data(, // reinterpret_cast, extern "). Every entry was a patch for one interop // site, the list could only grow, and it disabled the rule for a whole LINE // whenever one appeared. The question is now asked directly: whose header // dictates this spelling? A declaration with C language linkage, or one // that binds to an entity declared outside the project, is somebody else's // API and keeps its spelling. // // Only declarations are considered. A char* inside a cast or an expression // is not an interface, and reinterpret_cast for binary IO — the case // the denylist existed to permit — is no longer a finding to suppress. cfg.AddAstLintRule("no-char-pointer", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::span decls = ctx.Decls(); for (const Crafter::LintDecl& decl : decls) { // main's signature is fixed by the language, so its argv is no more // ours to modernise than a C API's is. if (decl.parent != Crafter::LintNoParent && decls[decl.parent].name == "main") continue; if (decl.kind == Crafter::LintDeclKind::Function && decl.name == "main") continue; switch (decl.kind) { case Crafter::LintDeclKind::Variable: case Crafter::LintDeclKind::Parameter: case Crafter::LintDeclKind::Field: case Crafter::LintDeclKind::TypeAlias: case Crafter::LintDeclKind::Function: case Crafter::LintDeclKind::Method: break; default: continue; } if (decl.isExternC || decl.isForeignApi) continue; if (!IsCharPointer(decl.type)) continue; ctx.Report(decl.line, std::format("prefer std::string / std::string_view over char* ('{}' is '{}')", decl.name, decl.type)); } }); // String building via `+` with a literal operand → std::format. Plain // `a += b` accumulation (builder pattern) stays legal. Single-line chains // of simple operands (identifier / member / call chains, parenthesized // groups, literals) are REWRITTEN automatically; anything the operand // scanner can't prove safe — raw-string lines, ternaries, mixed // operators, multi-line expressions — is reported for a human instead. cfg.AddLintRule("format-concat", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; const std::string& code = ctx.CommentStripped(); std::vector stripped = Lines(code); // Walk one operand leftwards from `from` (exclusive). Returns the // operand's begin, or npos when the shape isn't a simple postfix // chain (then the whole chain bails to a report). auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t { std::size_t i = from; while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i; bool any = false; for (;;) { if (i == 0) break; char c = s[i - 1]; if (c == ')' || c == ']') { char open = c == ')' ? '(' : '['; std::size_t depth = 0; do { --i; if (s[i] == c) ++depth; if (s[i] == open) --depth; if (depth == 0) break; } while (i > 0); if (depth != 0) return std::string_view::npos; any = true; // A group directly preceded by an identifier (or another // group) is a call/index postfix — keep consuming the // callee: `path.string()` is ONE operand, not `()`. if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue; } else if (c == '"') { --i; // closing quote; interior is blanked, find the opener while (i > 0 && s[i - 1] != '"') --i; if (i == 0) return std::string_view::npos; --i; any = true; } else if (IsWordChar(c)) { while (i > 0 && IsWordChar(s[i - 1])) --i; any = true; } else if (c == '.' && any) { --i; continue; } else if (c == ':' && i > 1 && s[i - 2] == ':' && any) { i -= 2; continue; } else if (c == '>' && i > 1 && s[i - 2] == '-' && any) { i -= 2; continue; } else { break; } // After a primary, only connectors continue the operand. if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':') || (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue; break; } if (!any) return std::string_view::npos; // A unary operator, ternary, or other-precedence operator in // front means expression structure we don't reason about — bail. std::size_t b = i; while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b; if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos; return i; }; // Walk one operand rightwards from `from` (inclusive). Returns one // past the operand's end, or npos on bail. auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t { std::size_t i = from; while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; bool any = false; for (;;) { if (i >= s.size()) break; char c = s[i]; if (c == '(' || c == '[') { char close = c == '(' ? ')' : ']'; std::size_t depth = 0; while (i < s.size()) { if (s[i] == c) ++depth; if (s[i] == close && --depth == 0) { ++i; break; } ++i; } if (depth != 0) return std::string_view::npos; any = true; } else if (c == '"') { ++i; while (i < s.size() && s[i] != '"') ++i; if (i >= s.size()) return std::string_view::npos; ++i; any = true; } else if (IsWordChar(c)) { while (i < s.size() && IsWordChar(s[i])) ++i; any = true; } else if (any && c == '.') { ++i; continue; } else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') { i += 2; continue; } else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') { i += 2; continue; } else { break; } if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.' || (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':') || (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue; break; } return any ? i : std::string_view::npos; }; struct Edit { std::size_t begin; std::size_t end; std::string replacement; }; std::vector edits; // offsets into ctx.content std::size_t lineStart = 0; for (std::size_t li = 0; li < stripped.size(); ++li) { std::string_view line = stripped[li]; std::size_t lineOff = lineStart; lineStart += line.size() + 1; std::string_view trimmed = Trim(line); if (trimmed.starts_with('#')) continue; // A chain is read within one line, so a literal that spans lines // would be sliced in half by chainEnd. Single-line raw strings are // fine: they reduce to R"…" in the stripped view, so they fail the // "is a plain literal" test below and travel through as an // argument, spelling and all. bool spansLines = ctx.LineHasMultiLineToken(li + 1); std::size_t searchFrom = 0; while (searchFrom < line.size()) { // A candidate `+` that isn't ++ / += and touches a literal. std::size_t plus = line.find('+', searchFrom); if (plus == std::string_view::npos) break; searchFrom = plus + 1; if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; } if (plus > 0 && line[plus - 1] == '+') continue; std::size_t leftEnd = plus; while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd; std::size_t rightBegin = plus + 1; while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin; bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"') || (rightBegin < line.size() && line[rightBegin] == '"'); if (!literalAdjacent) continue; if (spansLines) { ctx.Report(li + 1, "use std::format instead of string concatenation with + (multi-line literal, fix manually)"); break; } // Expand to the full chain: operands joined by `+`. std::size_t chainBegin = operandLeft(line, plus); std::size_t chainEnd = operandRight(line, plus + 1); bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos; while (!bail) { std::size_t b = chainBegin; while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b; if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) { std::size_t prev = operandLeft(line, b - 1); if (prev == std::string_view::npos) { bail = true; break; } chainBegin = prev; } else break; } while (!bail) { std::size_t e = chainEnd; while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e; if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) { std::size_t next = operandRight(line, e + 1); if (next == std::string_view::npos) { bail = true; break; } chainEnd = next; } else break; } if (bail) { ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); break; } // Split the chain at depth-0 '+' into parts; literals feed the // format string, everything else becomes an argument. std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin); std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin); std::string fmt; std::vector args; std::size_t partBegin = 0; std::int64_t depth = 0; bool inLit = false; for (std::size_t p = 0; p <= chain.size(); ++p) { if (p < chain.size()) { char c = chain[p]; if (c == '"') inLit = !inLit; if (inLit) continue; if (c == '(' || c == '[') ++depth; if (c == ')' || c == ']') --depth; if (!(c == '+' && depth == 0)) continue; } std::string_view part = Trim(chain.substr(partBegin, p - partBegin)); std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin)); if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2 && std::count(part.begin(), part.end(), '"') == 2) { for (char c : rawPart.substr(1, rawPart.size() - 2)) { fmt += c; if (c == '{') fmt += '{'; if (c == '}') fmt += '}'; } } else if (part.contains('"') && part.contains('+')) { bail = true; // nested concat inside an operand — human territory break; } else { fmt += "{}"; args.push_back(rawPart); } partBegin = p + 1; } if (bail || args.empty()) { ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); break; } std::string replacement = std::format("std::format(\"{}\"", fmt); for (std::string_view a : args) replacement += std::format(", {}", a); replacement += ")"; edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)}); searchFrom = chainEnd; } } if (edits.empty()) return; std::string out = ctx.content; std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; }); for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement); ctx.SetContent(std::move(out)); }); // ---------------- transforms (auto-fixed by `crafter-build format`) ---------------- // short/int/long → fixed-width types. Lines mentioning main/argc/argv or // extern "C" keep their C-conventional ints. cfg.AddAstLintRule("fixed-width-types", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; const std::string& code = ctx.CommentStripped(); struct Rep { std::size_t pos; std::size_t len; std::string to; }; std::vector reps; // Byte ranges whose integer spelling is not ours to change. A wrong // rewrite here does not merely over-report — it produces code that no // longer matches the API it is calling — so this is derived from the // AST rather than from substrings on the line. // // Replaces the old per-LINE textual exemption (`int main`, `argc`, // `argv`, `extern "`), which both missed cases and disabled the rule // for everything else sharing a line with one of those words. std::vector> protectedRanges; for (const Crafter::LintDecl& decl : ctx.Decls()) { if (decl.isExternC || decl.isForeignApi) { protectedRanges.emplace_back(decl.begin, decl.end); continue; } // main's signature is fixed by the language. Only the signature: // its body is ordinary code, and the declaration's extent covers // the whole function. if (decl.kind == Crafter::LintDeclKind::Function && decl.name == "main") { std::size_t bodyBrace = ctx.content.find('{', decl.begin); protectedRanges.emplace_back(decl.begin, bodyBrace == std::string::npos ? decl.end : bodyBrace); } } auto isProtected = [&protectedRanges](std::size_t offset) { return std::any_of(protectedRanges.begin(), protectedRanges.end(), [offset](const std::pair& range) { return offset >= range.first && offset < range.second; }); }; // Builtin integer type specifiers combine in any order (`unsigned // long`, `long unsigned int`, ...), so match whole RUNS of these // keywords and classify the run, rather than the words one by one. static const std::unordered_set IntWords = { "unsigned", "signed", "short", "long", "int", "char", }; std::size_t lineStart = 0; while (lineStart <= code.size()) { std::size_t lineEnd = code.find('\n', lineStart); if (lineEnd == std::string::npos) lineEnd = code.size(); std::string_view line(code.data() + lineStart, lineEnd - lineStart); std::size_t pos = 0; while (pos < line.size()) { if (!IsWordChar(line[pos])) { ++pos; continue; } std::size_t wordEnd = pos; while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; } // Extend the run over consecutive specifier keywords. std::size_t runBegin = pos; std::size_t runEnd = wordEnd; bool hasUnsigned = false; bool hasSigned = false; bool hasShort = false; bool hasChar = false; bool hasLong = false; std::string_view nextWord; for (;;) { std::string_view word = line.substr(pos, wordEnd - pos); if (word == "unsigned") hasUnsigned = true; else if (word == "signed") hasSigned = true; else if (word == "short") hasShort = true; else if (word == "char") hasChar = true; else if (word == "long") hasLong = true; runEnd = wordEnd; pos = wordEnd; while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos; wordEnd = pos; while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; nextWord = line.substr(pos, wordEnd - pos); if (!IntWords.contains(nextWord)) break; } pos = runEnd; // Bare `char` is text, not an integer — only signed/unsigned // char is byte arithmetic. `long double` is a floating type. if (hasChar && !hasUnsigned && !hasSigned) continue; if (nextWord == "double") continue; if (isProtected(lineStart + runBegin)) continue; std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32"; reps.push_back({lineStart + runBegin, runEnd - runBegin, std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)}); } lineStart = lineEnd + 1; } if (reps.empty()) return; std::string out = ctx.content; std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; }); for (const Rep& r : reps) out.replace(r.pos, r.len, r.to); ctx.SetContent(std::move(out)); }); // One declaration per statement. Auto-splits the simple initialized form // `Type a = x, b = y;`; anything with pointers/references, parens, or // templates in the declarators is only reported (splitting `int* a, b;` // would change b's type). cfg.AddAstLintRule("single-declaration", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::span decls = ctx.Decls(); std::span tokens = ctx.Tokens(); struct Edit { std::size_t begin; std::size_t end; std::string text; }; std::vector edits; for (std::size_t i = 0; i < decls.size(); ++i) { const Crafter::LintDecl& head = decls[i]; if (head.kind != Crafter::LintDeclKind::Variable && head.kind != Crafter::LintDeclKind::Field) continue; // A group head owns the shared type, so its extent starts before // its name. Continuation declarators start AT their name. if (head.begin == head.nameOffset) continue; std::size_t last = i; while (last + 1 < decls.size()) { const Crafter::LintDecl& next = decls[last + 1]; if (next.kind != head.kind || next.parent != head.parent) break; if (next.begin != next.nameOffset) break; // starts a new statement ++last; } if (last == i) continue; // a single declarator: nothing to split // The statement runs to the ';' after the final declarator. auto semicolon = std::ranges::find_if(tokens, [&](const Crafter::LintToken& t) { return t.offset >= decls[last].end && ctx.TokenText(t) == ";"; }); if (semicolon == tokens.end()) continue; std::size_t stmtEnd = semicolon->offset + 1; // A comment anywhere inside the statement would be swallowed by the // rewrite. One AFTER the ';' is outside the replaced range and // survives, which the line-based version could not manage — it // refused the whole line. bool hasInnerComment = std::ranges::any_of(tokens, [&](const Crafter::LintToken& t) { return t.kind == Crafter::LintTokenKind::Comment && t.offset >= head.begin && t.offset < stmtEnd; }); if (hasInnerComment) continue; if (ctx.Suppressed("single-declaration", head.line)) continue; // Indent from the head's own line, so a statement that starts // mid-line is left alone rather than reflowed. std::size_t lineBegin = ctx.content.rfind('\n', head.begin); lineBegin = lineBegin == std::string::npos ? 0 : lineBegin + 1; if (!Trim(std::string_view(ctx.content).substr(lineBegin, head.begin - lineBegin)).empty()) continue; std::string indent(std::string_view(ctx.content).substr(lineBegin, head.begin - lineBegin)); std::string replacement; for (std::size_t d = i; d <= last; ++d) { // Each declarator gets its OWN type. This is the whole reason // this rule needed the AST: copying the head's type prefix // turns `int* a, b;` into `int* a; int* b;` and silently // changes b's type. clang has already resolved b as plain int. std::string type = NormalisePointerSpelling(decls[d].type); std::string_view declarator = std::string_view(ctx.content).substr(decls[d].nameOffset, decls[d].end - decls[d].nameOffset); if (d > i) replacement += std::format("\n{}", indent); // NormalisePointerSpelling already attached the star to the // type, so the separator is always a single space: `int* a`. replacement += std::format("{} {};", type, Trim(declarator)); } edits.push_back({head.begin, stmtEnd, std::move(replacement)}); i = last; } if (edits.empty()) return; std::string out = ctx.content; std::ranges::sort(edits, [](const Edit& a, const Edit& b) { return a.begin > b.begin; }); for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.text); ctx.SetContent(std::move(out)); }); // K&R braces: `{` on its own line after a `)`/else/do/try header joins // onto the header line. Standalone scope blocks (previous line ends with // `;`, `{`, a comment, …) are intentional and stay. cfg.AddLintRule("brace-style", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::vector stripped = Lines(ctx.CommentStripped()); std::vector outLines; outLines.reserve(ctx.lines.size()); bool changed = false; for (std::size_t i = 0; i < ctx.lines.size(); ++i) { std::string_view trimmed = Trim(stripped[i]); if (trimmed == "{" && !outLines.empty()) { std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{}; bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try"; bool hasComment = ctx.LineHasComment(i + 1) || (i > 0 && ctx.LineHasComment(i)); bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1); if (headerBefore && !hasComment && !suppressed) { std::string& prev = outLines.back(); while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back(); prev += " {"; changed = true; continue; } } outLines.emplace_back(ctx.Line(i + 1)); } if (!changed) return; std::string out; for (std::size_t i = 0; i < outLines.size(); ++i) { out += outLines[i]; if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; } ctx.SetContent(std::move(out)); }); // Single-statement if bodies join onto the if line: `if (x)\n y;` → // `if (x) y;` — when the condition's parens balance on one line, the body // is one `;`-terminated statement, neither line carries a comment, and // the joined line stays ≤ 250 columns. cfg.AddLintRule("if-single-line", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::vector stripped = Lines(ctx.CommentStripped()); static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)"); std::vector outLines; bool changed = false; for (std::size_t i = 0; i < ctx.lines.size(); ++i) { std::string lineStr(stripped[i]); if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) { std::string_view body = Trim(stripped[i + 1]); bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';') && !ctx.LineHasComment(i + 1) && !ctx.LineHasComment(i + 2) && !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2); std::string joined = std::string(ctx.Line(i + 1)); while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); joined += " "; joined += Trim(ctx.Line(i + 2)); if (joinable && joined.size() <= 250) { outLines.push_back(std::move(joined)); ++i; // consume the body line changed = true; continue; } } outLines.emplace_back(ctx.Line(i + 1)); } if (!changed) return; std::string out; for (std::size_t i = 0; i < outLines.size(); ++i) { out += outLines[i]; if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; } ctx.SetContent(std::move(out)); }); // Wrapped call arguments join back onto one line when the whole // expression fits in 250 columns. Lambda bodies (lines ending `{`), // comments, raw strings, and preprocessor lines are left alone. // Operator-style continuations (next line starts with && / || / |) join // under the same length limit. cfg.AddLintRule("wrap-join", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; // Fixpoint loop: a join can enable another (joining an inner paren // wrap balances the line an operator continuation hangs off), so one // pass is not idempotent. Iterate until a pass changes nothing; the // cap is a safety net — joins strictly reduce the line count, so // termination is guaranteed anyway. for (std::int32_t pass = 0; pass < 16; ++pass) { std::vector stripped = Lines(ctx.CommentStripped()); std::vector outLines; bool changed = false; for (std::size_t i = 0; i < ctx.lines.size(); ++i) { std::string_view trimmed = Trim(stripped[i]); std::int64_t delta = ParenDelta(stripped[i]); bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#') && !trimmed.ends_with('{') && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1); if (candidate) { std::string joined(ctx.Line(i + 1)); std::size_t j = i + 1; bool ok = true; while (delta > 0 && j < ctx.lines.size() && j - i <= 4) { std::string_view next = Trim(stripped[j]); // A `{`-ending line is fine when it closes the expression // (a control header's `) {`); mid-expression it means a // lambda body starts — leave those wrapped. bool closes = delta + ParenDelta(stripped[j]) <= 0; if (next.empty() || (next.ends_with('{') && !closes) || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) { ok = false; break; } while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); std::string_view fragment = Trim(ctx.Line(j + 1)); // No separator right after an opening paren or before a // closing one — joining must not manufacture `( x` / `x )`. if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " "; joined += fragment; delta += ParenDelta(stripped[j]); ++j; } for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); if (ok && delta <= 0 && joined.size() <= 250) { outLines.push_back(std::move(joined)); i = j - 1; // consumed through line j-1 (0-based i) changed = true; continue; } } else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#') && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1)) { auto isOpStart = [](std::string_view s) { return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| "); }; // Operator-style continuation chain (`a\n && b\n && c`). // Joined when the WHOLE chain stays within 250 columns — // longer conditions legitimately wrap. Same mechanics as the // paren join: chain lines must be comment-free, raw-string // free, and paren-balanced. if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) { std::string joined(ctx.Line(i + 1)); std::size_t j = i + 1; bool ok = true; while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) { if (ParenDelta(stripped[j]) != 0 || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) { ok = false; break; } while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); joined += " "; joined += Trim(ctx.Line(j + 1)); ++j; } for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); if (ok && joined.size() <= 250) { outLines.push_back(std::move(joined)); i = j - 1; // consumed the chain changed = true; continue; } } } outLines.emplace_back(ctx.Line(i + 1)); } if (!changed) return; std::string out; for (std::size_t i = 0; i < outLines.size(); ++i) { out += outLines[i]; if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; } ctx.SetContent(std::move(out)); } }); // No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only // intra-line (a line legitimately ends with '(' when a call wraps); // string/comment interiors are excluded via the stripped text. cfg.AddLintRule("paren-spacing", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; const std::string& code = ctx.CommentStripped(); std::vector> cuts; // [begin, end) spans of spaces to delete for (std::size_t i = 0; i < code.size(); ++i) { if (code[i] == '(' ) { std::size_t j = i + 1; while (j < code.size() && code[j] == ' ') ++j; if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j}); } else if (code[i] == ')') { std::size_t j = i; while (j > 0 && code[j - 1] == ' ') --j; // Only single-space padding: multi-space runs are usually // deliberate alignment columns. if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i}); } } if (cuts.empty()) return; std::string out = ctx.content; for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first); ctx.SetContent(std::move(out)); }); cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; std::string out; out.reserve(ctx.content.size()); for (std::size_t n = 1; n <= ctx.lines.size(); ++n) { std::string_view line = ctx.Line(n); // Byte fidelity on CRLF files: peel the \r, trim, put it back. bool crlf = line.ends_with('\r'); if (crlf) line.remove_suffix(1); while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1); out += line; if (crlf) out += '\r'; // The last line only had a newline if the file ended with one — // preserve that byte exactly so this rule doesn't shadow // final-newline. if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n'; } ctx.SetContent(std::move(out)); }); cfg.AddLintRule("final-newline", [](LintContext& ctx) { if (!IsCppFile(ctx)) return; if (!ctx.content.empty() && !ctx.content.ends_with('\n')) { ctx.SetContent(ctx.content + '\n'); } }); } } // namespace ProjectLint