From 60f2876d5bd9b80e9059424d103a219d6b9c397b Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 10:52:08 +0100 Subject: [PATCH 1/5] Add footnote wrapping support --- src/lib.rs | 23 +++++++++++++++++++++++ tests/integration.rs | 11 +++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index bedd0844..66345b41 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 @@ -500,6 +503,26 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { continue; } + if let Some(cap) = FOOTNOTE_RE.captures(line) { + flush_paragraph(&mut out, &buf, &indent, width); + 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}")); + } + } + continue; + } + if buf.is_empty() { indent = line.chars().take_while(|c| c.is_whitespace()).collect(); } diff --git a/tests/integration.rs b/tests/integration.rs index cb835caf..bd555b7f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -716,6 +716,17 @@ fn test_wrap_multiple_inline_code_spans() { common::assert_wrapped_list_item(&output, "- ", 2); } +#[test] +fn test_wrap_footnote_multiline() { + let input = vec![ + "[^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] /// Verifies that short list items are not wrapped or altered by the stream processing logic. /// From 8c25dccab5175c41c11c5117949dfdcd20d3a366 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 12:03:40 +0100 Subject: [PATCH 2/5] Use concat macro for footnote test --- AGENTS.md | 6 ++++-- tests/integration.rs | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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/tests/integration.rs b/tests/integration.rs index bd555b7f..5682b75d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -719,9 +719,11 @@ fn test_wrap_multiple_inline_code_spans() { #[test] fn test_wrap_footnote_multiline() { let input = vec![ - "[^note]: This footnote is sufficiently long to require wrapping across multiple lines so \ - we can verify indentation." - .to_string(), + 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); From 1023704864a7540b93fad944c02175ada7e31449 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 13:06:17 +0100 Subject: [PATCH 3/5] Refactor footnote wrapping --- src/lib.rs | 52 +++++++++++++++++++++++--------------------- tests/integration.rs | 13 +++++++++++ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 66345b41..068a932b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -227,7 +227,7 @@ 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()); + std::sync::LazyLock::new(|| Regex::new(r"^(\s*)(\[\^[\w-]+\]:\s*)(.*)$").unwrap()); /// Parses a line beginning with a numbered list marker. /// @@ -403,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, @@ -489,17 +509,7 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { 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}")); - } - } + append_wrapped_with_prefix(&mut out, prefix, rest, width); continue; } @@ -507,19 +517,11 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { flush_paragraph(&mut out, &buf, &indent, width); 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 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 5682b75d..637d7d4d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -729,6 +729,19 @@ fn test_wrap_footnote_multiline() { 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. /// From bad9797786fb4a35d83f40707283f6e6f9e9f0f3 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 20:15:35 +0100 Subject: [PATCH 4/5] Allow any characters in footnote labels --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 068a932b..410c6800 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -227,7 +227,7 @@ 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*)(\[\^[\w-]+\]:\s*)(.*)$").unwrap()); + std::sync::LazyLock::new(|| Regex::new(r"^(\s*)(\[\^[^]]+\]:\s*)(.*)$").unwrap()); /// Parses a line beginning with a numbered list marker. /// From 35c03a52eb70de3ff74cdbc215d0036e5667fc74 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 20:51:37 +0100 Subject: [PATCH 5/5] Preserve whitespace in list items --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 410c6800..9f545b22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -508,7 +508,7 @@ 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 rest = cap.get(2).unwrap().as_str(); append_wrapped_with_prefix(&mut out, prefix, rest, width); continue; }