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>
This commit is contained in:
Jorijn van der Graaf 2026-07-27 03:08:06 +02:00
commit 78fbf8f80c
5 changed files with 87 additions and 16 deletions

View file

@ -408,9 +408,12 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
lineStart += line.size() + 1;
std::string_view trimmed = Trim(line);
if (trimmed.starts_with('#')) continue;
// Raw strings defeat the comment stripper's quote tracking; any
// literal-+ pattern on such a line is a manual fix.
bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\"");
// 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()) {
@ -427,8 +430,8 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"')
|| (rightBegin < line.size() && line[rightBegin] == '"');
if (!literalAdjacent) continue;
if (hasRaw) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)");
if (spansLines) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (multi-line literal, fix manually)");
break;
}
@ -607,7 +610,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
std::string lineStr(stripped[i]);
std::smatch m;
std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{};
if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1)
if (!ctx.LineHasComment(i + 1) && !ctx.Suppressed("single-declaration", i + 1)
&& std::regex_match(lineStr, m, simpleMulti)) {
std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str();
std::size_t start = 0;
@ -644,7 +647,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
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.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//"));
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();
@ -680,7 +683,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
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.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//")
&& !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();
@ -724,7 +727,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
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.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"");
&& !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;
@ -736,7 +739,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
// lambda body starts — leave those wrapped.
bool closes = delta + ParenDelta(stripped[j]) <= 0;
if (next.empty() || (next.ends_with('{') && !closes)
|| ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
|| ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
ok = false;
break;
}
@ -757,7 +760,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
continue;
}
} else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) {
&& !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1)) {
auto isOpStart = [](std::string_view s) {
return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| ");
};
@ -771,7 +774,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) {
std::size_t j = i + 1;
bool ok = true;
while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) {
if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
if (ParenDelta(stripped[j]) != 0 || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
ok = false;
break;
}