From 825cc875ad5f317405e838c1791cee17caee8671 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 2 Oct 2026 20:03:22 +0200 Subject: [PATCH] refactor: pull the house-style rules from Crafter.LintRules instead of a copied header lint-rules.h moves to its own repo as the module Crafter.LintRules, which project.cpp now imports through `#pragma crafter dependency`. Editing a rule no longer needs `touch project.cpp`: the rebuilt dependency makes project.so stale by itself. tests/HouseRules moves with the rules. Building this checkout needs a crafter-build that understands the directive (`./build.sh` bootstraps one) and the Crafter.LintRules repo on Forgejo. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- lint-rules.h | 971 -------------------------------------- project.cpp | 12 +- tests/HouseRules/main.cpp | 513 -------------------- 4 files changed, 7 insertions(+), 1491 deletions(-) delete mode 100644 lint-rules.h delete mode 100644 tests/HouseRules/main.cpp diff --git a/README.md b/README.md index a48f038..987df69 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ Directives silence findings **and** stop `format` from rewriting the suppressed The linted file set is the project's own sources: module interfaces (+ partitions), implementations, `cFiles`, `cuda`, shader sources, declared tests' sources, and `project.cpp` itself — for the root `Configuration` plus every transitive dependency whose path lies inside the project root. `GitProject` / external dependencies are foreign code and are skipped, as are `files`/`buildFiles`/`assets` (data, not source). Rules registered on any in-root config are collected and deduplicated by name, so with a lib/exe split you can attach them to either. Rules run in registration order, each seeing the previous transform's output; a rule that throws is reverted and becomes a finding instead of aborting the run. Files are read and written as bytes — line views carry a trailing `\r` on CRLF files, so transforms that should be CRLF-safe must handle it (see the `trim-trailing-ws` dogfood rule). -This repo dogfoods the feature with its full house style — 15 rules in [lint-rules.h](lint-rules.h) (naming conventions, K&R braces, `enum class`, `std::println`, fixed-width integer types, `std::format` over concatenation, wrap-joining, whitespace hygiene), registered by [project.cpp](project.cpp) via one `ProjectLint::AddProjectLintRules(cfg)` call. The header is deliberately project-local, not part of the crafter-build library — copy it into a sibling repo to adopt the same style there; `tests/HouseRules/` validates every transform. Most rules are transforms: `crafter-build format` mechanically restyled this entire codebase (~150 fixes), with only expression rewrites the operand scanner can't prove safe (ternaries, raw-string lines) reported for hand-fixing. +This repo dogfoods the feature with its full house style — 17 rules (naming conventions, K&R braces, `enum class`, `std::println`, fixed-width integer types, `std::format` over concatenation, wrap-joining, whitespace hygiene, AST-backed `const`/`constexpr`/`char*` checks) in their own repo, [Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules), which [project.cpp](project.cpp) pulls in with a `#pragma crafter dependency` and registers with one `LintRules::AddRules(cfg)` call. The rules are deliberately not part of the crafter-build library — add the same two lines to another project to adopt the style there; the rules repo's `tests/HouseRules/` validates every transform. Building this repo's own `project.cpp` therefore needs a crafter-build that understands the directive; `./build.sh` bootstraps one. Most rules are transforms: `crafter-build format` mechanically restyled this entire codebase (~150 fixes), with only expression rewrites the operand scanner can't prove safe (ternaries, raw-string lines) reported for hand-fixing. ## Examples diff --git a/lint-rules.h b/lint-rules.h deleted file mode 100644 index b701980..0000000 --- a/lint-rules.h +++ /dev/null @@ -1,971 +0,0 @@ -// 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. - // A local that is never written should say so. Restricted to SCALARS — - // integers, bools, enums, pointers, floating types — which is what makes - // the answer exact rather than a guess: a scalar has no member functions, - // so the only ways to write one are assignment, ++/--, having its address - // taken, or binding to a non-const reference, and the AST layer tracks all - // four. For a class type, a non-const method call could mutate it and - // deciding that needs the whole-program analysis clang-tidy does. - // - // Report-only. Adding const is a judgement about intent as much as - // mechanics, and a wrong suggestion should cost a glance, not a build. - cfg.AddAstLintRule("const-local", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::span decls = ctx.Decls(); - for (const Crafter::LintDecl& decl : decls) { - if (decl.kind != Crafter::LintDeclKind::Variable) continue; - if (decl.parent == Crafter::LintNoParent) continue; - // Locals only: a namespace-scope or static variable may be written - // from a translation unit this parse cannot see. - Crafter::LintDeclKind enclosing = decls[decl.parent].kind; - bool isLocal = enclosing == Crafter::LintDeclKind::Function || enclosing == Crafter::LintDeclKind::Method - || enclosing == Crafter::LintDeclKind::Constructor || enclosing == Crafter::LintDeclKind::Destructor; - if (!isLocal || decl.isStatic) continue; - if (decl.isConst || decl.isConstexpr) continue; - if (!decl.isScalar || decl.isMutated) continue; - if (decl.name.empty()) continue; - // A range-for binding is not what a reader pictures as an - // assignable variable, and `for (T* const x : …)` is not a spelling - // anybody writes. - if (decl.isLoopVariable) continue; - // Likewise `T* const p` — the useful constness for a pointer local - // is almost always on the pointee, which this rule cannot advise - // on. Restricting to value types keeps the advice actionable. - if (decl.type.contains('*')) continue; - ctx.Report(decl.line, std::format("'{}' is never modified — declare it const", decl.name)); - } - }); - - // A constant whose value is already a constant expression can be constexpr, - // which moves it into the type system instead of leaving it to the - // optimiser. Constant-ness of the initialiser is decided by clang's own - // evaluator, so `sizeof(T)`, a fold over other constants and anything else - // that folds all qualify, while `Compute()` does not. - // - // Restricted to scalars on purpose: for a class type, constexpr may be - // unavailable even when the initialiser folds (a std::string constant - // cannot be constexpr at namespace scope), and the evaluator answering - // "this folds" is not the same question as "constexpr is legal here". - cfg.AddAstLintRule("constexpr-constant", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - for (const Crafter::LintDecl& decl : ctx.Decls()) { - if (decl.kind != Crafter::LintDeclKind::Variable && decl.kind != Crafter::LintDeclKind::Field) continue; - if (!decl.isConst || decl.isConstexpr || !decl.isScalar) continue; - // A pointer's value is an address, which is rarely a constant - // expression and never an interesting one to promote. - if (decl.type.contains('*')) continue; - if (!decl.isConstantInitialised) continue; - if (decl.name.empty()) continue; - ctx.Report(decl.line, std::format("'{}' has a constant initialiser — declare it constexpr", decl.name)); - } - }); - - 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 diff --git a/project.cpp b/project.cpp index 646740d..ca4c077 100644 --- a/project.cpp +++ b/project.cpp @@ -3,7 +3,8 @@ import std; import Crafter.Build; -#include "lint-rules.h" +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" +import Crafter.LintRules; namespace fs = std::filesystem; using namespace Crafter; @@ -132,13 +133,12 @@ extern "C" Configuration CrafterBuildProject(std::span a .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); - cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() }); } - // Dogfood: this repo's house-style rules (see lint-rules.h). Report - // rules gate `crafter-build lint`; transform rules also auto-fix under - // `crafter-build format`. - ProjectLint::AddProjectLintRules(cfg); + // Dogfood: the house-style rules, imported from their own repo through + // the pragma above. Report rules gate `crafter-build lint`; transform + // rules also auto-fix under `crafter-build format`. + LintRules::AddRules(cfg); return cfg; } diff --git a/tests/HouseRules/main.cpp b/tests/HouseRules/main.cpp deleted file mode 100644 index e4c8058..0000000 --- a/tests/HouseRules/main.cpp +++ /dev/null @@ -1,513 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -import std; -import Crafter.Build; -#include "../../lint-rules.h" -namespace fs = std::filesystem; -using namespace Crafter; - -// Validates the house-style ruleset in lint-rules.h — the transforms and -// reports this repo (and sibling repos that copy the header) rely on. Each -// case writes a scratch file, runs ONE rule via the glob filter, and asserts -// the findings and/or rewritten bytes. -namespace { - std::int32_t Failures = 0; - - void Check(bool cond, std::string_view msg) { - if (!cond) { - std::println(std::cerr, "FAIL: {}", msg); - ++Failures; - } - } - - struct RuleRun { - LintSummary summary; - std::string text; // file content after the run - }; - - RuleRun RunRule(std::string_view content, std::string_view rule, LintMode mode) { - static std::int32_t Counter = 0; - fs::path dir = fs::temp_directory_path() / "crafter-build-house-rules" / std::format("case-{}", Counter++); - fs::remove_all(dir); - fs::create_directories(dir); - { - std::ofstream f(dir / "f.cpp", std::ios::binary | std::ios::trunc); - f.write(content.data(), static_cast(content.size())); - } - Configuration cfg; - cfg.path = dir; - cfg.name = "house-fixture"; - cfg.outputName = "house-fixture"; - cfg.target = HostTarget(); - std::array ifaces = {}; - std::array impls = { "f" }; - cfg.GetInterfacesAndImplementations(ifaces, impls); - ProjectLint::AddProjectLintRules(cfg); - RunLintOptions opts; - opts.mode = mode; - opts.globs = { std::string(rule) }; - RuleRun run; - run.summary = RunLint(cfg, opts); - std::ifstream f(dir / "f.cpp", std::ios::binary); - std::stringstream buffer; - buffer << f.rdbuf(); - run.text = std::move(buffer).str(); - return run; - } - - bool HasFinding(const LintSummary& s, std::string_view fragment) { - return std::any_of(s.findings.begin(), s.findings.end(), [&](const LintFinding& f) { return f.message.contains(fragment); }); - } -} - -int main() { - // fixed-width-types: int/long long convert; main/argc and extern "C" stay. - { - RuleRun r = RunRule("int Foo(long long v) { int x = 5; return x; }\n" "extern \"C\" int setenv(const char* n, const char* v, int o);\n" "int main(int argc, char** argv) { return 0; }\n", "fixed-width-types", LintMode::Apply); - Check(r.text.contains("std::int32_t Foo(std::int64_t v) { std::int32_t x = 5;"), "fixed-width converts int and long long"); - Check(r.text.contains("extern \"C\" int setenv"), "extern \"C\" prototype keeps int"); - Check(r.text.contains("int main(int argc"), "main/argc keep int"); - } - - // fixed-width-types is signed/unsigned aware, in any specifier order; - // bare char (text) and long double (floating) are not integers. - { - RuleRun r = RunRule("void F() {\n" - " unsigned a = 1;\n" - " unsigned int b = 2;\n" - " unsigned long long c = 3;\n" - " long unsigned int d = 4;\n" - " unsigned short e = 5;\n" - " signed f = 6;\n" - " signed char g = 7;\n" - " auto h = static_cast(g);\n" - " char text = 'x';\n" - " long double pi = 3.14L;\n" - "}\n", - "fixed-width-types", LintMode::Apply); - Check(r.text.contains("std::uint32_t a = 1;"), "bare unsigned -> uint32"); - Check(r.text.contains("std::uint32_t b = 2;"), "unsigned int -> uint32"); - Check(r.text.contains("std::uint64_t c = 3;"), "unsigned long long -> uint64"); - Check(r.text.contains("std::uint64_t d = 4;"), "long unsigned int (reordered) -> uint64"); - Check(r.text.contains("std::uint16_t e = 5;"), "unsigned short -> uint16"); - Check(r.text.contains("std::int32_t f = 6;"), "bare signed -> int32"); - Check(r.text.contains("std::int8_t g = 7;"), "signed char -> int8"); - Check(r.text.contains("static_cast(g)"), "unsigned char -> uint8"); - Check(r.text.contains("char text = 'x';"), "bare char stays (text, not an integer)"); - Check(r.text.contains("long double pi = 3.14L;"), "long double stays (floating type)"); - } - - // brace-style: Allman brace joins; standalone scope block stays. - { - RuleRun r = RunRule("void Foo()\n{\n}\n\nvoid Bar() {\n Baz();\n {\n Qux();\n }\n}\n", "brace-style", LintMode::Apply); - Check(r.text.contains("void Foo() {"), "Allman brace joined onto header"); - Check(r.text.contains("Baz();\n {"), "scope block brace untouched"); - } - - // if-single-line: short body joins; braced body stays. - { - RuleRun r = RunRule("void F(bool b) {\n if (b)\n Run();\n if (b) {\n Run();\n }\n}\n", "if-single-line", LintMode::Apply); - Check(r.text.contains("if (b) Run();"), "single-statement if body joined"); - Check(r.text.contains("if (b) {"), "braced if body untouched"); - } - - // single-declaration splits on the AST, so each declarator carries its own - // resolved type. That is the whole reason it is not a token rule: copying - // the head's type prefix turns `int* a, b;` into `int* a; int* b;` and - // silently changes b from int to int*. - { - RuleRun r = RunRule("#include \n" - "void F() {\n" - " bool a = false, b = true;\n" - " int* ptrA = nullptr, *ptrB = nullptr;\n" - " int* mixed = nullptr, plain = 7;\n" - " std::vector tmplA{}, tmplB{};\n" - " int callA = g(), callB = h();\n" - " int keep = 1, kept = 2; // trailing comment\n" - "}\n", - "single-declaration", LintMode::Apply); - Check(r.text.contains("bool a = false;\n bool b = true;"), "single-declaration: simple split"); - Check(r.text.contains("int* ptrA = nullptr;\n int* ptrB = nullptr;"), "single-declaration: pointers split, star kept on the type"); - // The case a token-based splitter gets wrong. - Check(r.text.contains("int* mixed = nullptr;\n int plain = 7;"), "single-declaration: only the starred declarator is a pointer"); - Check(r.text.contains("std::vector tmplA{};\n std::vector tmplB{};"), "single-declaration: template arguments are not a bail-out"); - Check(r.text.contains("int callA = g();\n int callB = h();"), "single-declaration: call initialisers are not a bail-out"); - // A comment after the ';' is outside the replaced range, so it survives; - // the line-based version refused the whole line instead. - Check(r.text.contains("int keep = 1;\n int kept = 2; // trailing comment"), "single-declaration: trailing comment survives the split"); - RuleRun again = RunRule(r.text, "single-declaration", LintMode::Apply); - Check(again.summary.changedFiles.empty(), "single-declaration: idempotent"); - } - - // wrap-join: short wrapped call joins; operator chain joins; long stays. - { - RuleRun r = RunRule("void F() {\n G(alpha,\n beta);\n bool x = alpha\n && beta;\n}\n", "wrap-join", LintMode::Apply); - Check(r.text.contains("G(alpha, beta);"), "wrapped call arguments joined"); - Check(r.text.contains("bool x = alpha && beta;"), "operator continuation joined"); - } - - // format-concat: literal-adjacent + rewrites; += RHS rewrites; args survive. - { - RuleRun r = RunRule("void F(std::string name, std::string cmd) {\n" - " std::string a = name + \".cpp\";\n" - " std::string b = \"pre-\" + name + \"-post\";\n" - " cmd += \" \" + name;\n" - " std::string keep = name + cmd;\n" - " fs::path p;\n" - " std::string c = p.stem().string() + \".pcm\";\n" - " fs::path d = std::string(cmd) + \".so\";\n" - "}\n", - "format-concat", LintMode::Apply); - Check(r.text.contains("std::string a = std::format(\"{}.cpp\", name);"), "trailing literal converts"); - Check(!r.text.contains("namestd::format"), "replacement splices at the operand boundary"); - Check(r.text.contains("std::string b = std::format(\"pre-{}-post\", name);"), "sandwich chain converts"); - Check(r.text.contains("cmd += std::format(\" {}\", name);"), "+= RHS converts"); - Check(r.text.contains("std::string keep = name + cmd;"), "literal-free + is left alone"); - Check(r.text.contains("std::string c = std::format(\"{}.pcm\", p.stem().string());"), "call-chain left operand consumed whole (the stringstd regression)"); - Check(r.text.contains("fs::path d = std::format(\"{}.so\", std::string(cmd));"), "constructor-call left operand consumed whole"); - } - { - // Ternary and raw-string lines are reported, never rewritten. - RuleRun r = RunRule("std::string F(bool b, std::string n) { return b ? n + \".x\" : n; }\n", "format-concat", LintMode::Report); - Check(HasFinding(r.summary, "not auto-fixable"), "ternary concat reports instead of fixing"); - Check(r.text.contains("n + \".x\""), "ternary concat untouched on disk"); - } - - // paren-spacing: single-space padding removed; wrapped call ends keep. - { - RuleRun r = RunRule("void F() {\n G( x );\n}\n", "paren-spacing", LintMode::Apply); - Check(r.text.contains("G(x);"), "paren padding removed"); - } - - // naming: wrong-case function/type/static/global flagged; camel local passes. - { - RuleRun r = RunRule("namespace {\n" - " std::int32_t bad_global = 0;\n" - " struct lint_thing {};\n" - "}\n" - "void lower_func() {\n" - " static bool lowerStatic = false;\n" - " std::int32_t fineLocal = 1;\n" - "}\n", - "naming", LintMode::Report); - Check(HasFinding(r.summary, "'bad_global' should be PascalCase"), "lowercase global flagged"); - Check(HasFinding(r.summary, "'lint_thing' should be PascalCase"), "lowercase type flagged"); - Check(HasFinding(r.summary, "'lower_func' should be PascalCase"), "lowercase function flagged"); - Check(HasFinding(r.summary, "'lowerStatic' should be PascalCase"), "camel static flagged"); - Check(!HasFinding(r.summary, "fineLocal"), "camelCase local passes"); - } - { - // Statement calls with inline lambda arguments are NOT function - // definitions (the any_of regression); a genuine lowercase one-liner - // method still is. - RuleRun r = RunRule("struct Holder {\n" - " bool clean() const { return true; }\n" - "};\n" - "void T(std::vector& v) {\n" - " bool x = std::any_of(v.begin(), v.end(), [](std::int32_t n) { return n > 0; });\n" - " std::erase_if(v, [](std::int32_t n) { return n < 0; });\n" - "}\n", - "naming", LintMode::Report); - Check(!HasFinding(r.summary, "any_of"), "call with lambda argument is not a function definition"); - Check(!HasFinding(r.summary, "erase_if"), "bare statement call is not a function definition"); - Check(HasFinding(r.summary, "'clean' should be PascalCase"), "lowercase one-liner method still flagged"); - } - - // enum-class + no-iostream-print reports. - { - RuleRun r = RunRule("enum Color { Red };\n", "enum-class", LintMode::Report); - Check(HasFinding(r.summary, "enum class"), "plain enum flagged"); - } - { - RuleRun r = RunRule("void F() { std::cout << 1; }\n", "no-iostream-print", LintMode::Report); - Check(HasFinding(r.summary, "std::println"), "std::cout flagged"); - } - - // The whole ruleset is idempotent: a second Apply changes nothing. - { - std::string_view source = "int Foo()\n{\n std::string s = std::string(\"a\") + \"b\";\n if (true)\n return 1;\n return 0;\n}\n"; - static constexpr std::string_view AllRules = "*"; - RuleRun first = RunRule(source, AllRules, LintMode::Apply); - RuleRun second = RunRule(first.text, AllRules, LintMode::Apply); - Check(second.summary.changedFiles.empty(), "second apply of the full ruleset is a no-op"); - Check(first.text != source, "first apply actually changed the fixture"); - } - - // Suppression directives against house transforms: the driver reverts - // line-preserving edits (fixed-width) and the count-changing rules check - // Suppressed() themselves (wrap-join). - { - RuleRun r = RunRule("void F() {\n" - " // lint-disable-next-line fixed-width-types\n" - " int keep = 1;\n" - " int convert = 2;\n" - "}\n", - "fixed-width-types", LintMode::Apply); - Check(r.text.contains("int keep = 1;"), "next-line directive keeps the suppressed int"); - Check(r.text.contains("std::int32_t convert = 2;"), "unsuppressed line still converts"); - } - { - RuleRun r = RunRule("void F() {\n" - " // lint-disable-next-line wrap-join\n" - " G(alpha,\n" - " beta);\n" - " H(alpha,\n" - " beta);\n" - "}\n", - "wrap-join", LintMode::Apply); - Check(r.text.contains("G(alpha,\n"), "suppressed wrap stays wrapped"); - Check(r.text.contains("H(alpha, beta);"), "unsuppressed wrap still joins"); - } - - // wrap-join converges in ONE run: joining the inner paren wrap balances - // the && line, which only then becomes an operator-joinable continuation. - { - RuleRun r = RunRule("void F() {\n" - " bool ok = alpha\n" - " && beta(gamma,\n" - " delta);\n" - "}\n", - "wrap-join", LintMode::Apply); - Check(r.text.contains("bool ok = alpha && beta(gamma, delta);"), "self-enabling joins reach the fixpoint in one apply"); - RuleRun second = RunRule(r.text, "wrap-join", LintMode::Apply); - Check(second.summary.changedFiles.empty(), "wrap-join is idempotent after the fixpoint"); - } - - // The guards used to be substring probes over the raw line, so a literal - // whose TEXT contained `//` or `R"` looked like a comment or a raw string - // and silently disabled the rule. Both shapes occur in this repo's own - // sources — "MARKER" ends in the characters R", and any string mentioning - // a lint-disable directive contains //. - { - RuleRun r = RunRule("void F() {\n" " auto hit = text.find(\"MARKER\",\n" " start);\n" "}\n", "wrap-join", LintMode::Apply); - Check(r.text.contains("text.find(\"MARKER\", start);"), "R\" inside a literal no longer blocks wrap-join"); - } - { - RuleRun r = RunRule("void F() {\n" " Check(read() == \"// lint-disable-next-line trim\\n\",\n" " \"message\");\n" "}\n", "wrap-join", LintMode::Apply); - Check(r.text.contains("\\n\", \"message\");"), "// inside a literal no longer blocks wrap-join"); - } - - // A real trailing comment still blocks the join: text pulled up past a - // `//` would be swallowed by it. - { - RuleRun r = RunRule("void F() {\n" " auto v = g(alpha, // why\n" " beta);\n" "}\n", "wrap-join", LintMode::Apply); - Check(r.text.contains("g(alpha, // why\n"), "a real comment still blocks wrap-join"); - } - - // A genuinely multi-line literal is never reflowed — joining its lines - // would change the string's contents. - { - std::string_view source = "void F() {\n" - " auto text = R\"sql(SELECT a,\n" - " b FROM t)sql\";\n" - "}\n"; - RuleRun r = RunRule(source, "wrap-join", LintMode::Apply); - Check(r.text == source, "wrap-join leaves a multi-line raw string alone"); - RuleRun paren = RunRule(source, "paren-spacing", LintMode::Apply); - Check(paren.text == source, "paren-spacing leaves a multi-line raw string alone"); - } - - // Type keywords inside a literal are text, not declarations. - { - std::string_view source = "void F() {\n" - " auto sql = R\"q(int x; unsigned long y;)q\";\n" - "}\n"; - RuleRun r = RunRule(source, "fixed-width-types", LintMode::Apply); - Check(r.text == source, "fixed-width-types leaves type names inside a raw string alone"); - } - - // no-char-pointer reads the AST, so it distinguishes OUR char* from one - // whose spelling belongs to somebody else's header. This is what replaced - // the substring denylist (argv, getenv, c_str, reinterpret_cast, …): each - // entry there disabled the rule for a whole line, and the list could only - // grow as new libraries arrived. - { - RuleRun r = RunRule("#include \n" - "#include \n" - "extern \"C\" const char* CApiEntry(const char* path);\n" - "char* OurBadApi(char* input) { return input; }\n" - "void F() {\n" - " char* fromLibc = std::getenv(\"HOME\");\n" - " std::string mine = \"ok\";\n" - " const char* toLibc = mine.c_str();\n" - " auto raw = reinterpret_cast(&mine);\n" - "}\n", - "no-char-pointer", LintMode::Report); - // Ours, so reported: the declaration and its parameter. - Check(HasFinding(r.summary, "'OurBadApi'"), "no-char-pointer: our own char* return is reported"); - Check(HasFinding(r.summary, "'input'"), "no-char-pointer: our own char* parameter is reported"); - // Foreign, so exempt — each for a reason, not by name. - Check(!HasFinding(r.summary, "'CApiEntry'"), "no-char-pointer: extern \"C\" declaration is exempt"); - Check(!HasFinding(r.summary, "'path'"), "no-char-pointer: extern \"C\" parameter is exempt"); - Check(!HasFinding(r.summary, "'fromLibc'"), "no-char-pointer: a value from libc is exempt"); - Check(!HasFinding(r.summary, "'toLibc'"), "no-char-pointer: a value from c_str() is exempt"); - // A local whose deduced type is char* is still our declaration, so it - // is reported. The old denylist exempted every line mentioning - // reinterpret_cast; a deliberate low-level cast now takes an explicit - // lint-disable comment, which is at least visible at the site. - Check(HasFinding(r.summary, "'raw'"), "no-char-pointer: a deduced char* local is still ours"); - } - - // Cases the line-based heuristic structurally could not see. It only looked - // at declarations starting at cumulative paren depth 0 and inferred scope - // from a running {/} count, so a wrapped signature, a specifier split over - // two lines, or a local shadowing a member all escaped it. - { - RuleRun r = RunRule("#include \n" - "namespace Outer {\n" - " int wrapped_function(\n" - " int first,\n" - " int second) { return first + second; }\n" - " static\n" - " int splitStatic = 1;\n" - " struct Holder {\n" - " int Member = 0;\n" - " void Method() {\n" - " int Local = 1;\n" - " (void)Local;\n" - " }\n" - " };\n" - "}\n" - "extern \"C\" int c_api_entry(const char* name);\n", - "naming", LintMode::Report); - // A signature wrapped over lines is still a function declaration. - Check(HasFinding(r.summary, "'wrapped_function' should be PascalCase"), "naming: wrapped signature is seen"); - // `static` on its own line still applies to the declaration below it. - Check(HasFinding(r.summary, "'splitStatic' should be PascalCase"), "naming: split specifier is seen"); - // Members are camelCase; the enclosing kind decides, not a brace count. - Check(HasFinding(r.summary, "'Member' should be camelCase"), "naming: member is checked as a member"); - // A local inside a method is a local, not a member and not a global. - Check(HasFinding(r.summary, "'Local' should be camelCase"), "naming: local inside a method is a local"); - // Named by libc, not by us. - Check(!HasFinding(r.summary, "c_api_entry"), "naming: extern \"C\" declaration is exempt"); - Check(!HasFinding(r.summary, "'Method'"), "naming: PascalCase method passes"); - Check(!HasFinding(r.summary, "'Holder'"), "naming: PascalCase type passes"); - } - - // fixed-width-types must not rewrite an integer whose width somebody else's - // header chose — the rewrite would leave code that no longer matches the API - // it calls. Derived from the AST, so the exemption is per-declaration rather - // than the old per-line textual one, which both missed cases and disabled - // the rule for anything else sharing a line with `argc`/`extern "`. - { - RuleRun r = RunRule("#include \n" - "extern \"C\" unsigned long CApiCall(unsigned int flags);\n" - "long OurOwnApi(int value) { return value; }\n" - "void F() {\n" - " unsigned long fromLibc = std::strtoul(\"1\", nullptr, 10);\n" - " unsigned ours = 1;\n" - " (void)fromLibc; (void)ours;\n" - "}\n" - "int main(int argc, char** argv) {\n" - " for (int i = 0; i < argc; ++i) { (void)argv[i]; }\n" - " return 0;\n" - "}\n", - "fixed-width-types", LintMode::Apply); - // Somebody else's widths, kept. - Check(r.text.contains("extern \"C\" unsigned long CApiCall(unsigned int flags);"), "fixed-width: extern \"C\" signature keeps its widths"); - Check(r.text.contains("unsigned long fromLibc = std::strtoul"), "fixed-width: a value from a C library keeps its width"); - Check(r.text.contains("int main(int argc, char** argv)"), "fixed-width: main's signature is untouched"); - // Ours, converted. - Check(r.text.contains("std::int64_t OurOwnApi(std::int32_t value)"), "fixed-width: our own signature converts"); - Check(r.text.contains("std::uint32_t ours = 1;"), "fixed-width: our own local converts"); - // The loop counter inside main's BODY is ordinary code. The old rule - // skipped it because `argc` appeared on the same line; only the - // signature is exempt now, not everything near it. - Check(r.text.contains("for (std::int32_t i = 0;"), "fixed-width: main's body is not exempt, only its signature"); - } - - // enum-class asks for the next TOKEN after `enum`, so a declaration split - // over lines reads the same as one that is not. The regex it replaced - // required the name to follow `enum` on the same line. - { - RuleRun r = RunRule("enum Plain { A };\n" - "enum\n" - " Split { B };\n" - "enum class Scoped { C };\n" - "enum\n" - " class SplitScoped { D };\n" - "enum struct AlsoFine { E };\n", - "enum-class", LintMode::Report); - Check(r.summary.findings.size() == 2, std::format("enum-class: exactly the two plain enums ({} found)", r.summary.findings.size())); - const bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; }); - const bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; }); - Check(onFirst, "enum-class: single-line plain enum reported"); - Check(onSplit, "enum-class: line-split plain enum reported at the keyword"); - } - - // const-local's mutation analysis is exact for scalars: assignment, ++/--, - // address-of and binding to a non-const reference are the only ways to - // write one, and all four are tracked. Both directions matter — a missed - // write means advising const on something that cannot be const. - { - RuleRun r = RunRule("void Mutate(int& out);\n" - "void ReadOnly(const int& in);\n" - "void ByValue(int v);\n" - "int Compute();\n" - "void F() {\n" - " int neverWritten = 1;\n" - " int assigned = 1; assigned = 2;\n" - " int incremented = 1; ++incremented;\n" - " int compound = 1; compound += 2;\n" - " int addressed = 1; int* taken = &addressed;\n" - " int toMutatingRef = 1; Mutate(toMutatingRef);\n" - " int toConstRef = 1; ReadOnly(toConstRef);\n" - " int toByValue = 1; ByValue(toByValue);\n" - " int boundToRef = 1; int& alias = boundToRef;\n" - " for (int loop = 0; loop < 1; ++loop) { (void)loop; }\n" - " (void)taken; (void)alias;\n" - "}\n", - "const-local", LintMode::Report); - Check(HasFinding(r.summary, "'neverWritten'"), "const-local: an unwritten local is reported"); - Check(HasFinding(r.summary, "'toConstRef'"), "const-local: passing to a const& is not a write"); - Check(HasFinding(r.summary, "'toByValue'"), "const-local: passing by value is not a write"); - Check(!HasFinding(r.summary, "'assigned'"), "const-local: assignment is a write"); - Check(!HasFinding(r.summary, "'incremented'"), "const-local: ++ is a write"); - Check(!HasFinding(r.summary, "'compound'"), "const-local: += is a write"); - Check(!HasFinding(r.summary, "'addressed'"), "const-local: taking an address counts as a write"); - Check(!HasFinding(r.summary, "'toMutatingRef'"), "const-local: binding to a non-const& parameter is a write"); - Check(!HasFinding(r.summary, "'boundToRef'"), "const-local: binding to a non-const& local is a write"); - Check(!HasFinding(r.summary, "'loop'"), "const-local: a mutated loop counter is not reported"); - // Pointers and range-for bindings are excluded: `T* const p` and - // `for (T* const x : …)` are not spellings anybody writes. - Check(!HasFinding(r.summary, "'taken'"), "const-local: pointer locals are out of scope"); - } - { - RuleRun r = RunRule("void F() {\n" " for (int each : Range()) { (void)each; }\n" "}\n", "const-local", LintMode::Report); - Check(!HasFinding(r.summary, "'each'"), "const-local: a range-for binding is not reported"); - } - - // constexpr-constant asks clang's constant evaluator, not the tokens of the - // initialiser. sizeof and a fold over other constants are constant - // expressions that no token scan can recognise; a call result is not. - { - RuleRun r = RunRule("int Compute();\n" - "constexpr int Base = 4;\n" - "struct Big { int a, b; };\n" - "void F() {\n" - " const int literal = 4;\n" - " const int folded = 1 << 4;\n" - " const int fromSizeof = sizeof(Big);\n" - " const int fromOtherConstant = Base + 1;\n" - " const int fromCall = Compute();\n" - " constexpr int already = 8;\n" - " (void)literal; (void)folded; (void)fromSizeof;\n" - " (void)fromOtherConstant; (void)fromCall; (void)already;\n" - "}\n", - "constexpr-constant", LintMode::Report); - Check(HasFinding(r.summary, "'literal'"), "constexpr: a literal constant is reported"); - Check(HasFinding(r.summary, "'folded'"), "constexpr: an operator fold over literals is reported"); - // Neither of these is reachable from a token scan of the initialiser. - Check(HasFinding(r.summary, "'fromSizeof'"), "constexpr: sizeof folds"); - Check(HasFinding(r.summary, "'fromOtherConstant'"), "constexpr: a fold over another constant folds"); - Check(!HasFinding(r.summary, "'fromCall'"), "constexpr: a call result is not a constant expression"); - Check(!HasFinding(r.summary, "'already'"), "constexpr: an existing constexpr is not re-reported"); - } - - if (Failures > 0) { - std::println(std::cerr, "{} assertions failed", Failures); - return 1; - } - return 0; -}