diff --git a/AGENTS.md b/AGENTS.md index 712e2e25..19ce51f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,8 @@ ## Rust Specific Guidance This repository is written in Rust and uses Cargo for building and dependency -management. Contributors should follow these best practices when working on the -project: +management. Contributors should follow these best practices when working on +the project: - Run `make fmt`, `make lint`, and `make test` before committing. These targets wrap `cargo fmt`, `cargo clippy`, and `cargo test` with the appropriate flags. @@ -130,6 +130,8 @@ project: - Replace duplicated tests with `#[rstest(...)]` parameterised cases. - Prefer `mockall` for mocks/stubs. - Prefer `.expect()` over `.unwrap()`. +- Use `concat!()` to combine long string literals rather than escaping newlines + with a backslash. ## Markdown Guidance diff --git a/src/lib.rs b/src/lib.rs index bedd0844..9f545b22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -226,6 +226,9 @@ static BULLET_RE: std::sync::LazyLock = static NUMBERED_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| Regex::new(r"^(\s*)([1-9][0-9]*)\.(\s+)(.*)").unwrap()); +static FOOTNOTE_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"^(\s*)(\[\^[^]]+\]:\s*)(.*)$").unwrap()); + /// Parses a line beginning with a numbered list marker. /// /// Returns the indentation prefix, separator following the number, and the @@ -400,6 +403,26 @@ fn flush_paragraph(out: &mut Vec, buf: &[(String, bool)], indent: &str, } } +fn append_wrapped_with_prefix(out: &mut Vec, prefix: &str, text: &str, width: usize) { + use unicode_width::UnicodeWidthStr; + + let prefix_width = UnicodeWidthStr::width(prefix); + let indent_str: String = prefix.chars().take_while(|c| c.is_whitespace()).collect(); + let indent_width = UnicodeWidthStr::width(indent_str.as_str()); + let wrapped_indent = format!("{}{}", indent_str, " ".repeat(prefix_width - indent_width)); + + for (i, line) in wrap_preserving_code(text, width - prefix_width) + .iter() + .enumerate() + { + if i == 0 { + out.push(format!("{prefix}{line}")); + } else { + out.push(format!("{wrapped_indent}{line}")); + } + } +} + /// Wraps text lines to a specified width, preserving markdown structure. /// /// Paragraphs and list items are reflowed to the given width, while code blocks, tables, headers, @@ -485,18 +508,20 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { buf.clear(); indent.clear(); let prefix = cap.get(1).unwrap().as_str(); - let rest = cap.get(2).unwrap().as_str().trim(); - let spaces = " ".repeat(prefix.len()); - for (i, l) in wrap_preserving_code(rest, width - prefix.len()) - .iter() - .enumerate() - { - if i == 0 { - out.push(format!("{prefix}{l}")); - } else { - out.push(format!("{spaces}{l}")); - } - } + let rest = cap.get(2).unwrap().as_str(); + append_wrapped_with_prefix(&mut out, prefix, rest, width); + continue; + } + + if let Some(cap) = FOOTNOTE_RE.captures(line) { + flush_paragraph(&mut out, &buf, &indent, width); + buf.clear(); + indent.clear(); + let indent_part = cap.get(1).unwrap().as_str(); + let label_part = cap.get(2).unwrap().as_str(); + let prefix = format!("{indent_part}{label_part}"); + let rest = cap.get(3).unwrap().as_str(); + append_wrapped_with_prefix(&mut out, &prefix, rest, width); continue; } diff --git a/tests/integration.rs b/tests/integration.rs index cb835caf..637d7d4d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -716,6 +716,32 @@ fn test_wrap_multiple_inline_code_spans() { common::assert_wrapped_list_item(&output, "- ", 2); } +#[test] +fn test_wrap_footnote_multiline() { + let input = vec![ + concat!( + "[^note]: This footnote is sufficiently long to require wrapping ", + "across multiple lines so we can verify indentation." + ) + .to_string(), + ]; + let output = process_stream(&input); + common::assert_wrapped_list_item(&output, "[^note]: ", 2); +} + +#[test] +fn test_wrap_footnote_with_inline_code() { + let input = vec![ + concat!( + " [^code_note]: A footnote containing inline `code` that should wrap ", + "across multiple lines without breaking the span." + ) + .to_string(), + ]; + let output = process_stream(&input); + common::assert_wrapped_list_item(&output, " [^code_note]: ", 2); +} + #[test] /// Verifies that short list items are not wrapped or altered by the stream processing logic. ///