RFC: string_continuation_style
Preamble
- Status: Proposed
- Repository:
leynos/whitaker
- Created: 2026-07-26
- Proposed lint:
string_continuation_style
- Default level:
Warn
- Suite status: Stable, enabled in the standard suite
Summary
Add an opinionated, context-sensitive Dylint rule that treats a cooked string literal's escaped newline as one of two things:
- a source-line join, for which
concat!() is the required spelling when replacing the literal with a macro expression is proven safe; or
- a required continuation, retained when
concat!() would change the grammar, type, format-argument semantics, or layout intent.
The rule is deliberately asymmetric. It emits a diagnostic only for proven concat!() candidates, but its pure classifier also records why other continuations are required. Tests pin both sides so later broadening cannot quietly turn safe exceptions into false positives.
The central invariant is:
Replace an interior continuation escape with concat!() only when Whitaker can prove that the replacement preserves compilation, type, evaluated string contents, format-argument binding, and macro grammar.
Immediate finding on the motivating example
The CodeRabbit suggestion, as written, does not compile.
This source uses implicit named capture:
let header = format!(
"HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n\
Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n",
body.len(),
);
A direct string literal lets format! capture status_line from the surrounding scope. A format string produced by concat!() does not. Rust deliberately forbids implicit capture when the format string comes from macro expansion.
Accordingly, the exact example is a required continuation and must pass this lint unchanged.
A concat!() rewrite becomes valid only after making the captured argument explicit:
let header = format!(
concat!(
"HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n",
"Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n",
),
body.len(),
status_line = status_line,
);
The lint will not manufacture redundant explicit format arguments merely to unlock a cosmetic rewrite. That edit broadens the rule from literal spelling into macro argument surgery, behaves differently across formatting facade macros, and sacrifices the useful direct-literal capture idiom.
Motivation
Whitaker already states the house rule in AGENTS.md: use concat!() for long string literals rather than escaped newlines. Review findings have repeatedly applied that guidance to logging and formatting literals.
A naive textual rule would be wrong in several important cases:
format! and related macros lose implicit named capture when the format string is generated by concat!();
b"..." and c"..." literals have different types, and stable concat!() produces only &'static str;
- patterns, attributes, and macros with a
$literal:literal contract require a literal token rather than a general expression;
- a leading continuation immediately after the opening quote commonly trims the source layout's initial newline and indentation rather than joining two semantic fragments;
- macro-generated or span-mangled literals may not have a trustworthy user-editable replacement range.
The lint therefore needs semantic context, not a regular expression wearing a compiler badge.
Goals
- Enforce
concat!() for interior source-line joins in ordinary cooked string expressions.
- Enforce the same rule in source-authored format strings when no argument is implicitly captured and the originating macro accepts an expression in the format-string position.
- Retain escaped-newline continuations when direct-literal status, literal type, literal-token grammar, or layout trimming requires them.
- Provide a whole-literal, machine-applicable suggestion whenever the proof succeeds.
- Preserve the evaluated string byte for byte.
- Catch multiple join continuations with one diagnostic and one replacement.
- Work in the standalone lint crate and the aggregated Whitaker suite.
- Localize the primary message, note, and help in
en-GB, cy, and gd.
Non-goals
- Choosing where a long single-line string should wrap.
- Rewriting actual newline characters embedded in a string.
- Rewriting raw strings, which do not interpret continuation escapes.
- Adding explicit format arguments to replace implicit captures.
- Inspecting arbitrary macro token trees whose grammar Whitaker cannot prove.
- Reversing already-valid
concat!() expressions into continuation escapes.
- Acting as a general-purpose formatter.
Terminology
Continuation escape
A backslash followed by a physical source newline and the whitespace consumed after that newline. Its evaluated value is empty.
Source-line join
A continuation escape that appears after content on a physical line and before further non-delimiter content. Its sole purpose is to wrap one logical string across source lines.
let text = "first fragment \
second fragment";
Layout trim
A continuation used to suppress an initial or terminal source-layout newline or indentation, rather than to divide two logical fragments.
let text = "\
first displayed line
second displayed line";
Required continuation
A continuation for which replacing the literal expression with concat!() is not proven to preserve the relevant language or macro contract.
Rule semantics
The implementation centres on a pure classification result:
pub(crate) enum ContinuationDisposition {
PreferConcat(ConcatRewrite),
RequireContinuation(RequiredReason),
Ignore,
}
pub(crate) enum RequiredReason {
ImplicitFormatCapture,
NonStringLiteralType,
LeadingOrTrailingLayoutTrim,
LiteralTokenContext,
UnknownMacroContract,
GeneratedFormatString,
UnrecoverableSourceSpan,
}
Only PreferConcat emits a lint. The RequireContinuation variants exist to make the negative policy explicit and directly testable.
Decision matrix
| Source situation |
Decision |
Rationale |
Root-context cooked str expression with an interior join |
Diagnose and suggest concat!() |
A general expression is valid and type/value are preserved |
| Source-authored format string with positional or explicitly named arguments only |
Diagnose and suggest concat!() |
No implicit capture is lost |
Source-authored format string containing any FormatArgumentKind::Captured argument |
Require continuation |
concat!() would turn the format string into macro output and disable capture |
| Cooked byte string or C string |
Require continuation |
concat!() changes the type or has no stable equivalent |
| Raw string, raw byte string, or raw C string |
Ignore |
Continuation escapes are not interpreted |
| Leading or trailing layout trim |
Require continuation |
It expresses source layout rather than a fragment boundary |
| String literal pattern or attribute/meta literal |
Require continuation |
concat!() is an expression, not a literal token or pattern |
Literal passed through an unknown $literal macro contract |
Require continuation |
Replacement may stop the outer macro matching |
Format string generated by concat!, include_str!, or another eager macro |
Ignore |
There is no direct source literal to rewrite safely |
| Literal whose editable source span cannot be recovered exactly |
Ignore |
No trustworthy suggestion range exists |
| Several interior joins in one eligible literal |
One diagnostic, one whole-literal rewrite |
Avoid cascaded diagnostics and conflicting fixes |
Why the motivating example passes
Rust's parsed FormatArgs AST retains the complete argument classification. Captured arguments use FormatArgumentKind::Captured(Ident), distinct from positional Normal arguments and explicit Named(Ident) arguments.
The classifier therefore uses:
let has_implicit_capture = format_args
.arguments
.all_args()
.iter()
.any(|argument| matches!(argument.kind, FormatArgumentKind::Captured(_)));
When this is true, the result is:
ContinuationDisposition::RequireContinuation(
RequiredReason::ImplicitFormatCapture,
)
This is more reliable than parsing braces manually because it also catches captured width and precision parameters such as {value:width$} and {value:.precision$}.
Detection architecture
Pass phase
Implement the rule as a post-expansion early lint.
This is the narrow sweet spot:
- rustc has expanded format-like macros into
ExprKind::FormatArgs, resolved placeholders, and classified captured arguments;
- the AST still retains
FormatArgs::uncooked_fmt_str, documented by rustc as useful for lints that care about the raw bytes written by the user;
FormatArgs::is_source_literal tells the lint whether the format string was written directly rather than produced by concat!() or include_str!();
- ordinary source string expressions still appear as AST literal expressions;
- HIR lowering has not yet erased the source spelling that distinguishes a continuation from an ordinary evaluated string.
Use dylint_linting::impl_early_lint!, not a late HIR pass and not a blind pre-expansion token scan.
AST entry points
EarlyLintPass::check_expr handles two candidate shapes:
match &expr.kind {
ExprKind::Lit(literal) => check_plain_literal(cx, expr.span, literal),
ExprKind::FormatArgs(arguments) => check_format_args(cx, expr.span, arguments),
_ => {}
}
Patterns and attribute/meta literals are intentionally not visited by this rule.
RFC:
string_continuation_stylePreamble
leynos/whitakerstring_continuation_styleWarnSummary
Add an opinionated, context-sensitive Dylint rule that treats a cooked string literal's escaped newline as one of two things:
concat!()is the required spelling when replacing the literal with a macro expression is proven safe; orconcat!()would change the grammar, type, format-argument semantics, or layout intent.The rule is deliberately asymmetric. It emits a diagnostic only for proven
concat!()candidates, but its pure classifier also records why other continuations are required. Tests pin both sides so later broadening cannot quietly turn safe exceptions into false positives.The central invariant is:
Immediate finding on the motivating example
The CodeRabbit suggestion, as written, does not compile.
This source uses implicit named capture:
A direct string literal lets
format!capturestatus_linefrom the surrounding scope. A format string produced byconcat!()does not. Rust deliberately forbids implicit capture when the format string comes from macro expansion.Accordingly, the exact example is a required continuation and must pass this lint unchanged.
A
concat!()rewrite becomes valid only after making the captured argument explicit:The lint will not manufacture redundant explicit format arguments merely to unlock a cosmetic rewrite. That edit broadens the rule from literal spelling into macro argument surgery, behaves differently across formatting facade macros, and sacrifices the useful direct-literal capture idiom.
Motivation
Whitaker already states the house rule in
AGENTS.md: useconcat!()for long string literals rather than escaped newlines. Review findings have repeatedly applied that guidance to logging and formatting literals.A naive textual rule would be wrong in several important cases:
format!and related macros lose implicit named capture when the format string is generated byconcat!();b"..."andc"..."literals have different types, and stableconcat!()produces only&'static str;$literal:literalcontract require a literal token rather than a general expression;The lint therefore needs semantic context, not a regular expression wearing a compiler badge.
Goals
concat!()for interior source-line joins in ordinary cooked string expressions.en-GB,cy, andgd.Non-goals
concat!()expressions into continuation escapes.Terminology
Continuation escape
A backslash followed by a physical source newline and the whitespace consumed after that newline. Its evaluated value is empty.
Source-line join
A continuation escape that appears after content on a physical line and before further non-delimiter content. Its sole purpose is to wrap one logical string across source lines.
Layout trim
A continuation used to suppress an initial or terminal source-layout newline or indentation, rather than to divide two logical fragments.
Required continuation
A continuation for which replacing the literal expression with
concat!()is not proven to preserve the relevant language or macro contract.Rule semantics
The implementation centres on a pure classification result:
Only
PreferConcatemits a lint. TheRequireContinuationvariants exist to make the negative policy explicit and directly testable.Decision matrix
strexpression with an interior joinconcat!()concat!()FormatArgumentKind::Capturedargumentconcat!()would turn the format string into macro output and disable captureconcat!()changes the type or has no stable equivalentconcat!()is an expression, not a literal token or pattern$literalmacro contractconcat!,include_str!, or another eager macroWhy the motivating example passes
Rust's parsed
FormatArgsAST retains the complete argument classification. Captured arguments useFormatArgumentKind::Captured(Ident), distinct from positionalNormalarguments and explicitNamed(Ident)arguments.The classifier therefore uses:
When this is true, the result is:
This is more reliable than parsing braces manually because it also catches captured width and precision parameters such as
{value:width$}and{value:.precision$}.Detection architecture
Pass phase
Implement the rule as a post-expansion early lint.
This is the narrow sweet spot:
ExprKind::FormatArgs, resolved placeholders, and classified captured arguments;FormatArgs::uncooked_fmt_str, documented by rustc as useful for lints that care about the raw bytes written by the user;FormatArgs::is_source_literaltells the lint whether the format string was written directly rather than produced byconcat!()orinclude_str!();Use
dylint_linting::impl_early_lint!, not a late HIR pass and not a blind pre-expansion token scan.AST entry points
EarlyLintPass::check_exprhandles two candidate shapes:Patterns and attribute/meta literals are intentionally not visited by this rule.