Skip to content

RFC: Add context-sensitive string_continuation_style lint #345

Description

@leynos

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:

  1. a source-line join, for which concat!() is the required spelling when replacing the literal with a macro expression is proven safe; or
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmediumRoadmap items to schedule within the current quarter. Clear scope, normal review cycles.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions