Crafter.Build/lint-rules.h

984 lines
50 KiB
C
Raw Normal View History

2026-07-23 01:24:42 +02:00
// 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;
}
feat(lint): no-char-pointer reads the AST, retiring the interop denylist The rule was `\bchar\s*\*` over the text minus a substring denylist — argv, getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry was a patch for one interop site, the list could only grow as libraries arrived, and each entry disabled the rule for the whole LINE it appeared on. It now walks declarations and asks the question the denylist was approximating: whose header dictates this spelling? A declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project root, is somebody else's API and keeps its spelling. getenv, c_str and friends are exempt because of where they are declared, not because they are named here, so a new external library needs no new entry. Two bugs found while testing this, both of which had made the rule silently pass over the entire repository: clang_getCursorLanguage cannot be used to detect extern "C". Its default answer is CXLanguage_C for a plain function, variable or parameter even in a C++ translation unit, so isExternC was true almost everywhere and exempted everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk, reading the extent text to tell extern "C" from extern "C++". Attributing any foreign reference in a subtree to the enclosing declaration was too broad: a function that merely touched libc++ somewhere in its body would exempt its own signature. Narrowed to initialiser contexts — a variable, field or parameter — which is where a binding to a foreign API actually occurs. Also: functions now carry their RESULT type rather than the whole function type, since the parameters arrive as their own declarations and would otherwise be reported twice. main's parameters are exempt structurally, its signature being fixed by the language rather than chosen here. Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv verbatim. That is two visible, reasoned suppressions in place of a denylist that silently disabled the rule for every line mentioning one of eight tokens. ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a source that includes an external dependency's headers can be parsed without running a build to discover where they are. BuildExternal derives its own working directory through the same function, so the two cannot drift. Crafter.Build-Shader.cpp needed this to parse at all. A file with no compile command — project.cpp, which LoadProject builds with its own flags — is not a translation unit of the build graph, so AST rules skip it the way a rule self-filters by extension. That is distinct from a file that should have parsed and did not, which stays an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
// 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";
}
2026-07-23 01:24:42 +02:00
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)");
}
});
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
// 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) {
2026-07-23 01:24:42 +02:00
if (!IsCppFile(ctx)) return;
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
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));
2026-07-23 01:24:42 +02:00
}
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
break;
case Kind::TypeAlias:
if (!IsPascalCase(decl.name)) {
ctx.Report(decl.line, std::format("type alias '{}' should be PascalCase", decl.name));
2026-07-23 01:24:42 +02:00
}
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
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));
2026-07-23 01:24:42 +02:00
}
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
} else if (!IsCamelCase(decl.name)) {
ctx.Report(decl.line, std::format("variable '{}' should be camelCase", decl.name));
2026-07-23 01:24:42 +02:00
}
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
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));
2026-07-23 01:24:42 +02:00
}
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
break;
default:
break;
2026-07-23 01:24:42 +02:00
}
}
});
// 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.
2026-07-23 01:24:42 +02:00
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");
2026-07-23 01:24:42 +02:00
}
});
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");
}
}
});
feat(lint): no-char-pointer reads the AST, retiring the interop denylist The rule was `\bchar\s*\*` over the text minus a substring denylist — argv, getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry was a patch for one interop site, the list could only grow as libraries arrived, and each entry disabled the rule for the whole LINE it appeared on. It now walks declarations and asks the question the denylist was approximating: whose header dictates this spelling? A declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project root, is somebody else's API and keeps its spelling. getenv, c_str and friends are exempt because of where they are declared, not because they are named here, so a new external library needs no new entry. Two bugs found while testing this, both of which had made the rule silently pass over the entire repository: clang_getCursorLanguage cannot be used to detect extern "C". Its default answer is CXLanguage_C for a plain function, variable or parameter even in a C++ translation unit, so isExternC was true almost everywhere and exempted everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk, reading the extent text to tell extern "C" from extern "C++". Attributing any foreign reference in a subtree to the enclosing declaration was too broad: a function that merely touched libc++ somewhere in its body would exempt its own signature. Narrowed to initialiser contexts — a variable, field or parameter — which is where a binding to a foreign API actually occurs. Also: functions now carry their RESULT type rather than the whole function type, since the parameters arrive as their own declarations and would otherwise be reported twice. main's parameters are exempt structurally, its signature being fixed by the language rather than chosen here. Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv verbatim. That is two visible, reasoned suppressions in place of a denylist that silently disabled the rule for every line mentioning one of eight tokens. ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a source that includes an external dependency's headers can be parsed without running a build to discover where they are. BuildExternal derives its own working directory through the same function, so the two cannot drift. Crafter.Build-Shader.cpp needed this to parse at all. A file with no compile command — project.cpp, which LoadProject builds with its own flags — is not a translation unit of the build graph, so AST rules skip it the way a rule self-filters by extension. That is distinct from a file that should have parsed and did not, which stays an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
// 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) {
2026-07-23 01:24:42 +02:00
if (!IsCppFile(ctx)) return;
feat(lint): no-char-pointer reads the AST, retiring the interop denylist The rule was `\bchar\s*\*` over the text minus a substring denylist — argv, getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry was a patch for one interop site, the list could only grow as libraries arrived, and each entry disabled the rule for the whole LINE it appeared on. It now walks declarations and asks the question the denylist was approximating: whose header dictates this spelling? A declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project root, is somebody else's API and keeps its spelling. getenv, c_str and friends are exempt because of where they are declared, not because they are named here, so a new external library needs no new entry. Two bugs found while testing this, both of which had made the rule silently pass over the entire repository: clang_getCursorLanguage cannot be used to detect extern "C". Its default answer is CXLanguage_C for a plain function, variable or parameter even in a C++ translation unit, so isExternC was true almost everywhere and exempted everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk, reading the extent text to tell extern "C" from extern "C++". Attributing any foreign reference in a subtree to the enclosing declaration was too broad: a function that merely touched libc++ somewhere in its body would exempt its own signature. Narrowed to initialiser contexts — a variable, field or parameter — which is where a binding to a foreign API actually occurs. Also: functions now carry their RESULT type rather than the whole function type, since the parameters arrive as their own declarations and would otherwise be reported twice. main's parameters are exempt structurally, its signature being fixed by the language rather than chosen here. Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv verbatim. That is two visible, reasoned suppressions in place of a denylist that silently disabled the rule for every line mentioning one of eight tokens. ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a source that includes an external dependency's headers can be parsed without running a build to discover where they are. BuildExternal derives its own working directory through the same function, so the two cannot drift. Crafter.Build-Shader.cpp needed this to parse at all. A file with no compile command — project.cpp, which LoadProject builds with its own flags — is not a translation unit of the build graph, so AST rules skip it the way a rule self-filters by extension. That is distinct from a file that should have parsed and did not, which stays an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
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;
2026-07-23 01:24:42 +02:00
}
feat(lint): no-char-pointer reads the AST, retiring the interop denylist The rule was `\bchar\s*\*` over the text minus a substring denylist — argv, getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry was a patch for one interop site, the list could only grow as libraries arrived, and each entry disabled the rule for the whole LINE it appeared on. It now walks declarations and asks the question the denylist was approximating: whose header dictates this spelling? A declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project root, is somebody else's API and keeps its spelling. getenv, c_str and friends are exempt because of where they are declared, not because they are named here, so a new external library needs no new entry. Two bugs found while testing this, both of which had made the rule silently pass over the entire repository: clang_getCursorLanguage cannot be used to detect extern "C". Its default answer is CXLanguage_C for a plain function, variable or parameter even in a C++ translation unit, so isExternC was true almost everywhere and exempted everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk, reading the extent text to tell extern "C" from extern "C++". Attributing any foreign reference in a subtree to the enclosing declaration was too broad: a function that merely touched libc++ somewhere in its body would exempt its own signature. Narrowed to initialiser contexts — a variable, field or parameter — which is where a binding to a foreign API actually occurs. Also: functions now carry their RESULT type rather than the whole function type, since the parameters arrive as their own declarations and would otherwise be reported twice. main's parameters are exempt structurally, its signature being fixed by the language rather than chosen here. Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv verbatim. That is two visible, reasoned suppressions in place of a denylist that silently disabled the rule for every line mentioning one of eight tokens. ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a source that includes an external dependency's headers can be parsed without running a build to discover where they are. BuildExternal derives its own working directory through the same function, so the two cannot drift. Crafter.Build-Shader.cpp needed this to parse at all. A file with no compile command — project.cpp, which LoadProject builds with its own flags — is not a translation unit of the build graph, so AST rules skip it the way a rule self-filters by extension. That is distinct from a file that should have parsed and did not, which stays an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
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));
2026-07-23 01:24:42 +02:00
}
});
// 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.
feat(lint): const-local and constexpr-constant rules 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>
2026-07-31 00:50:48 +02:00
// 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));
}
});
2026-07-23 01:24:42 +02:00
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;
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
// 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);
2026-07-23 01:24:42 +02:00
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;
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
if (spansLines) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (multi-line literal, fix manually)");
2026-07-23 01:24:42 +02:00
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.
feat(lint): fixed-width-types keeps widths that a foreign API chose The rewrite itself stays token-shaped — it edits type SPELLINGS, which an AST discards — but what it must not touch now comes from the AST. The old exemption was per LINE: `int main`, `argc`, `argv`, `extern "`. Being a transform, a missed exemption here does not over-report, it emits code that no longer matches the API being called, so this is the rule where guessing from substrings mattered most. And being per-line, it also disabled the rule for anything sharing a line with one of those words. Now a declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project, contributes a protected byte range and keeps its spelling. That answers the case directly: a function declared in somebody else's header taking `unsigned int` keeps `unsigned int`, and a local initialised from strtoul keeps `unsigned long`, because of where those are declared rather than because of what the line says. main is protected from the start of its declaration to the opening brace of its body, not for its whole extent. Its signature is fixed by the language; its body is ordinary code. Three findings on this repository came out of that distinction, all correct: for (int i = 1; i < argc; ++i) -> for (std::int32_t i = 1; ...) skipped before only because `argc` appeared on the line, plus Crafter::Run's own `int argc` and return type, which are ours rather than the language's. All three AST rules now share one interop test instead of carrying a denylist each, and it is the same test: whose header dictates this spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:46 +02:00
cfg.AddAstLintRule("fixed-width-types", [](LintContext& ctx) {
2026-07-23 01:24:42 +02:00
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;
feat(lint): fixed-width-types keeps widths that a foreign API chose The rewrite itself stays token-shaped — it edits type SPELLINGS, which an AST discards — but what it must not touch now comes from the AST. The old exemption was per LINE: `int main`, `argc`, `argv`, `extern "`. Being a transform, a missed exemption here does not over-report, it emits code that no longer matches the API being called, so this is the rule where guessing from substrings mattered most. And being per-line, it also disabled the rule for anything sharing a line with one of those words. Now a declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project, contributes a protected byte range and keeps its spelling. That answers the case directly: a function declared in somebody else's header taking `unsigned int` keeps `unsigned int`, and a local initialised from strtoul keeps `unsigned long`, because of where those are declared rather than because of what the line says. main is protected from the start of its declaration to the opening brace of its body, not for its whole extent. Its signature is fixed by the language; its body is ordinary code. Three findings on this repository came out of that distinction, all correct: for (int i = 1; i < argc; ++i) -> for (std::int32_t i = 1; ...) skipped before only because `argc` appeared on the line, plus Crafter::Run's own `int argc` and return type, which are ours rather than the language's. All three AST rules now share one interop test instead of carrying a denylist each, and it is the same test: whose header dictates this spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:46 +02:00
// 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;
});
};
2026-07-23 01:24:42 +02:00
// 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;
feat(lint): fixed-width-types keeps widths that a foreign API chose The rewrite itself stays token-shaped — it edits type SPELLINGS, which an AST discards — but what it must not touch now comes from the AST. The old exemption was per LINE: `int main`, `argc`, `argv`, `extern "`. Being a transform, a missed exemption here does not over-report, it emits code that no longer matches the API being called, so this is the rule where guessing from substrings mattered most. And being per-line, it also disabled the rule for anything sharing a line with one of those words. Now a declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project, contributes a protected byte range and keeps its spelling. That answers the case directly: a function declared in somebody else's header taking `unsigned int` keeps `unsigned int`, and a local initialised from strtoul keeps `unsigned long`, because of where those are declared rather than because of what the line says. main is protected from the start of its declaration to the opening brace of its body, not for its whole extent. Its signature is fixed by the language; its body is ordinary code. Three findings on this repository came out of that distinction, all correct: for (int i = 1; i < argc; ++i) -> for (std::int32_t i = 1; ...) skipped before only because `argc` appeared on the line, plus Crafter::Run's own `int argc` and return type, which are ours rather than the language's. All three AST rules now share one interop test instead of carrying a denylist each, and it is the same test: whose header dictates this spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:46 +02:00
while (pos < line.size()) {
2026-07-23 01:24:42 +02:00
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;
feat(lint): fixed-width-types keeps widths that a foreign API chose The rewrite itself stays token-shaped — it edits type SPELLINGS, which an AST discards — but what it must not touch now comes from the AST. The old exemption was per LINE: `int main`, `argc`, `argv`, `extern "`. Being a transform, a missed exemption here does not over-report, it emits code that no longer matches the API being called, so this is the rule where guessing from substrings mattered most. And being per-line, it also disabled the rule for anything sharing a line with one of those words. Now a declaration with C language linkage, or one whose initialiser binds to an entity declared outside the project, contributes a protected byte range and keeps its spelling. That answers the case directly: a function declared in somebody else's header taking `unsigned int` keeps `unsigned int`, and a local initialised from strtoul keeps `unsigned long`, because of where those are declared rather than because of what the line says. main is protected from the start of its declaration to the opening brace of its body, not for its whole extent. Its signature is fixed by the language; its body is ordinary code. Three findings on this repository came out of that distinction, all correct: for (int i = 1; i < argc; ++i) -> for (std::int32_t i = 1; ...) skipped before only because `argc` appeared on the line, plus Crafter::Run's own `int argc` and return type, which are ours rather than the language's. All three AST rules now share one interop test instead of carrying a denylist each, and it is the same test: whose header dictates this spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:46 +02:00
if (isProtected(lineStart + runBegin)) continue;
2026-07-23 01:24:42 +02:00
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) {
2026-07-23 01:24:42 +02:00
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;
2026-07-23 01:24:42 +02:00
}
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;
2026-07-23 01:24:42 +02:00
}
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));
2026-07-23 01:24:42 +02:00
});
// 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";
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
bool hasComment = ctx.LineHasComment(i + 1) || (i > 0 && ctx.LineHasComment(i));
2026-07-23 01:24:42 +02:00
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(';')
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
&& !ctx.LineHasComment(i + 1) && !ctx.LineHasComment(i + 2)
2026-07-23 01:24:42 +02:00
&& !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('#')
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
&& !trimmed.ends_with('{') && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1);
2026-07-23 01:24:42 +02:00
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)
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
|| ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
2026-07-23 01:24:42 +02:00
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('#')
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
&& !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1)) {
2026-07-23 01:24:42 +02:00
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]))) {
fix(lint): make the reflow guards token-accurate instead of textual The transforms guard themselves against comments and raw strings before joining or rewriting a line, because pulling text up past a `//` buries it and reflowing a multi-line literal changes the string. Those guards were substring probes over the raw line, so they answered the wrong question: Line(n).contains("//") fires on // inside a string literal Line(n).contains("R\"") fires on the characters R" inside a literal, and MISSES a raw string opened on an earlier line Both misfire on this repo's own sources. "MARKER" ends in R", and any string mentioning a lint-disable directive contains //. Two wrapped call sites in tests/Lint were being left unjoined for exactly these reasons; they join now, and the results are in this commit. Replaced by LineHasComment() (added with the token layer) and a new LineHasMultiLineToken(), which reports whether any token actually covering that line spans a line boundary — a raw string or a block comment. Backed by a per-line bitmap derived from the token cache and invalidated with it. The guards themselves stay: joining across a real comment or a real multi-line literal is still unsafe, and there are tests for both. What changes is that they now fire on comments and literals rather than on the characters that spell them. format-concat gets narrower as a result. It used to refuse any line containing R" and ask for a manual fix; now only a literal that genuinely spans lines does that, because a single-line raw string reduces to R"…" in the stripped view, fails the plain-literal test, and travels through as an argument with its spelling intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
if (ParenDelta(stripped[j]) != 0 || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
2026-07-23 01:24:42 +02:00
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