Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#3197·mdBook

partition_rust_source over-consumes leading indentation of the first code line following an inner attribute

Author: Poojax21Created Aug 26, 2026Updated Aug 26, 2026

Summary

When mdBook wraps a Rust snippet that lacks fn main (wrap_rust_main), the source is split into inner attributes and remaining code by partition_rust_source. The second alternative of its header regex, ^\s* (?:\r?\n)?, can match a partial line: after an attribute line, it consumes only the leading spaces of the next (code) line without consuming that line's newline. Because the captured group is then non-empty, those spaces are counted as part of the attributes region, so split_at moves them out of the code - visibly de-indenting the first code line in the rendered/hidden-main output.

This is a static-analysis finding based on reading master; I did not run a build.

Location

  • File: crates/mdbook-html/src/html/hide_lines.rs
  • Function: partition_rust_source (regex HEADER_RE, used by wrap_rust_main)
  • Relevant code path:
rust
static_regex!(
    HEADER_RE,
    r"^(?mx)
    (
        (?:
            ^[ \t]*\#!\[.* (?:\r?\n)?
            |
            ^\s* (?:\r?\n)?
        )*
    )"
);

Problem

With multiline mode, each repetition of the alternation restarts at a line boundary. For input

#![allow(a)]
    let x = 1;

the first iteration matches the attribute line plus its newline. At the start of the next line, the second alternative ^\s*(?:\r?\n)? greedily matches the four leading spaces; the optional (?:\r?\n) does not apply because the following character is l, not a newline - and an optional group may match empty, so the iteration still succeeds, ending mid-line. The next iteration cannot proceed because ^ no longer holds.

The captured attributes are therefore "#![allow(a)]\n " (note the four trailing spaces). Since attributes.trim().is_empty() is false, the guard intended to reject pure-whitespace captures does not trigger, and s.split_at(attributes.len()) places the code line's indentation into the attributes part.

Trigger / Reproduction

Based on source analysis:

  1. A Rust code block whose source has no fn main and begins with an inner attribute, followed by indented code:
rust
#![allow(a)]
    let x = 1;
  1. wrap_rust_main produces # #![allow(unused)]\n{attrs}# fn main() {{\n{code}.... With the over-consumed capture, two things go wrong:
    • the inserted marker line inherits the dangling spaces: the text becomes ...\n # fn main() {;
    • code starts at let x = 1; - its original four-space indentation was moved into attrs and disappears from the displayed snippet.

Expected Behavior

The attributes/code split should occur exactly at the end of the last inner-attribute line (including its newline). Leading whitespace belonging to the first non-attribute line should remain part of the code.

Actual Behavior

Up to one line's worth of leading whitespace after the final attribute is transferred to the attributes segment, de-indenting the first visible code line and appending stray whitespace to the auto-generated hidden wrapper.

Impact

Any book snippet that starts with an inner attribute and relies on automatic fn main wrapping renders with altered indentation for its first statement. Indentation is semantically meaningful in Rust display contexts (readability, rustfmt-style examples, macro-sensitive snippets), so the rendered example differs from the authored source. Severity is low-to-moderate and purely presentational, but it is deterministic and easy to reproduce once pointed out.

Suggested Direction

Require that the whitespace-only alternative consumes whole lines, e.g. change ^\s* (?:\r?\n)? to something like ^[ \t]* (?:\r?\n|$) or [ \t]*\r?\n within the repeated group, so it cannot terminate mid-line; alternatively post-check that the capture ends with \n (or is empty) before using attributes.len() as the split point.

Evidence

  • The regex alternation permits (?:\r?\n)? to match empty while \s* has already consumed intra-line indentation; nothing else constrains the group to line boundaries.
  • The existing guard only rejects captures whose trimmed content is empty, which is precisely why a mixed capture (attribute + trailing spaces) slips through.
  • All existing unit tests in the same file use either no attributes or attribute blocks followed by unindented code, so this path is uncovered.

Source: rust-lang/mdBook

View original on GitHubView discussion on GitHub