Tokens are not enough for this one, which is worth stating because it is the
opposite of the enum-class case. Splitting a multi-declarator statement by
copying the shared type prefix is wrong in C++:
int* a, b; -> int* a; int* b; // b was int, not int*
Only per-declarator types get it right, and clang has already resolved them —
`int *` for a, plain `int` for b. A token-based splitter cannot know.
The regex it replaces bailed on `*`, `&`, `<>`, parens and quotes, so pointers,
templates and call initialisers were all left alone. All three split now, and
the fixture proves the mixed pointer case above comes out correctly.
Groups are found structurally rather than by matching a line shape: the first
declarator's extent starts at the shared type, so begin < nameOffset, while a
continuation declarator's starts at its own name, so begin == nameOffset. That
signal comes from the AST itself. nameOffset is now on LintDecl, which is also
what lets the replacement reuse each declarator's original text verbatim
instead of reconstructing it.
Replacing a byte range rather than rewriting whole lines means a comment after
the ';' is outside the edit and survives — the line-based version refused to
touch any line carrying a comment. A comment INSIDE the statement still bails,
since the rewrite would swallow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
910 lines
46 KiB
C++
910 lines
46 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.
|
|
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
|