Two rules the AST makes possible, plus the mutation analysis behind them.
const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction 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. All four are now tracked in the walk:
- assignment and compound assignment visit their LEFT operand in a write
context, the right one normally;
- ++/-- and & write their operand;
- a call argument is checked against the callee's parameter type, so passing
to `const int&` or by value is a read while `int&` is a write;
- initialising a non-const reference writes what it binds to.
For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.
constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.
On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.
Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
984 lines
50 KiB
C++
984 lines
50 KiB
C++
// 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<std::string_view> Lines(std::string_view text) {
|
|
std::vector<std::string_view> 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<const Crafter::LintDecl> 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<const Crafter::LintToken> 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<std::string_view> 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<char*> 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<const Crafter::LintDecl> 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<const Crafter::LintDecl> 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 puts it in the type system rather than leaving it to the optimiser.
|
|
// Only fires when every token of the initialiser is a literal or an
|
|
// operator, so `const int A = 1 << 4;` qualifies and
|
|
// `const int B = Compute();` does not.
|
|
cfg.AddAstLintRule("constexpr-constant", [](LintContext& ctx) {
|
|
if (!IsCppFile(ctx)) return;
|
|
std::span<const Crafter::LintToken> tokens = ctx.Tokens();
|
|
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;
|
|
|
|
// Walk the declaration's own tokens, starting after the '='.
|
|
bool sawAssign = false;
|
|
bool allConstant = true;
|
|
bool sawLiteral = false;
|
|
for (const Crafter::LintToken& token : tokens) {
|
|
if (token.offset < decl.nameOffset) continue;
|
|
if (token.offset >= decl.end) break;
|
|
std::string_view text = ctx.TokenText(token);
|
|
if (!sawAssign) {
|
|
if (text == "=") sawAssign = true;
|
|
continue;
|
|
}
|
|
if (token.kind == Crafter::LintTokenKind::Literal) { sawLiteral = true; continue; }
|
|
if (token.kind == Crafter::LintTokenKind::Punctuation) continue;
|
|
allConstant = false; // an identifier or keyword: not a literal fold
|
|
break;
|
|
}
|
|
if (!sawAssign || !sawLiteral || !allConstant) continue;
|
|
ctx.Report(decl.line, std::format("'{}' is a literal constant — declare it constexpr", decl.name));
|
|
}
|
|
});
|
|
|
|
cfg.AddLintRule("format-concat", [](LintContext& ctx) {
|
|
if (!IsCppFile(ctx)) return;
|
|
const std::string& code = ctx.CommentStripped();
|
|
std::vector<std::string_view> 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<Edit> 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<std::string_view> 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<Rep> 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<std::pair<std::size_t, std::size_t>> 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<std::size_t, std::size_t>& 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<std::string_view> 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<const Crafter::LintDecl> decls = ctx.Decls();
|
|
std::span<const Crafter::LintToken> tokens = ctx.Tokens();
|
|
|
|
struct Edit { std::size_t begin; std::size_t end; std::string text; };
|
|
std::vector<Edit> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
std::vector<std::string> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
|
|
std::vector<std::string> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
std::vector<std::string> 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<std::pair<std::size_t, std::size_t>> 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
|