refactor(lint): single-declaration splits on the AST, per-declarator type

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>
This commit is contained in:
Jorijn van der Graaf 2026-07-31 00:35:52 +02:00
commit 5ca2b3e1df
4 changed files with 112 additions and 35 deletions

View file

@ -112,10 +112,32 @@ int main() {
Check(r.text.contains("if (b) {"), "braced if body untouched");
}
// single-declaration: simple multi-decl splits; pointer decl only reports.
// single-declaration splits on the AST, so each declarator carries its own
// resolved type. That is the whole reason it is not a token rule: copying
// the head's type prefix turns `int* a, b;` into `int* a; int* b;` and
// silently changes b from int to int*.
{
RuleRun r = RunRule("void F() {\n bool a = false, b = true;\n}\n", "single-declaration", LintMode::Apply);
Check(r.text.contains("bool a = false;\n bool b = true;"), "multi-declaration split");
RuleRun r = RunRule("#include <vector>\n"
"void F() {\n"
" bool a = false, b = true;\n"
" int* ptrA = nullptr, *ptrB = nullptr;\n"
" int* mixed = nullptr, plain = 7;\n"
" std::vector<int> tmplA{}, tmplB{};\n"
" int callA = g(), callB = h();\n"
" int keep = 1, kept = 2; // trailing comment\n"
"}\n",
"single-declaration", LintMode::Apply);
Check(r.text.contains("bool a = false;\n bool b = true;"), "single-declaration: simple split");
Check(r.text.contains("int* ptrA = nullptr;\n int* ptrB = nullptr;"), "single-declaration: pointers split, star kept on the type");
// The case a token-based splitter gets wrong.
Check(r.text.contains("int* mixed = nullptr;\n int plain = 7;"), "single-declaration: only the starred declarator is a pointer");
Check(r.text.contains("std::vector<int> tmplA{};\n std::vector<int> tmplB{};"), "single-declaration: template arguments are not a bail-out");
Check(r.text.contains("int callA = g();\n int callB = h();"), "single-declaration: call initialisers are not a bail-out");
// A comment after the ';' is outside the replaced range, so it survives;
// the line-based version refused the whole line instead.
Check(r.text.contains("int keep = 1;\n int kept = 2; // trailing comment"), "single-declaration: trailing comment survives the split");
RuleRun again = RunRule(r.text, "single-declaration", LintMode::Apply);
Check(again.summary.changedFiles.empty(), "single-declaration: idempotent");
}
// wrap-join: short wrapped call joins; operator chain joins; long stays.