From 9d5b106ff339db8573d558a4fa3c58932ed58a69 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 25 Jul 2026 15:34:57 +0200 Subject: [PATCH 1/4] Isolate wrap tracing behind observer adapter Move inline classification and parsing observations into domain events.\n\nKeep tracing integration at the paragraph-facing boundary so domain code\ncan be called without a subscriber or vendor-specific guards. --- src/wrap.rs | 2 + src/wrap/inline.rs | 58 ++++++++----- src/wrap/inline/fragment.rs | 61 ++++++-------- src/wrap/inline/normalize.rs | 8 +- src/wrap/inline/postprocess.rs | 39 +-------- src/wrap/inline/postprocess_tests.rs | 6 +- src/wrap/inline/predicate_tracing_tests.rs | 38 --------- src/wrap/inline/predicates.rs | 57 ++++--------- src/wrap/inline/span_helper_tracing_tests.rs | 89 -------------------- src/wrap/inline/span_helpers.rs | 44 +++------- src/wrap/inline/tests.rs | 3 +- src/wrap/inline/tracing_events.rs | 72 ---------------- src/wrap/observer.rs | 66 +++++++++++++++ src/wrap/paragraph.rs | 12 ++- src/wrap/tokenize/mod.rs | 11 ++- src/wrap/tokenize/parsing.rs | 74 ++++++++-------- src/wrap/tokenize/parsing_tests.rs | 38 +++++---- src/wrap/tracing_adapter.rs | 54 ++++++++++++ 18 files changed, 302 insertions(+), 430 deletions(-) delete mode 100644 src/wrap/inline/predicate_tracing_tests.rs delete mode 100644 src/wrap/inline/span_helper_tracing_tests.rs delete mode 100644 src/wrap/inline/tracing_events.rs create mode 100644 src/wrap/observer.rs create mode 100644 src/wrap/tracing_adapter.rs diff --git a/src/wrap.rs b/src/wrap.rs index 6e7d9b44..fe2f2c76 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -18,8 +18,10 @@ mod continuation; mod fence; mod inline; mod link_reference; +mod observer; mod paragraph; mod tokenize; +mod tracing_adapter; use block::{BULLET_RE, FOOTNOTE_RE}; pub(crate) use block::{BlockKind, classify_block, leading_indent}; pub use blockquote::BlockquotePrefix; diff --git a/src/wrap/inline.rs b/src/wrap/inline.rs index 4f461a25..4a5a950d 100644 --- a/src/wrap/inline.rs +++ b/src/wrap/inline.rs @@ -16,7 +16,6 @@ mod span_helpers; mod test_support; #[cfg(test)] mod tests; -mod tracing_events; /// Returns whether `token` begins with a matched inline code fence, optionally /// followed by a non-whitespace suffix such as an inflectional affix. @@ -54,13 +53,15 @@ use span_helpers::{ try_couple_inline_link_after_opener, }; use textwrap::wrap_algorithms::wrap_first_fit; -use tracing::trace; -use tracing_events::{emit_footnote_reference_coupling, emit_whitespace_footnote_coupling}; use unicode_width::UnicodeWidthStr; use super::tokenize; -fn initial_token_span(tokens: &[String], start: usize) -> (usize, usize, SpanKind) { +fn initial_token_span( + tokens: &[String], + start: usize, + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, +) -> (usize, usize, SpanKind) { let mut end = start + 1; let mut width = UnicodeWidthStr::width(tokens[start].as_str()); let mut kind = SpanKind::General; @@ -105,7 +106,7 @@ fn initial_token_span(tokens: &[String], start: usize) -> (usize, usize, SpanKin } else if looks_like_link(&tokens[start]) { kind = SpanKind::Link; end = extend_punctuation(tokens, end, &mut width); - } else if looks_like_footnote_ref(&tokens[start]) { + } else if looks_like_footnote_ref(&tokens[start], observer) { kind = SpanKind::FootnoteRef; end = extend_punctuation(tokens, end, &mut width); } @@ -121,16 +122,21 @@ fn initial_token_span(tokens: &[String], start: usize) -> (usize, usize, SpanKin /// link, or plain fragment, and `width` is its Unicode display width. This /// helper assumes `start < tokens.len()` and will panic if called out of /// bounds. +#[cfg(test)] pub(super) fn determine_token_span(tokens: &[String], start: usize) -> (usize, usize) { + determine_token_span_observed(tokens, start, &mut None) +} + +fn determine_token_span_observed( + tokens: &[String], + start: usize, + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, +) -> (usize, usize) { if let Some((end, width)) = date_token_span(tokens, start) { - trace!( - start, - end, width, "determine_token_span grouped date sequence" - ); return (end, width); } - let (mut end, mut width, mut kind) = initial_token_span(tokens, start); + let (mut end, mut width, mut kind) = initial_token_span(tokens, start, observer); while end < tokens.len() { let token = &tokens[end]; @@ -138,7 +144,6 @@ pub(super) fn determine_token_span(tokens: &[String], start: usize) -> (usize, u let next_token = tokens.get(end + 1); let following_token = tokens.get(end + 2); let should_couple = should_couple_whitespace(kind, next_token, following_token); - emit_whitespace_footnote_coupling(kind, next_token, following_token, should_couple); if should_couple { width += UnicodeWidthStr::width(token.as_str()); end += 1; @@ -173,8 +178,8 @@ pub(super) fn determine_token_span(tokens: &[String], start: usize) -> (usize, u // Footnote markers must be coupled before consecutive link/code chaining; // otherwise `[^N]` stays a separate wrap token even when punctuation is // already attached to the preceding atomic span. - let footnote_coupling = try_couple_footnote_reference(tokens, end, kind, &mut width); - emit_footnote_reference_coupling(tokens, end, kind, footnote_coupling.is_some()); + let footnote_coupling = + try_couple_footnote_reference(tokens, end, kind, &mut width, observer); if let Some((next_kind, next_end)) = footnote_coupling { kind = next_kind; end = next_end; @@ -221,12 +226,15 @@ fn push_span_text(text: &mut String, tokens: &[String], span: Range) { /// The return value preserves token order while grouping inline code, links, /// and whitespace runs into `InlineFragment` values with precomputed widths. /// This helper never panics when `tokens` is well-formed. -fn build_fragments(tokens: &[String]) -> Vec { +fn build_fragments( + tokens: &[String], + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, +) -> Vec { let mut fragments: Vec = Vec::new(); let mut i = 0; while i < tokens.len() { - let (group_end, _group_width) = determine_token_span(tokens, i); + let (group_end, _group_width) = determine_token_span_observed(tokens, i, observer); let span = i..group_end; let text = if tokens[i..group_end] .iter() @@ -238,7 +246,7 @@ fn build_fragments(tokens: &[String]) -> Vec { push_span_text(&mut text, tokens, span); text }; - fragments.push(InlineFragment::new(text)); + fragments.push(InlineFragment::new_observed(text, observer)); i = group_end; } @@ -247,7 +255,7 @@ fn build_fragments(tokens: &[String]) -> Vec { /// Returns whether `line` contains one link fragment. fn is_single_link_line(line: &[InlineFragment]) -> bool { - line.len() == 1 && line[0].kind == fragment::FragmentKind::Link + line.len() == 1 && line[0].kind == crate::wrap::observer::FragmentKind::Link } /// Returns the total display width of a fragment line. @@ -265,7 +273,7 @@ fn split_boundary_link_line( if !(previous_width == width || previous_width + 1 == width) || !line .first() - .is_some_and(|fragment| fragment.kind == fragment::FragmentKind::Link) + .is_some_and(|fragment| fragment.kind == crate::wrap::observer::FragmentKind::Link) || !line .get(1) .is_some_and(|fragment| fragment.is_whitespace() || fragment.is_plain()) @@ -327,14 +335,24 @@ fn render_line( /// rendered back into `Vec` output lines. `width` is measured in /// Unicode display columns and must be at least one effective column after any /// caller prefix handling. This helper never panics for valid input. +#[cfg(test)] pub(super) fn wrap_preserving_code(text: &str, width: usize) -> Vec { - let tokens = tokenize::segment_inline(text); + let mut observer = crate::wrap::observer::NoOpObserver; + wrap_preserving_code_observed(text, width, &mut Some(&mut observer)) +} + +pub(super) fn wrap_preserving_code_observed( + text: &str, + width: usize, + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, +) -> Vec { + let tokens = tokenize::segment_inline_observed(text, observer); if tokens.is_empty() { return Vec::new(); } let tokens = normalize_footnote_ref_spacing(&tokens); - let fragments = build_fragments(&tokens); + let fragments = build_fragments(&tokens, observer); let mut lines = Vec::new(); let mut buffer: Vec = Vec::new(); diff --git a/src/wrap/inline/fragment.rs b/src/wrap/inline/fragment.rs index ce6c42bb..8a305b7b 100644 --- a/src/wrap/inline/fragment.rs +++ b/src/wrap/inline/fragment.rs @@ -12,7 +12,6 @@ //! throughout the wrapping pipeline. use textwrap::core::Fragment; -use tracing::debug; use unicode_width::UnicodeWidthStr; use super::{ @@ -24,21 +23,7 @@ use super::{ is_whitespace_token, looks_like_footnote_ref, }; - -/// Classifies an inline fragment for post-wrap heuristics. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum FragmentKind { - /// Marks a fragment that contains only whitespace. - Whitespace, - /// Marks a fragment that contains inline code. - InlineCode, - /// Marks a fragment that contains a Markdown link. - Link, - /// Marks a fragment that contains a GFM footnote reference. - FootnoteRef, - /// Marks a fragment that contains ordinary prose. - Plain, -} +use crate::wrap::observer::{Event, FragmentKind, Observer}; /// Stores rendered fragment text, width, and classification for wrapping. #[derive(Debug, Clone, PartialEq, Eq)] @@ -57,10 +42,23 @@ impl InlineFragment { /// The parameter is stored verbatim. The returned fragment also carries /// its Unicode display width, computed with `UnicodeWidthStr::width`, and /// its `FragmentKind`, computed once through `classify_fragment`. + #[cfg(test)] pub(super) fn new(text: String) -> Self { + let mut observer = crate::wrap::observer::NoOpObserver; + Self::new_observed(text, &mut Some(&mut observer)) + } + + pub(super) fn new_observed(text: String, observer: &mut Option<&mut dyn Observer>) -> Self { let width = UnicodeWidthStr::width(text.as_str()); - let kind = classify_fragment(text.as_str()); - log_fragment_classification(text.as_str(), &kind); + let kind = classify_fragment(text.as_str(), observer); + if let Some(observer) = observer.as_deref_mut() { + let (token, truncated) = trace_text_snippet(text.as_str()); + observer.observe(Event::FragmentClassified { + token, + truncated, + kind, + }); + } Self { text, width, kind } } @@ -154,7 +152,7 @@ fn contains_link_with_trailing_punctuation(text: &str) -> bool { /// still recognised as links or code spans. Footnote references also recognise /// the `word.[^label]` suffix shape that the wrapper groups to avoid splitting /// sentence punctuation from the marker. -fn classify_fragment(text: &str) -> FragmentKind { +fn classify_fragment(text: &str, observer: &mut Option<&mut dyn Observer>) -> FragmentKind { if is_whitespace_token(text) { return FragmentKind::Whitespace; } @@ -168,9 +166,9 @@ fn classify_fragment(text: &str) -> FragmentKind { || has_inline_code_structure(text) { FragmentKind::InlineCode - } else if looks_like_footnote_ref(text) - || looks_like_footnote_ref(trimmed) - || ends_with_footnote_ref(text) + } else if looks_like_footnote_ref(text, observer) + || looks_like_footnote_ref(trimmed, observer) + || ends_with_footnote_ref(text, observer) { FragmentKind::FootnoteRef } else { @@ -200,19 +198,6 @@ fn trace_text_snippet(text: &str) -> (&str, bool) { (&text[..byte_end], true) } -/// Emits a structured trace when fragment classification logging is enabled. -fn log_fragment_classification(text: &str, kind: &FragmentKind) { - if tracing::enabled!(tracing::Level::DEBUG) { - let (snippet, truncated) = trace_text_snippet(text); - debug!( - token = %snippet, - truncated, - kind = ?kind, - "fragment classified" - ); - } -} - #[cfg(test)] mod tests { //! Unit tests for inline-fragment classification. @@ -346,7 +331,8 @@ mod tracing_tests { use rstest::rstest; use tracing_test::traced_test; - use super::{FragmentKind, InlineFragment}; + use super::InlineFragment; + use crate::wrap::{observer::FragmentKind, tracing_adapter::TracingObserver}; #[traced_test] #[rstest] @@ -356,7 +342,8 @@ mod tracing_tests { #[case(" ", "Whitespace")] #[case("plain", "Plain")] fn fragment_classification_logs_kind(#[case] input: &str, #[case] expected: &str) { - let _fragment = InlineFragment::new(input.to_string()); + let mut observer = TracingObserver; + let _fragment = InlineFragment::new_observed(input.to_string(), &mut Some(&mut observer)); assert!(logs_contain("fragment classified")); assert!(logs_contain(&format!("kind={expected}"))); assert!(logs_contain("token=")); diff --git a/src/wrap/inline/normalize.rs b/src/wrap/inline/normalize.rs index bea0d3e6..639f96c5 100644 --- a/src/wrap/inline/normalize.rs +++ b/src/wrap/inline/normalize.rs @@ -42,10 +42,10 @@ pub(in crate::wrap::inline) fn normalize_footnote_ref_spacing( fn matches_footnote_ref_spacing(tokens: &[String], index: usize) -> bool { tokens.get(index..index + 3).is_some_and(|window| { - !looks_like_footnote_ref(&window[0]) + !looks_like_footnote_ref(&window[0], &mut None) && window[0].chars().last().is_some_and(is_trailing_punct) && window[1].chars().all(char::is_whitespace) - && looks_like_footnote_ref(&window[2]) + && looks_like_footnote_ref(&window[2], &mut None) }) } @@ -125,10 +125,10 @@ mod tests { return false; }; - !looks_like_footnote_ref(&window[0]) + !looks_like_footnote_ref(&window[0], &mut None) && window[0].chars().last().is_some_and(is_trailing_punct) && window[1].chars().all(char::is_whitespace) - && looks_like_footnote_ref(&window[2]) + && looks_like_footnote_ref(&window[2], &mut None) } fn removed_spacing_count(tokens: &[String]) -> usize { diff --git a/src/wrap/inline/postprocess.rs b/src/wrap/inline/postprocess.rs index 482ce7e1..e230e639 100644 --- a/src/wrap/inline/postprocess.rs +++ b/src/wrap/inline/postprocess.rs @@ -11,9 +11,8 @@ //! historical whitespace behaviour and keep eligible atomic fragments attached //! to the line where they fit. -use tracing::trace; - -use super::fragment::{FragmentKind, InlineFragment}; +use super::fragment::InlineFragment; +use crate::wrap::observer::FragmentKind; /// Returns whether every fragment on the line is whitespace-only. fn is_whitespace_only_line(line: &[InlineFragment]) -> bool { @@ -89,45 +88,18 @@ fn inline_code_tail_carry_fits( width: usize, ) -> bool { let Some(next_content_line) = next_content_line else { - trace!( - fits = true, - reason = "no following content line", - target_width = width, - "checked inline-code tail carry width" - ); return true; }; let Some(previous_line) = merged.last() else { - trace!( - fits = true, - reason = "no previous merged line", - target_width = width, - "checked inline-code tail carry width" - ); return true; }; let Some(previous_tail) = previous_line.last() else { - trace!( - fits = true, - reason = "previous merged line is empty", - target_width = width, - "checked inline-code tail carry width" - ); return true; }; let next_line_width = line_width(next_content_line); let projected_width = previous_tail.width + 1 + next_line_width; - let fits = projected_width <= width; - trace!( - fits, - projected_width, - target_width = width, - previous_tail_width = previous_tail.width, - next_line_width, - "checked inline-code tail carry width" - ); - fits + projected_width <= width } /// Merges whitespace-only wrap artefacts into neighbouring content lines. @@ -145,11 +117,6 @@ pub(super) fn merge_whitespace_only_lines( for (index, mut line) in lines.iter().cloned().enumerate() { if is_whitespace_only_line(&line) { - trace!( - index, - fragment_count = line.len(), - "normalizing whitespace-only wrapped line" - ); let next_starts_atomic = lines .get(index + 1) .is_some_and(|next_line| line_starts_with_atomic(next_line)); diff --git a/src/wrap/inline/postprocess_tests.rs b/src/wrap/inline/postprocess_tests.rs index b0f6840a..99f42e57 100644 --- a/src/wrap/inline/postprocess_tests.rs +++ b/src/wrap/inline/postprocess_tests.rs @@ -5,10 +5,8 @@ use rstest::rstest; -use super::{ - super::fragment::{FragmentKind, InlineFragment}, - *, -}; +use super::{super::fragment::InlineFragment, *}; +use crate::wrap::observer::FragmentKind; fn fragment(text: &str) -> InlineFragment { InlineFragment::new(text.into()) } diff --git a/src/wrap/inline/predicate_tracing_tests.rs b/src/wrap/inline/predicate_tracing_tests.rs deleted file mode 100644 index e2d9ff86..00000000 --- a/src/wrap/inline/predicate_tracing_tests.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Traced-event tests for inline predicate helpers. -//! -//! These tests verify that instrumented predicate helpers emit TRACE events -//! when called, confirming that `#[tracing::instrument]` is active at the -//! declared log level. - -use rstest::rstest; -use tracing_test::traced_test; - -use super::{ - ends_with_footnote_ref, - ends_with_hyphen_prefix, - is_month_name, - is_numeric_day, - is_ordinal_day, - is_year, - looks_like_footnote_ref, -}; - -#[traced_test] -#[rstest( - predicate, - input, - expected_log, - case(looks_like_footnote_ref, "[^1]", "looks_like_footnote_ref"), - case(ends_with_footnote_ref, "word.[^1]", "ends_with_footnote_ref"), - case(ends_with_hyphen_prefix, "pre-", "ends_with_hyphen_prefix"), - case(is_month_name, "January", "is_month_name"), - case(is_ordinal_day, "25th", "is_ordinal_day"), - case(is_numeric_day, "25", "is_numeric_day"), - case(is_year, "2025", "is_year") -)] -#[test] -fn predicate_emits_trace_event(predicate: fn(&str) -> bool, input: &str, expected_log: &str) { - let _ = predicate(input); - assert!(logs_contain(expected_log)); - assert!(!logs_contain("token=")); -} diff --git a/src/wrap/inline/predicates.rs b/src/wrap/inline/predicates.rs index 8909bb98..e37ca0eb 100644 --- a/src/wrap/inline/predicates.rs +++ b/src/wrap/inline/predicates.rs @@ -9,6 +9,7 @@ //! tokens, and four-digit year tokens before wrapping. pub(crate) use super::month_names::MONTH_NAMES; +use crate::wrap::observer::{Event, Observer}; pub(in crate::wrap::inline) fn is_opening_punct(c: char) -> bool { matches!(c, '(' | '[' | '"') || "“‘([【《「『".contains(c) @@ -32,10 +33,6 @@ pub(in crate::wrap::inline) fn is_trailing_punctuation_token(token: &str) -> boo } /// Returns whether `token` is a full or abbreviated English month name. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] pub(in crate::wrap::inline) fn is_month_name(token: &str) -> bool { let token = strip_leading_openers(token); month_names_for_len(token.len()) @@ -70,10 +67,6 @@ fn month_names_for_len(len: usize) -> &'static [&'static str] { } /// Returns whether `token` is an ordinal day number from 1st through 31st. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] pub(in crate::wrap::inline) fn is_ordinal_day(token: &str) -> bool { let token = strip_leading_openers(token); ["st", "nd", "rd", "th"] @@ -83,10 +76,6 @@ pub(in crate::wrap::inline) fn is_ordinal_day(token: &str) -> bool { } /// Returns whether `token` is a numeric day number from 1 through 31. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] pub(in crate::wrap::inline) fn is_numeric_day(token: &str) -> bool { let token = strip_leading_openers(token); token @@ -98,10 +87,6 @@ pub(in crate::wrap::inline) fn is_numeric_day(token: &str) -> bool { /// Returns whether `token` is a year from 1000 through 2999, optionally /// followed by trailing prose punctuation. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] pub(in crate::wrap::inline) fn is_year(token: &str) -> bool { token .trim_end_matches(is_trailing_punct) @@ -121,28 +106,30 @@ pub(in crate::wrap::inline) fn looks_like_link(token: &str) -> bool { } /// Returns whether `token` looks like a complete GFM footnote reference. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] -pub(in crate::wrap::inline) fn looks_like_footnote_ref(token: &str) -> bool { - token +pub(in crate::wrap::inline) fn looks_like_footnote_ref( + token: &str, + observer: &mut Option<&mut dyn Observer>, +) -> bool { + let result = token .strip_prefix("[^") .and_then(|label| label.strip_suffix(']')) - .is_some_and(|label| !label.is_empty()) + .is_some_and(|label| !label.is_empty()); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::FootnoteRefChecked { token, result }); + } + result } /// Returns whether `token` ends with an inline footnote reference. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] -pub(in crate::wrap::inline) fn ends_with_footnote_ref(token: &str) -> bool { +pub(in crate::wrap::inline) fn ends_with_footnote_ref( + token: &str, + observer: &mut Option<&mut dyn Observer>, +) -> bool { let Some(start) = token.rfind("[^") else { return false; }; - looks_like_footnote_ref(&token[start..]) + looks_like_footnote_ref(&token[start..], observer) } /// Returns whether `token` contains only Unicode whitespace. @@ -164,10 +151,6 @@ pub(in crate::wrap::inline) fn is_inline_code_token(token: &str) -> bool { /// `字-`) are intentionally accepted alongside ASCII prefixes. Internal hyphen /// chains (`state-of-the-art-`) are also accepted because such compounds /// remain a single atomic wrap token by design. -/// -/// The `#[tracing::instrument]` attribute records the return value while -/// excluding document content from the span. -#[tracing::instrument(level = "trace", skip(token), ret)] pub(in crate::wrap::inline) fn ends_with_hyphen_prefix(token: &str) -> bool { token.ends_with('-') && token.chars().any(char::is_alphabetic) } @@ -225,10 +208,6 @@ pub(in crate::wrap::inline) fn fragment_is_link(text: &str) -> bool { #[path = "predicate_date_props.rs"] mod predicate_date_props; -#[cfg(test)] -#[path = "predicate_tracing_tests.rs"] -mod predicate_tracing_tests; - #[cfg(test)] mod tests { //! Unit tests for inline-token predicates. @@ -308,13 +287,13 @@ mod tests { fn looks_like_footnote_ref_implies_non_empty_label() { proptest!(|(label in footnote_label_strategy())| { let token = format!("[^{label}]"); - prop_assert!(looks_like_footnote_ref(&token)); + prop_assert!(looks_like_footnote_ref(&token, &mut None)); }); } #[test] fn looks_like_footnote_ref_rejects_empty_label() { - assert!(!looks_like_footnote_ref("[^]")); + assert!(!looks_like_footnote_ref("[^]", &mut None)); } #[rstest] diff --git a/src/wrap/inline/span_helper_tracing_tests.rs b/src/wrap/inline/span_helper_tracing_tests.rs deleted file mode 100644 index 4aefff6a..00000000 --- a/src/wrap/inline/span_helper_tracing_tests.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Traced-event tests for inline span helper instrumentation. - -use rstest::{fixture, rstest}; -use tracing_test::traced_test; - -use super::{date_token_span, try_match_date_sequence}; -use crate::wrap::inline::determine_token_span; - -#[fixture] -fn date_tokens() -> Vec { - vec![ - "25th".to_string(), - " ".to_string(), - "December".to_string(), - " ".to_string(), - "2025".to_string(), - ] -} - -#[fixture] -fn colon_footnote_tokens() -> Vec { - vec![ - "word".to_string(), - " ".to_string(), - "[^note]".to_string(), - ":".to_string(), - ] -} - -#[traced_test] -#[rstest] -fn try_match_date_sequence_emits_trace_event(date_tokens: Vec) { - let _ = try_match_date_sequence(&date_tokens, 0); - assert!(logs_contain("try_match_date_sequence")); -} - -#[traced_test] -#[rstest] -fn try_match_date_sequence_logs_matched_pattern(date_tokens: Vec) { - let _ = try_match_date_sequence(&date_tokens, 0); - assert!(logs_contain("ordinal_day_month_year")); -} - -#[traced_test] -#[rstest] -fn date_token_span_emits_trace_event(date_tokens: Vec) { - let _ = date_token_span(&date_tokens, 0); - assert!(logs_contain("date_token_span")); -} - -#[traced_test] -#[rstest] -#[case::whitespace("coupled whitespace before colon-suffixed footnote reference")] -#[case::reference("coupled colon-suffixed footnote reference after whitespace")] -fn grouping_boundary_logs_colon_footnote_coupling( - colon_footnote_tokens: Vec, - #[case] expected_event: &str, -) { - let _ = determine_token_span(&colon_footnote_tokens, 0); - assert!(logs_contain(expected_event)); - assert!(logs_contain("span_kind=General")); - assert!(logs_contain("token_length=7")); - assert!(!logs_contain("[^note]")); -} - -#[traced_test] -#[rstest] -#[case::missing_colon( - &["word", " ", "[^note]"], - "footnote_colon_whitespace_coupling_declined" -)] -#[case::context_mismatch( - &["word", "[^note]"], - "footnote_coupling_context_mismatch" -)] -fn grouping_boundary_logs_declined_footnote_coupling( - #[case] token_text: &[&str], - #[case] error_category: &str, -) { - let tokens = token_text - .iter() - .map(|token| (*token).to_string()) - .collect::>(); - let _ = determine_token_span(&tokens, 0); - assert!(logs_contain(&format!( - "error_category=\"{error_category}\"" - ))); - assert!(!logs_contain("[^note]")); -} diff --git a/src/wrap/inline/span_helpers.rs b/src/wrap/inline/span_helpers.rs index 878e71b5..27ddb178 100644 --- a/src/wrap/inline/span_helpers.rs +++ b/src/wrap/inline/span_helpers.rs @@ -8,7 +8,6 @@ //! span before `determine_token_span` performs the standard punctuation and //! link grouping pass. -use tracing::debug; use unicode_width::UnicodeWidthStr; use super::predicates::{ @@ -52,41 +51,19 @@ pub(in crate::wrap::inline) fn extend_punctuation( } /// Returns the exclusive end of a date-like token run beginning at `start`. -#[tracing::instrument(level = "trace", skip(tokens), ret)] pub(in crate::wrap::inline) fn try_match_date_sequence( tokens: &[String], start: usize, ) -> Option { if let Some(end) = match_ordinal_day_month_year(tokens, start) { - debug!( - start, - end, - pattern = "ordinal_day_month_year", - "matched date sequence" - ); Some(end) } else if let Some(end) = match_numeric_day_month_year(tokens, start) { - debug!( - start, - end, - pattern = "numeric_day_month_year", - "matched date sequence" - ); - Some(end) - } else if let Some(end) = match_month_numeric_day_year(tokens, start) { - debug!( - start, - end, - pattern = "month_numeric_day_year", - "matched date sequence" - ); Some(end) } else { - None + match_month_numeric_day_year(tokens, start) } } -#[tracing::instrument(level = "trace", skip(tokens), ret)] pub(in crate::wrap::inline) fn date_token_span( tokens: &[String], start: usize, @@ -96,9 +73,13 @@ pub(in crate::wrap::inline) fn date_token_span( .iter() .map(|token| UnicodeWidthStr::width(token.as_str())) .sum(); - if let Some((_, footnote_end)) = - try_couple_footnote_reference(tokens, date_end, SpanKind::General, &mut date_width) - { + if let Some((_, footnote_end)) = try_couple_footnote_reference( + tokens, + date_end, + SpanKind::General, + &mut date_width, + &mut None, + ) { return Some((footnote_end, date_width)); } Some((date_end, date_width)) @@ -172,7 +153,7 @@ pub(in crate::wrap::inline) fn should_couple_whitespace( } (SpanKind::Code, Some(next), _) if is_trailing_punctuation_token(next) => true, (SpanKind::General, Some(next), Some(following)) - if looks_like_footnote_ref(next) && following == ":" => + if looks_like_footnote_ref(next, &mut None) && following == ":" => { true } @@ -239,9 +220,10 @@ pub(in crate::wrap::inline) fn try_couple_footnote_reference( end: usize, kind: SpanKind, width: &mut usize, + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, ) -> Option<(SpanKind, usize)> { let token = tokens.get(end)?; - if !looks_like_footnote_ref(token) { + if !looks_like_footnote_ref(token, observer) { return None; } @@ -272,7 +254,3 @@ pub(in crate::wrap::inline) fn try_couple_footnote_reference( #[cfg(test)] #[path = "span_helper_props.rs"] mod span_helper_props; - -#[cfg(test)] -#[path = "span_helper_tracing_tests.rs"] -mod tracing_tests; diff --git a/src/wrap/inline/tests.rs b/src/wrap/inline/tests.rs index a9ee28c3..e9f71407 100644 --- a/src/wrap/inline/tests.rs +++ b/src/wrap/inline/tests.rs @@ -9,7 +9,8 @@ use rstest::rstest; use unicode_width::UnicodeWidthStr; -use super::fragment::{FragmentKind, InlineFragment, width_as_f64}; +use super::fragment::{InlineFragment, width_as_f64}; +use crate::wrap::observer::FragmentKind; #[test] fn inline_fragment_new_marks_spaces_as_whitespace() { diff --git a/src/wrap/inline/tracing_events.rs b/src/wrap/inline/tracing_events.rs deleted file mode 100644 index 224e1a1a..00000000 --- a/src/wrap/inline/tracing_events.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Structured, content-free events emitted at inline grouping boundaries. - -use tracing::debug; - -use super::{SpanKind, predicates::looks_like_footnote_ref}; - -pub(super) fn emit_whitespace_footnote_coupling( - kind: SpanKind, - next_token: Option<&String>, - following_token: Option<&String>, - coupled: bool, -) { - let Some(token) = next_token.filter(|token| looks_like_footnote_ref(token)) else { - return; - }; - let token_length = token.chars().count(); - let has_following_colon = following_token.is_some_and(|following| following == ":"); - if coupled { - debug!( - span_kind = ?kind, - token_length, - has_following_colon, - "coupled whitespace before colon-suffixed footnote reference" - ); - } else { - debug!( - span_kind = ?kind, - token_length, - has_following_colon, - error_category = "footnote_colon_whitespace_coupling_declined", - "declined whitespace coupling before footnote reference" - ); - } -} - -pub(super) fn emit_footnote_reference_coupling( - tokens: &[String], - end: usize, - kind: SpanKind, - coupled: bool, -) { - let Some(token) = tokens - .get(end) - .filter(|token| looks_like_footnote_ref(token)) - else { - return; - }; - let token_length = token.chars().count(); - let follows_space_before_colon = end - .checked_sub(1) - .and_then(|previous| tokens.get(previous)) - .is_some_and(|previous| previous.chars().all(char::is_whitespace)) - && tokens - .get(end + 1) - .is_some_and(|following| following == ":"); - if coupled && follows_space_before_colon { - debug!( - span_kind = ?kind, - token_length, - has_following_colon = true, - "coupled colon-suffixed footnote reference after whitespace" - ); - } else if !coupled { - debug!( - span_kind = ?kind, - token_length, - follows_space_before_colon, - error_category = "footnote_coupling_context_mismatch", - "declined footnote reference coupling" - ); - } -} diff --git a/src/wrap/observer.rs b/src/wrap/observer.rs new file mode 100644 index 00000000..ccd64c1e --- /dev/null +++ b/src/wrap/observer.rs @@ -0,0 +1,66 @@ +//! Domain events emitted while classifying inline Markdown. + +use std::fmt; + +/// Describes an inline fragment classification without binding it to a logger. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FragmentKind { + /// The fragment contains only whitespace. + Whitespace, + /// The fragment contains an inline code span. + InlineCode, + /// The fragment contains a Markdown link. + Link, + /// The fragment contains a GFM footnote reference. + FootnoteRef, + /// The fragment contains ordinary prose. + Plain, +} + +/// An observable outcome from inline parsing or classification. +#[derive(Debug, Clone, Copy)] +pub(crate) enum Event<'a> { + /// A complete footnote reference was parsed. + FootnoteReferenceParsed { token_length: usize }, + /// A Markdown link or image was parsed. + LinkOrImageParsed { token_length: usize, is_image: bool }, + /// A footnote label could not be completed. + FootnoteEndNotFound { start: usize, reason: &'static str }, + /// A footnote label span was recognised. + FootnoteLabelRecognized { + start: usize, + end: usize, + token_length: usize, + }, + /// A footnote-reference predicate was evaluated. + FootnoteRefChecked { token: &'a str, result: bool }, + /// A rendered fragment was classified. + FragmentClassified { + token: &'a str, + truncated: bool, + kind: FragmentKind, + }, +} + +/// Receives domain-level classification events. +pub(crate) trait Observer { + /// Records one event. + fn observe(&mut self, event: Event<'_>); +} + +/// Discards every event. +#[allow( + dead_code, + reason = "The domain boundary provides a no-op observer for direct callers." +)] +pub(crate) struct NoOpObserver; + +impl Observer for NoOpObserver { + fn observe(&mut self, _: Event<'_>) {} +} + +impl fmt::Display for FragmentKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} diff --git a/src/wrap/paragraph.rs b/src/wrap/paragraph.rs index 6a044be4..4986b8db 100644 --- a/src/wrap/paragraph.rs +++ b/src/wrap/paragraph.rs @@ -9,7 +9,11 @@ use code_span_trim::trim_code_span_edge_spaces; use tracing::trace; use unicode_width::UnicodeWidthStr; -use super::{inline::wrap_preserving_code, tokenize::parse_open_code_span}; +use super::{ + inline::wrap_preserving_code_observed, + tokenize::parse_open_code_span, + tracing_adapter::TracingObserver, +}; mod code_span_trim; mod hard_break; @@ -138,7 +142,8 @@ impl<'a> ParagraphWriter<'a> { fn wrap_with_prefix(&mut self, prefix: &str, continuation_prefix: &str, text: &str) { let prefix_width = UnicodeWidthStr::width(prefix); let available = self.width.saturating_sub(prefix_width).max(1); - let lines = wrap_preserving_code(text, available); + let mut observer = TracingObserver; + let lines = wrap_preserving_code_observed(text, available, &mut Some(&mut observer)); if lines.is_empty() { self.out.push(prefix.to_string()); return; @@ -175,7 +180,8 @@ impl<'a> ParagraphWriter<'a> { let continuation_prefix = continuation_prefix_for(prefix, line.repeat_prefix, line.outer_prefix.as_deref()); - let lines = wrap_preserving_code(line.rest, available); + let mut observer = TracingObserver; + let lines = wrap_preserving_code_observed(line.rest, available, &mut Some(&mut observer)); if lines.is_empty() { self.out.push(prefix.to_string()); return; diff --git a/src/wrap/tokenize/mod.rs b/src/wrap/tokenize/mod.rs index 818f913a..158654ba 100644 --- a/src/wrap/tokenize/mod.rs +++ b/src/wrap/tokenize/mod.rs @@ -75,7 +75,16 @@ pub enum Token<'a> { /// vec!["foo", " ", "bar", "\t", "baz", " ", "`qux`"] /// ); /// ``` +#[cfg(test)] pub(super) fn segment_inline(text: &str) -> Vec { + let mut observer = crate::wrap::observer::NoOpObserver; + segment_inline_observed(text, &mut Some(&mut observer)) +} + +pub(super) fn segment_inline_observed( + text: &str, + observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, +) -> Vec { let mut tokens = Vec::new(); let bytes = text.as_bytes(); let mut i = 0; @@ -107,7 +116,7 @@ pub(super) fn segment_inline(text: &str) -> Vec { let looks_like_image = looks_like_image_start(text, i, ch); let is_escaped = has_odd_backslash_escape_bytes(bytes, i); if (ch == '[' || looks_like_image) && !is_escaped { - let (tok, mut new_i) = parse_link_or_image(text, i); + let (tok, mut new_i) = parse_link_or_image(text, i, observer); tokens.push(tok); let punct_start = new_i; new_i = scan_trailing_punctuation_end(text, new_i); diff --git a/src/wrap/tokenize/parsing.rs b/src/wrap/tokenize/parsing.rs index 90156766..02c5922c 100644 --- a/src/wrap/tokenize/parsing.rs +++ b/src/wrap/tokenize/parsing.rs @@ -1,8 +1,7 @@ //! Higher-level inline Markdown parsing helpers, isolated from tokenizer entry points. -use tracing::{debug, trace}; - use super::scanning::{collect_range, position_after_close, scan_while}; +use crate::wrap::observer::{Event, Observer}; /// Parse a Markdown link or image starting at `i`. /// @@ -20,18 +19,20 @@ use super::scanning::{collect_range, position_after_close, scan_while}; /// assert_eq!(tok, "![alt](a(b)c)"); /// assert_eq!(idx, text.len()); /// ``` -#[tracing::instrument(level = "debug", skip(text))] -pub(super) fn parse_link_or_image(text: &str, mut idx: usize) -> (String, usize) { +pub(super) fn parse_link_or_image( + text: &str, + mut idx: usize, + observer: &mut Option<&mut dyn Observer>, +) -> (String, usize) { let start = idx; - if let Some(text_end) = find_footnote_end(text, idx) + if let Some(text_end) = find_footnote_end(text, idx, observer) && (text_end == text.len() || !text[text_end..].starts_with('(')) { - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - token_length = text[start..text_end].chars().count(), - "footnote reference parsed" - ); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::FootnoteReferenceParsed { + token_length: text[start..text_end].chars().count(), + }); } return (collect_range(text, start, text_end), text_end); } @@ -46,12 +47,11 @@ pub(super) fn parse_link_or_image(text: &str, mut idx: usize) -> (String, usize) if text_end < text.len() && text[text_end..].starts_with('(') { if let Some(url_end) = parse_link_url(text, text_end) { - if tracing::enabled!(tracing::Level::DEBUG) { - let is_image = text[start..].starts_with('!'); - debug!( - token_length = text[start..url_end].chars().count(), - is_image, "link or image parsed" - ); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::LinkOrImageParsed { + token_length: text[start..url_end].chars().count(), + is_image: text[start..].starts_with('!'), + }); } return (collect_range(text, start, url_end), url_end); } @@ -70,15 +70,17 @@ pub(super) fn parse_link_or_image(text: &str, mut idx: usize) -> (String, usize) fallback_single_char(text, start) } -#[tracing::instrument(level = "trace", skip(text), ret)] -fn find_footnote_end(text: &str, idx: usize) -> Option { +fn find_footnote_end( + text: &str, + idx: usize, + observer: &mut Option<&mut dyn Observer>, +) -> Option { if idx >= text.len() || !text[idx..].starts_with("[^") { - if tracing::enabled!(tracing::Level::TRACE) { - trace!( - start = idx, - reason = "prefix_mismatch", - "footnote end not found" - ); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::FootnoteEndNotFound { + start: idx, + reason: "prefix_mismatch", + }); } return None; } @@ -96,24 +98,22 @@ fn find_footnote_end(text: &str, idx: usize) -> Option { } if ch == ']' { - if tracing::enabled!(tracing::Level::TRACE) { - trace!( - start = idx, - end = cursor, - token_length = text[idx..cursor].chars().count(), - "footnote label span recognized" - ); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::FootnoteLabelRecognized { + start: idx, + end: cursor, + token_length: text[idx..cursor].chars().count(), + }); } return Some(cursor); } } - if tracing::enabled!(tracing::Level::TRACE) { - trace!( - start = idx, - reason = "unterminated_bracket", - "footnote end not found" - ); + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::FootnoteEndNotFound { + start: idx, + reason: "unterminated_bracket", + }); } None } diff --git a/src/wrap/tokenize/parsing_tests.rs b/src/wrap/tokenize/parsing_tests.rs index c6fc05c8..87dfcb7f 100644 --- a/src/wrap/tokenize/parsing_tests.rs +++ b/src/wrap/tokenize/parsing_tests.rs @@ -72,21 +72,21 @@ fn balanced_url_inner() -> impl Strategy { #[test] fn parse_link_or_image_handles_nested_parentheses() { let text = "![alt](path(a(b)c)) more"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "![alt](path(a(b)c))"); assert_eq!(idx, token.len()); } #[test] fn parse_link_or_image_falls_back_on_malformed_input() { let text = "[broken"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "["); assert_eq!(idx, "[".len()); } #[test] fn parse_link_or_image_handles_deeply_nested_parentheses() { let text = "[link](url(a(b(c)d)e)) tail"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "[link](url(a(b(c)d)e))"); assert_eq!(idx, token.len()); } @@ -94,7 +94,7 @@ fn parse_link_or_image_handles_deeply_nested_parentheses() { #[test] fn parse_link_or_image_handles_nested_parentheses_for_images() { let text = "![alt](path(a(b(c)d)e))"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "![alt](path(a(b(c)d)e))"); assert_eq!(idx, token.len()); } @@ -102,7 +102,7 @@ fn parse_link_or_image_handles_nested_parentheses_for_images() { #[test] fn parse_link_or_image_handles_text_ending_at_bracket() { let text = "["; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "["); assert_eq!(idx, 1); } @@ -110,7 +110,7 @@ fn parse_link_or_image_handles_text_ending_at_bracket() { #[test] fn parse_link_or_image_preserves_footnote_reference() { let text = "[^4] tail"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "[^4]"); assert_eq!(idx, token.len()); } @@ -118,7 +118,7 @@ fn parse_link_or_image_preserves_footnote_reference() { #[test] fn parse_link_or_image_preserves_footnote_reference_with_escaped_bracket() { let text = r"[^a\]b] tail"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, r"[^a\]b]"); assert_eq!(idx, token.len()); } @@ -126,7 +126,7 @@ fn parse_link_or_image_preserves_footnote_reference_with_escaped_bracket() { #[test] fn parse_link_or_image_preserves_footnote_at_end() { let text = "[^4]"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "[^4]"); assert_eq!(idx, token.len()); } @@ -134,7 +134,7 @@ fn parse_link_or_image_preserves_footnote_at_end() { #[test] fn parse_link_or_image_keeps_caret_text_links_as_links() { let text = "[^label](https://example.com) tail"; - let (token, idx) = parse_link_or_image(text, 0); + let (token, idx) = parse_link_or_image(text, 0, &mut None); assert_eq!(token, "[^label](https://example.com)"); assert_eq!(idx, token.len()); } @@ -144,7 +144,7 @@ fn parse_link_or_image_preserves_reference_style_link() { let input = "[trybuild][implicit-fixture-trybuild]"; assert_eq!( - parse_link_or_image(input, 0), + parse_link_or_image(input, 0, &mut None), (input.to_string(), input.len()) ); } @@ -159,7 +159,7 @@ proptest! { let expected_len = expected.len(); let text = format!("{expected} tail"); - let (token, idx) = parse_link_or_image(&text, 0); + let (token, idx) = parse_link_or_image(&text, 0, &mut None); prop_assert_eq!(token, expected); prop_assert_eq!(idx, expected_len); @@ -233,11 +233,13 @@ mod tracing_tests { use tracing_test::traced_test; use super::*; + use crate::wrap::tracing_adapter::TracingObserver; #[traced_test] #[test] fn parse_link_or_image_logs_footnote_reference() { - let _ = parse_link_or_image("[^4] tail", 0); + let mut observer = TracingObserver; + let _ = parse_link_or_image("[^4] tail", 0, &mut Some(&mut observer)); assert!(logs_contain("footnote reference parsed")); assert!(logs_contain("token_length=4")); assert!(!logs_contain("[^4]")); @@ -246,7 +248,8 @@ mod tracing_tests { #[traced_test] #[test] fn parse_link_or_image_logs_link_parsed() { - let _ = parse_link_or_image("[link](url)", 0); + let mut observer = TracingObserver; + let _ = parse_link_or_image("[link](url)", 0, &mut Some(&mut observer)); assert!(logs_contain("link or image parsed")); assert!(logs_contain("token_length=11")); assert!(!logs_contain("[link](url)")); @@ -256,7 +259,8 @@ mod tracing_tests { #[traced_test] #[test] fn find_footnote_end_logs_prefix_mismatch() { - let _ = find_footnote_end("no-caret", 0); + let mut observer = TracingObserver; + let _ = find_footnote_end("no-caret", 0, &mut Some(&mut observer)); assert!(logs_contain("footnote end not found")); assert!(logs_contain("reason=")); assert!(logs_contain("prefix_mismatch")); @@ -265,7 +269,8 @@ mod tracing_tests { #[traced_test] #[test] fn parse_link_or_image_logs_footnote_label_span() { - let _ = parse_link_or_image("[^4] tail", 0); + let mut observer = TracingObserver; + let _ = parse_link_or_image("[^4] tail", 0, &mut Some(&mut observer)); assert!(logs_contain("footnote label span recognized")); assert!(logs_contain("start=")); assert!(logs_contain("end=")); @@ -276,7 +281,8 @@ mod tracing_tests { #[traced_test] #[test] fn find_footnote_end_logs_unterminated_bracket() { - let _ = find_footnote_end("[^unterminated", 0); + let mut observer = TracingObserver; + let _ = find_footnote_end("[^unterminated", 0, &mut Some(&mut observer)); assert!(logs_contain("footnote end not found")); assert!(logs_contain("reason=")); assert!(logs_contain("unterminated_bracket")); diff --git a/src/wrap/tracing_adapter.rs b/src/wrap/tracing_adapter.rs new file mode 100644 index 00000000..2055d155 --- /dev/null +++ b/src/wrap/tracing_adapter.rs @@ -0,0 +1,54 @@ +//! `tracing` integration for inline-classification domain events. + +use tracing::{debug, trace}; + +use super::observer::{Event, Observer}; + +/// Translates domain events into the crate's existing `tracing` events. +pub(crate) struct TracingObserver; + +impl Observer for TracingObserver { + fn observe(&mut self, event: Event<'_>) { + match event { + Event::FootnoteReferenceParsed { token_length } + if tracing::enabled!(tracing::Level::DEBUG) => + { + debug!(token_length, "footnote reference parsed"); + } + Event::LinkOrImageParsed { + token_length, + is_image, + } if tracing::enabled!(tracing::Level::DEBUG) => { + debug!(token_length, is_image, "link or image parsed"); + } + Event::FootnoteEndNotFound { start, reason } + if tracing::enabled!(tracing::Level::TRACE) => + { + trace!(start, reason, "footnote end not found"); + } + Event::FootnoteLabelRecognized { + start, + end, + token_length, + } if tracing::enabled!(tracing::Level::TRACE) => { + trace!(start, end, token_length, "footnote label span recognized"); + } + Event::FootnoteRefChecked { token, result } + if tracing::enabled!(tracing::Level::TRACE) => + { + trace!( + token_length = token.chars().count(), + result, "footnote reference checked" + ); + } + Event::FragmentClassified { + token, + truncated, + kind, + } if tracing::enabled!(tracing::Level::DEBUG) => { + debug!(token = %token, truncated, kind = ?kind, "fragment classified"); + } + _ => {} + } + } +} From d0d574e1163a39dd706d78948034fa5c3e78fef9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 26 Jul 2026 19:29:13 +0200 Subject: [PATCH 2/4] Address observer-boundary review feedback (#309) Refine the tracing-isolation work so the inline-wrapping domain stays free of vendor-specific tracing and the observer port is documented. - Add a `pub(crate) ObserverHandle<'a>` alias and replace the repeated `&mut Option<&mut dyn Observer>` signatures across the wrap modules. - Gate `NoOpObserver` behind `#[cfg(test)]` and drop its `dead_code` allowance; only test paths construct it. - Move the observer-aware orchestration (span grouping, fragment building, line fitting) into `wrap/inline/wrapping.rs`, keeping `inline.rs` on the public surface and both files under the 400-line limit; import `FragmentKind` rather than fully qualifying it. - Thread the live observer through `date_token_span`, `should_couple_whitespace`, and footnote-ref normalization instead of discarding it via `&mut None`. - Extract the shared `TracingObserver` setup in `paragraph.rs` into a `wrap_observed` helper. - Make `Event` variants carry borrowed `&str` slices and move all derived work (`chars().count()`, snippet truncation) into `TracingObserver` behind its level gate, so a disabled subscriber pays only a branch on the hot path. - Restore date diagnostics through a new `DateSequenceMatched` event emitted from `date_token_span`. - Add traced tests for `FootnoteRefChecked` and `DateSequenceMatched`; tighten the checklist-span proptest closing-fence assertion so it no longer collides with the opening fence. - Document the observer boundary: developers-guide observability section and event/log mapping, new ADR 0006, and architecture notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0006-observer-boundary-for-tracing.md | 72 ++++ docs/architecture.md | 31 +- docs/contents.md | 3 + docs/developers-guide.md | 115 ++++-- src/wrap/inline.rs | 381 +---------------- src/wrap/inline/fragment.rs | 79 +--- src/wrap/inline/normalize.rs | 36 +- src/wrap/inline/predicates.rs | 62 ++- src/wrap/inline/span_helpers.rs | 44 +- src/wrap/inline/wrapping.rs | 388 ++++++++++++++++++ src/wrap/observer.rs | 42 +- src/wrap/paragraph.rs | 16 +- src/wrap/tokenize/mod.rs | 2 +- src/wrap/tokenize/parsing.rs | 16 +- src/wrap/tracing_adapter.rs | 126 +++++- tests/wrap_properties.rs | 6 +- 16 files changed, 877 insertions(+), 542 deletions(-) create mode 100644 docs/adrs/0006-observer-boundary-for-tracing.md create mode 100644 src/wrap/inline/wrapping.rs diff --git a/docs/adrs/0006-observer-boundary-for-tracing.md b/docs/adrs/0006-observer-boundary-for-tracing.md new file mode 100644 index 00000000..e5ba3bf9 --- /dev/null +++ b/docs/adrs/0006-observer-boundary-for-tracing.md @@ -0,0 +1,72 @@ +# Architecture Decision Record (ADR) 0006: Observer boundary for tracing + +- Status: Accepted +- Date: 2026-07-26 + +## Context + +The inline-wrapping domain logic under `src/wrap/tokenize/` and +`src/wrap/inline/` previously called `tracing` macros and +`#[tracing::instrument]` attributes directly to record classification +outcomes: fragment kinds, parsed link and footnote token lengths, footnote +label spans, and matched date sequences. This coupled parsing and +classification helpers — code whose correctness is independent of any +logging vendor — to a specific observability crate. It also pushed +`tracing::enabled!` level gates and derived-value computation (Unicode +`chars().count()` scans, bounded snippet truncation) into the same functions +that decide fragment boundaries, making it harder to unit test the domain +logic without a `tracing` subscriber and harder to change logging behaviour +without touching parsing code. + +Issue #309 asked for vendor-specific tracing to be isolated from +inline-wrapping domain logic behind an adapter boundary, consistent with the +abstraction/port/helper policy in `AGENTS.md`. + +## Decision + +For inline-wrapping domain code that emits diagnostics, an `Observer` port, +not direct `tracing` calls, is the mechanism. + +The `Observer` trait, its `Event` enum, and the `ObserverHandle<'a>` alias +(`Option<&'a mut dyn Observer>`) live in `src/wrap/observer.rs`. Domain +helpers under `src/wrap/tokenize/` and `src/wrap/inline/` accept +`&mut ObserverHandle<'_>` and call `observer.observe(event)` at the same +points where they previously called `tracing` macros; they no longer import +`tracing` or carry `#[tracing::instrument]` attributes. Every `Event` variant +carries only cheap, borrowed data — indices, flags, and `&str` slices — so +constructing an event costs no more than a few copies. + +`TracingObserver`, the crate's single `Observer` implementation, lives in +`src/wrap/tracing_adapter.rs` and translates each `Event` into the crate's +existing `tracing` records. It owns every vendor-specific concern: the +`tracing::enabled!` level gate for each event and any derived value that +costs more than a copy, such as `chars().count()` or the bounded-snippet +truncation used for `fragment classified`. A `#[cfg(test)]`-only +`NoOpObserver` in `observer.rs` discards every event for tests that need an +`ObserverHandle` without a subscriber. + +New diagnostics needs are met by adding an `Event` variant and a matching arm +in `TracingObserver::observe`, not by importing `tracing` into a domain +module or by adding a second adapter alongside `TracingObserver`. + +## Consequences + +- Domain parsing and classification helpers can be unit tested, including + with property tests, without installing a `tracing` subscriber; a test can + pass `None` or a fixture `Observer` in place of `TracingObserver`. +- All `tracing` field names, message strings, and levels for inline + classification are defined in one file, `src/wrap/tracing_adapter.rs`, + rather than scattered across `src/wrap/tokenize/` and `src/wrap/inline/`. + Changing a message or promoting an event from `trace!` to `debug!` no + longer risks touching parsing logic. +- Because `Event` variants carry only borrowed data and the adapter performs + level-gating before any derived computation, a disabled subscriber still + pays only the cost of constructing a borrowed enum value and evaluating one + `tracing::enabled!` branch per event, matching the performance discipline + already required of the `tracing` calls it replaced. +- The trade-off is one layer of indirection: reading what a domain helper + logs now requires following the `Event` variant it emits to its match arm + in `TracingObserver`, rather than reading a `tracing` macro call inline. + `parse_rows` in `src/reflow.rs`, which is outside the inline-wrapping + domain this port covers, keeps its existing `#[tracing::instrument]` + attribute and is not affected by this decision. diff --git a/docs/architecture.md b/docs/architecture.md index fb9920f8..f3f7d347 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -266,17 +266,15 @@ Text. ## Footnotes - 1. First note + [^1]: First note - 2. Second note + [^2]: Second note -10. Final note +[^10]: Final note ``` -After: - -```markdown -Text. +`convert_footnotes` only processes the final contiguous numeric list that +immediately follows an H2 heading when these conditions are met. ## Footnotes @@ -613,6 +611,25 @@ lines bypass the inline wrapping path and are emitted unchanged. The helper `html_table_to_markdown` is retained for backward compatibility but is deprecated. New code should call `convert_html_tables` instead. + +### Observer boundary for inline diagnostics + +The tokenizing and classification helpers in the wrap sequence above — +`build_fragments`, `parse_link_or_image`, `find_footnote_end`, +`looks_like_footnote_ref`, `ends_with_footnote_ref`, and `date_token_span` — +do not call `tracing` directly. They emit borrowed `Event` values, defined in +[src/wrap/observer.rs](../src/wrap/observer.rs), through a +`&mut ObserverHandle<'_>` parameter threaded alongside the fragment-building +call chain shown in the [wrap sequence](#wrap-sequence) diagram. +[src/wrap/tracing_adapter.rs](../src/wrap/tracing_adapter.rs) provides the +crate's single `Observer` implementation, `TracingObserver`, which owns the +`tracing::enabled!` level gates and any derived computation, such as Unicode +length counts and bounded-snippet truncation. This keeps the tokenizer and +fragment-classification logic free of vendor-specific logging concerns. See +[ADR 0006](adrs/0006-observer-boundary-for-tracing.md) for the rationale and +[the developer's guide](developers-guide.md#inline-classification-observer-boundary) +for the ownership and reuse policy governing this port. + ## Concurrency with `rayon` `mdtablefix` uses the `rayon` crate to process multiple files concurrently. diff --git a/docs/contents.md b/docs/contents.md index 6ed4430b..7bb95888 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -33,6 +33,9 @@ Accepted - [Ellipsis literal-region protection](adrs/0005-ellipsis-literal-regions.md): Accepted decision covering links, URLs, and filesystem-like tokens. +- [Observer boundary for tracing](adrs/0006-observer-boundary-for-tracing.md): + Accepted decision covering the `Observer` port and `TracingObserver` + adapter for inline-wrapping domain diagnostics. ## Reference material diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 97466b67..301a7d82 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -585,6 +585,41 @@ global subscriber or metrics recorder. Executables and test harnesses that want log output must install their own subscriber (e.g. `tracing_subscriber::fmt::init()` in `main`). + +### Inline classification observer boundary + +The `Observer` trait in +[src/wrap/observer.rs](../src/wrap/observer.rs) is the sole boundary between +inline-wrapping domain logic (tokenizing, span grouping, and fragment +classification under `src/wrap/tokenize/` and `src/wrap/inline/`) and any +diagnostics backend. Domain helpers never reference `tracing` directly; they +emit a domain-level `Event` through a `&mut ObserverHandle<'_>` — an alias for +`Option<&mut dyn Observer>` — threaded through the wrapping pipeline. `Event` +variants carry only cheap, borrowed data: indices, flags, and `&str` slices. +`TracingObserver` in +[src/wrap/tracing_adapter.rs](../src/wrap/tracing_adapter.rs) is the only +adapter and translates each `Event` into the crate's `tracing` records; it +owns every vendor-specific concern, including the `tracing::enabled!` level +gate and any derived value that costs more than a copy. `NoOpObserver`, also +declared in `observer.rs`, is `#[cfg(test)]`-only and discards every event. + +This is the abstraction's ownership and reuse policy, per the +abstraction/port/helper policy in `AGENTS.md`: + +- **Ownership:** the `Observer` port and the `Event`/`FragmentKind` types are + owned by `crate::wrap`. They are not exposed outside the crate. +- **Permitted call sites:** only the inline wrapping, tokenizing, and + classification domain helpers under `src/wrap/tokenize/` and + `src/wrap/inline/` may accept an `ObserverHandle` parameter and call + `observer.observe(...)`. +- **Composition rule:** when a new diagnostics need arises, add an `Event` + variant and a matching arm in `TracingObserver::observe`. Do not import + `tracing` into a domain module, and do not add a second adapter; if another + backend is ever required, it must implement `Observer` rather than + replacing `TracingObserver` inline. + +See [ADR 0006](adrs/0006-observer-boundary-for-tracing.md) for the rationale. + ### Log levels Use `debug!` for high-value classification outcomes: fragment kind, parsed @@ -624,6 +659,7 @@ Table: Structured field names emitted by tracing instrumentation. | `row_index` | `usize` | table-row events | Zero-based index of the parsed logical row | | `cell_count` | `usize` | table-row events | Number of cells in the parsed logical row | | `error_category` | `&str` | declined or discarded events | Stable category for a non-successful classification outcome | +| `result` | `bool` | `footnote reference checked` | Whether the checked token is a footnote reference | | `line_len` | `usize` | blockquote-prefix events | Byte length of the examined source line | | `prefix_len` | `usize` | blockquote-prefix events | Byte length of the recognized blockquote prefix | | `depth` | `usize` | blockquote and fence events | Current blockquote nesting depth | @@ -633,17 +669,32 @@ Table: Structured field names emitted by tracing instrumentation. | `open_marker_len` | `usize` | fence-state events | Length of the active opening fence marker | | `transition` | `&str` | fence-state events | Stable fence-state transition category | -For example: +For example, a domain helper emits a borrowed event without touching +`tracing`: + +```rust +observer.observe(Event::FragmentClassified { token, kind }); +``` + +`TracingObserver` then gates and enriches it: ```rust -debug!(token_length = token.chars().count(), kind = ?kind, "fragment classified"); +Event::FragmentClassified { token, kind } if tracing::enabled!(tracing::Level::DEBUG) => { + let (snippet, truncated) = trace_text_snippet(token); + debug!(token = %snippet, truncated, kind = ?kind, "fragment classified"); +} ``` ### Performance discipline Guard any expression that performs non-trivial work with `tracing::enabled!(Level::DEBUG)` or `tracing::enabled!(Level::TRACE)` before -computing the value. +computing the value. Domain emitters hand `TracingObserver` only cheap +borrowed data — indices, flags, and `&str` slices copied by reference — never +a pre-computed `chars().count()` or truncated snippet. The adapter performs +that derived work, such as Unicode length counts and bounded-snippet +truncation, only inside the guarded match arm, so a disabled subscriber pays +nothing beyond the initial branch. ### Security considerations @@ -654,26 +705,44 @@ table-row, or token text. ### Instrumented functions -Functions decorated with `#[tracing::instrument]` are listed below with their -level and notable fields. Update this list when adding new instrumented entry -points. - -Table: Instrumented functions and their logging levels and fields. - -| Function | Level | Fields | -| ------------------------- | ------------ | ------------------------------------------------------------------------------------------------- | -| `looks_like_footnote_ref` | trace | `skip(token)`, return value (out) | -| `ends_with_footnote_ref` | trace | `skip(token)`, return value (out) | -| `ends_with_hyphen_prefix` | trace | `skip(token)`, return value (out) | -| `is_month_name` | trace | `skip(token)`, return value (out) | -| `is_ordinal_day` | trace | `skip(token)`, return value (out) | -| `is_numeric_day` | trace | `skip(token)`, return value (out) | -| `is_year` | trace | `skip(token)`, return value (out) | -| `try_match_date_sequence` | trace, debug | `start` (in), `skip(tokens)`, return value (out); matched date pattern | -| `date_token_span` | trace | `start` (in), `skip(tokens)`, return value (out); over-width date fallback remains behaviour-only | -| `parse_link_or_image` | debug | `idx` (in), `skip(text)`; `token_length` and `is_image` events | -| `find_footnote_end` | trace | `idx` (in), `skip(text)`, return value (out) | -| `parse_rows` | trace, debug | `skip(trimmed)`; `row_index`, `cell_count`, and `error_category` events | +The inline-wrapping, tokenizing, and classification helpers under +`src/wrap/tokenize/` and `src/wrap/inline/` are no longer decorated with +`#[tracing::instrument]`; they emit `Event` values through the `Observer` +port described in +["Inline classification observer boundary"](#inline-classification-observer-boundary) +instead. `parse_rows` in [src/reflow.rs](../src/reflow.rs) is the only +function in the crate that remains `#[tracing::instrument]`. Update the table +below when adding new domain events or new instrumented entry points. + +Table: `parse_rows` instrumentation. + +| Function | Level | Fields | +| ------------ | ------------ | ----------------------------------------------------------------------- | +| `parse_rows` | trace, debug | `skip(trimmed)`; `row_index`, `cell_count`, and `error_category` events | + +Table: Domain events and their tracing output. + +| Event | tracing message | Level | Fields | +| ------------------------- | -------------------------------- | ----- | ---------------------------------------------- | +| `FootnoteReferenceParsed` | `footnote reference parsed` | debug | `token_length` | +| `LinkOrImageParsed` | `link or image parsed` | debug | `token_length`, `is_image` | +| `FootnoteEndNotFound` | `footnote end not found` | trace | `start`, `reason` | +| `FootnoteLabelRecognized` | `footnote label span recognized` | trace | `start`, `end`, `token_length` | +| `FootnoteRefChecked` | `footnote reference checked` | trace | `token_length`, `result` | +| `DateSequenceMatched` | `matched date sequence` | debug | `start`, `end` | +| `FragmentClassified` | `fragment classified` | debug | `token` (bounded snippet), `truncated`, `kind` | + +`FootnoteReferenceParsed` and `LinkOrImageParsed` are emitted from +`parse_link_or_image` and `find_footnote_end` in +`src/wrap/tokenize/parsing.rs`; `FootnoteEndNotFound` and +`FootnoteLabelRecognized` are emitted from `find_footnote_end` in the same +module. `FootnoteRefChecked` is emitted from `looks_like_footnote_ref` and +`ends_with_footnote_ref` in `src/wrap/inline/predicates.rs`. +`DateSequenceMatched` is emitted from `date_token_span` in +`src/wrap/inline/span_helpers.rs`; the over-width date fallback remains +behaviour-only and emits no event. `FragmentClassified` is emitted from +`InlineFragment::new_observed` and `classify_fragment` in +`src/wrap/inline/fragment.rs`. ## Fences module diff --git a/src/wrap/inline.rs b/src/wrap/inline.rs index 4a5a950d..862d5a8e 100644 --- a/src/wrap/inline.rs +++ b/src/wrap/inline.rs @@ -3,6 +3,10 @@ //! These functions operate on token streams so `wrap_text` can preserve //! inline code, links, and trailing punctuation without reimplementing the //! grouping logic in multiple places. +//! +//! The observer-aware orchestration that drives span grouping and line fitting +//! lives in the [`wrapping`] submodule; this module keeps the public wrapping +//! surface and the predicate re-exports shared across the inline submodules. #[cfg(test)] mod footnote_tests; @@ -16,390 +20,29 @@ mod span_helpers; mod test_support; #[cfg(test)] mod tests; +mod wrapping; -/// Returns whether `token` begins with a matched inline code fence, optionally -/// followed by a non-whitespace suffix such as an inflectional affix. -fn has_inline_code_structure(token: &str) -> bool { fragment::has_inline_code_structure(token) } - -fn is_code_token(token: &str) -> bool { - is_inline_code_token(token) || has_inline_code_structure(token) -} - -use std::ops::Range; - -use fragment::{InlineFragment, width_as_f64}; -use normalize::normalize_footnote_ref_spacing; -use postprocess::{merge_whitespace_only_lines, rebalance_atomic_tails}; -use predicates::looks_like_link; +// Shared predicate surface for the inline submodules. `fragment` and +// `normalize` reach these through `super::`, so they are re-exported here rather +// than imported directly from `predicates` in each module. pub(in crate::wrap::inline) use predicates::{ ends_with_footnote_ref, - ends_with_hyphen_prefix, fragment_is_link, is_inline_code_token, is_opening_punct, is_trailing_punct, - is_trailing_punctuation_token, is_whitespace_token, looks_like_footnote_ref, }; -use span_helpers::{ - SpanKind, - absorb_token_and_trailing_punctuation, - date_token_span, - extend_punctuation, - merge_code_span, - should_couple_whitespace, - try_couple_footnote_reference, - try_couple_inline_link_after_opener, -}; -use textwrap::wrap_algorithms::wrap_first_fit; -use unicode_width::UnicodeWidthStr; - -use super::tokenize; - -fn initial_token_span( - tokens: &[String], - start: usize, - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, -) -> (usize, usize, SpanKind) { - let mut end = start + 1; - let mut width = UnicodeWidthStr::width(tokens[start].as_str()); - let mut kind = SpanKind::General; - - // Forward-couple opening punctuation to the next atomic span so wrapping - // never leaves a lone `(` at the end of a line before inline code or a link. - if tokens[start].chars().all(is_opening_punct) - && let Some(next) = tokens.get(start + 1) - { - if is_code_token(next) { - kind = SpanKind::Code; - end += 1; - width += UnicodeWidthStr::width(next.as_str()); - end = extend_punctuation(tokens, end, &mut width); - } else if looks_like_link(next) { - kind = SpanKind::Link; - end += 1; - width += UnicodeWidthStr::width(next.as_str()); - end = extend_punctuation(tokens, end, &mut width); - } - } - - // Forward-couple a hyphen-prefix token to the next inline code span so - // wrapping never splits compounds such as `pre-`code`` at the hyphen. - if kind == SpanKind::General - && ends_with_hyphen_prefix(&tokens[start]) - && let Some(next) = tokens.get(end) - && is_code_token(next) - { - kind = SpanKind::Code; - width += UnicodeWidthStr::width(next.as_str()); - end += 1; - end = extend_punctuation(tokens, end, &mut width); - } - - if tokens[start] == "`" { - kind = SpanKind::Code; - end = merge_code_span(tokens, start, &mut width); - } else if is_code_token(&tokens[start]) { - kind = SpanKind::Code; - end = extend_punctuation(tokens, end, &mut width); - } else if looks_like_link(&tokens[start]) { - kind = SpanKind::Link; - end = extend_punctuation(tokens, end, &mut width); - } else if looks_like_footnote_ref(&tokens[start], observer) { - kind = SpanKind::FootnoteRef; - end = extend_punctuation(tokens, end, &mut width); - } - - (end, width, kind) -} - -/// Finds the next logical token group starting at `start`. -/// -/// `tokens` is the segmented inline token stream and `start` is the first -/// token in the next candidate group. The return value is `(end, width)`, -/// where `end` is the exclusive end index of the grouped inline code span, -/// link, or plain fragment, and `width` is its Unicode display width. This -/// helper assumes `start < tokens.len()` and will panic if called out of -/// bounds. -#[cfg(test)] -pub(super) fn determine_token_span(tokens: &[String], start: usize) -> (usize, usize) { - determine_token_span_observed(tokens, start, &mut None) -} - -fn determine_token_span_observed( - tokens: &[String], - start: usize, - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, -) -> (usize, usize) { - if let Some((end, width)) = date_token_span(tokens, start) { - return (end, width); - } - - let (mut end, mut width, mut kind) = initial_token_span(tokens, start, observer); - - while end < tokens.len() { - let token = &tokens[end]; - if is_whitespace_token(token) { - let next_token = tokens.get(end + 1); - let following_token = tokens.get(end + 2); - let should_couple = should_couple_whitespace(kind, next_token, following_token); - if should_couple { - width += UnicodeWidthStr::width(token.as_str()); - end += 1; - continue; - } - - break; - } - - if is_trailing_punctuation_token(token) { - if matches!( - kind, - SpanKind::Code | SpanKind::Link | SpanKind::FootnoteRef - ) { - width += UnicodeWidthStr::width(token.as_str()); - end += 1; - continue; - } - break; - } - - let is_link = looks_like_link(token); - let is_code = is_code_token(token); - if let Some((next_kind, next_end)) = - try_couple_inline_link_after_opener(tokens, end, &mut width) - { - kind = next_kind; - end = next_end; - continue; - } - - // Footnote markers must be coupled before consecutive link/code chaining; - // otherwise `[^N]` stays a separate wrap token even when punctuation is - // already attached to the preceding atomic span. - let footnote_coupling = - try_couple_footnote_reference(tokens, end, kind, &mut width, observer); - if let Some((next_kind, next_end)) = footnote_coupling { - kind = next_kind; - end = next_end; - continue; - } - - if kind == SpanKind::Link && is_link { - end = absorb_token_and_trailing_punctuation(tokens, end, &mut width); - continue; - } - - if kind == SpanKind::Code && is_code { - end = absorb_token_and_trailing_punctuation(tokens, end, &mut width); - continue; - } - - break; - } - - (end, width) -} - /// Re-exports the test-only helper that joins punctuation onto a prior code /// line when `current` is empty. #[cfg(test)] pub(super) use test_support::attach_punctuation_to_previous_line; - -/// Appends the token span into the rendered fragment buffer `text`. -/// -/// `tokens` supplies the source tokens and `span` identifies the grouped range -/// to copy. This helper mutates `text` in place and preserves the invariant -/// that punctuation after code spans keeps its original Markdown spacing. -fn push_span_text(text: &mut String, tokens: &[String], span: Range) { - for token in &tokens[span] { - if token.len() == 1 && ".?!,:;".contains(token) && text.trim_end().ends_with('`') { - text.truncate(text.trim_end_matches(char::is_whitespace).len()); - } - text.push_str(token); - } -} - -/// Builds Markdown-aware fragments from the segmented token stream `tokens`. -/// -/// The return value preserves token order while grouping inline code, links, -/// and whitespace runs into `InlineFragment` values with precomputed widths. -/// This helper never panics when `tokens` is well-formed. -fn build_fragments( - tokens: &[String], - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, -) -> Vec { - let mut fragments: Vec = Vec::new(); - let mut i = 0; - - while i < tokens.len() { - let (group_end, _group_width) = determine_token_span_observed(tokens, i, observer); - let span = i..group_end; - let text = if tokens[i..group_end] - .iter() - .all(|token| is_whitespace_token(token)) - { - tokens[span].join("") - } else { - let mut text = String::new(); - push_span_text(&mut text, tokens, span); - text - }; - fragments.push(InlineFragment::new_observed(text, observer)); - i = group_end; - } - - fragments -} - -/// Returns whether `line` contains one link fragment. -fn is_single_link_line(line: &[InlineFragment]) -> bool { - line.len() == 1 && line[0].kind == crate::wrap::observer::FragmentKind::Link -} - -/// Returns the total display width of a fragment line. -fn fragment_line_width(line: &[InlineFragment]) -> usize { - line.iter().map(|fragment| fragment.width).sum() -} - -/// Splits a link first fragment from trailing prose after a boundary wrap. -fn split_boundary_link_line( - previous_line: &[InlineFragment], - line: &[InlineFragment], - width: usize, -) -> Option<(Vec, Vec)> { - let previous_width = fragment_line_width(previous_line); - if !(previous_width == width || previous_width + 1 == width) - || !line - .first() - .is_some_and(|fragment| fragment.kind == crate::wrap::observer::FragmentKind::Link) - || !line - .get(1) - .is_some_and(|fragment| fragment.is_whitespace() || fragment.is_plain()) - { - return None; - } - - Some((vec![line[0].clone()], line[1..].to_vec())) -} - -/// Returns whether a boundary link fragment should be finalized now. -fn should_flush_boundary_link( - lines: &[String], - buffer: &[InlineFragment], - next: &InlineFragment, - width: usize, -) -> bool { - lines.last().is_some_and(|line| { - let rendered_width = UnicodeWidthStr::width(line.as_str()); - rendered_width == width || rendered_width + 1 == width - }) && is_single_link_line(buffer) - && (next.is_whitespace() || next.is_plain()) -} - -/// Renders one wrapped fragment line back into Markdown text. -/// -/// `line` supplies the fragments to render. `is_final_output_line` determines -/// whether a single trailing space may be trimmed. When -/// `strip_leading_carry_whitespace` is set, carry whitespace from the wrap -/// pipeline is removed from continuation lines only. The return value is the -/// emitted text for that line, and this helper preserves the invariant that -/// hard-break double spaces survive on the final output line. -fn render_line( - line: &[InlineFragment], - is_final_output_line: bool, - strip_leading_carry_whitespace: bool, -) -> String { - let mut text = line - .iter() - .map(|fragment| fragment.text.as_str()) - .collect::(); - - if !is_final_output_line && text.ends_with(' ') && !text.ends_with(" ") { - text.pop(); - } - - if strip_leading_carry_whitespace { - text = text.trim_start().to_string(); - } - - text -} - -/// Wraps inline Markdown `text` without splitting code spans or links. -/// -/// `text` is tokenised into `InlineFragment`s, fitted with -/// `textwrap::wrap_algorithms::wrap_first_fit`, normalized with -/// `merge_whitespace_only_lines` plus `rebalance_atomic_tails`, and then -/// rendered back into `Vec` output lines. `width` is measured in -/// Unicode display columns and must be at least one effective column after any -/// caller prefix handling. This helper never panics for valid input. +// Public wrapping entry points, defined in `wrapping` and surfaced here so +// `paragraph` and the wrap test suites keep using `inline::…` paths. +pub(super) use wrapping::wrap_preserving_code_observed; #[cfg(test)] -pub(super) fn wrap_preserving_code(text: &str, width: usize) -> Vec { - let mut observer = crate::wrap::observer::NoOpObserver; - wrap_preserving_code_observed(text, width, &mut Some(&mut observer)) -} - -pub(super) fn wrap_preserving_code_observed( - text: &str, - width: usize, - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, -) -> Vec { - let tokens = tokenize::segment_inline_observed(text, observer); - if tokens.is_empty() { - return Vec::new(); - } - - let tokens = normalize_footnote_ref_spacing(&tokens); - let fragments = build_fragments(&tokens, observer); - let mut lines = Vec::new(); - let mut buffer: Vec = Vec::new(); - - for fragment in fragments { - if should_flush_boundary_link(&lines, &buffer, &fragment, width) { - lines.push(render_line(&buffer, false, !lines.is_empty())); - buffer.clear(); - if fragment.is_whitespace() { - continue; - } - } - - buffer.push(fragment); - let wrapped = wrap_first_fit(&buffer, &[width_as_f64(width)]); - let raw_lines = wrapped.iter().map(|line| line.to_vec()).collect::>(); - let mut grouped_lines = merge_whitespace_only_lines(&raw_lines, width); - rebalance_atomic_tails(&mut grouped_lines, width); - - if grouped_lines.len() == 1 { - continue; - } - - if let Some((link_line, remaining_line)) = grouped_lines - .get(grouped_lines.len() - 2) - .zip(grouped_lines.last()) - .and_then(|(previous, line)| split_boundary_link_line(previous, line, width)) - { - for line in &grouped_lines[..grouped_lines.len() - 1] { - lines.push(render_line(line, false, !lines.is_empty())); - } - lines.push(render_line(&link_line, false, !lines.is_empty())); - buffer = remaining_line; - continue; - } - - for line in &grouped_lines[..grouped_lines.len() - 1] { - lines.push(render_line(line, false, !lines.is_empty())); - } - buffer = grouped_lines.pop().unwrap_or_default(); - } - - if !buffer.is_empty() { - lines.push(render_line(&buffer, true, !lines.is_empty())); - } - - lines -} +pub(super) use wrapping::{determine_token_span, wrap_preserving_code}; #[cfg(test)] mod date_strategies; diff --git a/src/wrap/inline/fragment.rs b/src/wrap/inline/fragment.rs index 8a305b7b..1e31aad9 100644 --- a/src/wrap/inline/fragment.rs +++ b/src/wrap/inline/fragment.rs @@ -23,7 +23,7 @@ use super::{ is_whitespace_token, looks_like_footnote_ref, }; -use crate::wrap::observer::{Event, FragmentKind, Observer}; +use crate::wrap::observer::{Event, FragmentKind, ObserverHandle}; /// Stores rendered fragment text, width, and classification for wrapping. #[derive(Debug, Clone, PartialEq, Eq)] @@ -48,14 +48,12 @@ impl InlineFragment { Self::new_observed(text, &mut Some(&mut observer)) } - pub(super) fn new_observed(text: String, observer: &mut Option<&mut dyn Observer>) -> Self { + pub(super) fn new_observed(text: String, observer: &mut ObserverHandle<'_>) -> Self { let width = UnicodeWidthStr::width(text.as_str()); let kind = classify_fragment(text.as_str(), observer); if let Some(observer) = observer.as_deref_mut() { - let (token, truncated) = trace_text_snippet(text.as_str()); observer.observe(Event::FragmentClassified { - token, - truncated, + token: text.as_str(), kind, }); } @@ -152,7 +150,7 @@ fn contains_link_with_trailing_punctuation(text: &str) -> bool { /// still recognised as links or code spans. Footnote references also recognise /// the `word.[^label]` suffix shape that the wrapper groups to avoid splitting /// sentence punctuation from the marker. -fn classify_fragment(text: &str, observer: &mut Option<&mut dyn Observer>) -> FragmentKind { +fn classify_fragment(text: &str, observer: &mut ObserverHandle<'_>) -> FragmentKind { if is_whitespace_token(text) { return FragmentKind::Whitespace; } @@ -176,28 +174,6 @@ fn classify_fragment(text: &str, observer: &mut Option<&mut dyn Observer>) -> Fr } } -/// Returns a UTF-8-safe prefix of `text` for debug logging. -/// -/// The prefix contains at most 80 bytes and never splits a multi-byte -/// character. The second tuple element is `true` when `text` was shortened. -fn trace_text_snippet(text: &str) -> (&str, bool) { - const MAX_TRACE_BYTES: usize = 80; - if text.len() <= MAX_TRACE_BYTES { - return (text, false); - } - - let mut byte_end = 0; - for (idx, ch) in text.char_indices() { - let next_end = idx + ch.len_utf8(); - if next_end > MAX_TRACE_BYTES { - break; - } - byte_end = next_end; - } - - (&text[..byte_end], true) -} - #[cfg(test)] mod tests { //! Unit tests for inline-fragment classification. @@ -298,27 +274,6 @@ mod tests { } } -#[cfg(test)] -mod trace_snippet_tests { - //! Tests for the `trace_text_snippet` helper. - //! - //! Verifies that the UTF-8-safe truncation helper produces a slice at a - //! valid character boundary and sets the truncation flag correctly. - - use super::trace_text_snippet; - - #[test] - fn trace_text_snippet_truncates_on_char_boundary() { - let ascii = "a".repeat(79); - let text = format!("{ascii}étail"); - let (snippet, truncated) = trace_text_snippet(&text); - - assert!(truncated); - assert_eq!(snippet, ascii.as_str()); - assert!(snippet.is_char_boundary(snippet.len())); - } -} - #[cfg(test)] mod tracing_tests { //! Traced-event tests for `InlineFragment` classification. @@ -356,29 +311,3 @@ mod tracing_tests { assert_eq!(fragment.kind, FragmentKind::FootnoteRef); } } - -#[cfg(test)] -mod proptests { - //! Property tests for `trace_text_snippet` invariants. - //! - //! Verifies on arbitrary Unicode input that the helper never panics, the - //! result is a valid UTF-8 slice of at most 80 bytes, and the truncation - //! flag accurately reflects whether the input exceeded that limit. - - use proptest::prelude::*; - - use super::trace_text_snippet; - - proptest! { - #[test] - fn trace_text_snippet_never_panics(s in "\\PC*") { - let (snippet, truncated) = trace_text_snippet(&s); - // Invariant 1: result is always valid UTF-8 at a char boundary. - assert!(snippet.is_char_boundary(snippet.len())); - // Invariant 2: result never exceeds 80 bytes. - assert!(snippet.len() <= 80); - // Invariant 3: truncation flag is accurate. - assert_eq!(truncated, s.len() > 80); - } - } -} diff --git a/src/wrap/inline/normalize.rs b/src/wrap/inline/normalize.rs index 639f96c5..df49e820 100644 --- a/src/wrap/inline/normalize.rs +++ b/src/wrap/inline/normalize.rs @@ -7,17 +7,19 @@ use std::borrow::Cow; use super::{is_trailing_punct, looks_like_footnote_ref}; +use crate::wrap::observer::ObserverHandle; /// Removes whitespace between trailing punctuation and an inline footnote ref. /// /// This keeps sentence punctuation and an immediately following GFM footnote /// reference as a single semantic unit before span building decides wrap /// boundaries. -pub(in crate::wrap::inline) fn normalize_footnote_ref_spacing( - tokens: &[String], -) -> Cow<'_, [String]> { +pub(in crate::wrap::inline) fn normalize_footnote_ref_spacing<'a>( + tokens: &'a [String], + observer: &mut ObserverHandle<'_>, +) -> Cow<'a, [String]> { let Some(first_match) = - (0..tokens.len()).find(|index| matches_footnote_ref_spacing(tokens, *index)) + (0..tokens.len()).find(|index| matches_footnote_ref_spacing(tokens, *index, observer)) else { return Cow::Borrowed(tokens); }; @@ -27,7 +29,7 @@ pub(in crate::wrap::inline) fn normalize_footnote_ref_spacing( let mut index = first_match; while index < tokens.len() { - if matches_footnote_ref_spacing(tokens, index) { + if matches_footnote_ref_spacing(tokens, index, observer) { normalized.push(tokens[index].clone()); normalized.push(tokens[index + 2].clone()); index += 3; @@ -40,12 +42,16 @@ pub(in crate::wrap::inline) fn normalize_footnote_ref_spacing( Cow::Owned(normalized) } -fn matches_footnote_ref_spacing(tokens: &[String], index: usize) -> bool { +fn matches_footnote_ref_spacing( + tokens: &[String], + index: usize, + observer: &mut ObserverHandle<'_>, +) -> bool { tokens.get(index..index + 3).is_some_and(|window| { - !looks_like_footnote_ref(&window[0], &mut None) + !looks_like_footnote_ref(&window[0], observer) && window[0].chars().last().is_some_and(is_trailing_punct) && window[1].chars().all(char::is_whitespace) - && looks_like_footnote_ref(&window[2], &mut None) + && looks_like_footnote_ref(&window[2], observer) }) } @@ -158,7 +164,7 @@ mod tests { #[case::adjacent_references(&["a.", " ", "[^0]", " ", "[^_]"], &["a.", "[^0]", " ", "[^_]"])] fn normalizes_inline_footnote_ref_spacing(#[case] input: &[&str], #[case] expected: &[&str]) { assert_eq!( - normalize_footnote_ref_spacing(&strings(input)).as_ref(), + normalize_footnote_ref_spacing(&strings(input), &mut None).as_ref(), strings(expected) ); } @@ -166,7 +172,7 @@ mod tests { proptest! { #[test] fn normalizing_preserves_non_whitespace_tokens(tokens in token_stream_strategy()) { - let normalized = normalize_footnote_ref_spacing(&tokens); + let normalized = normalize_footnote_ref_spacing(&tokens, &mut None); let input_non_whitespace = tokens .iter() .filter(|token| !token.chars().all(char::is_whitespace)) @@ -181,7 +187,7 @@ mod tests { #[test] fn normalizing_removes_only_matched_spacing_tokens(tokens in token_stream_strategy()) { - let normalized = normalize_footnote_ref_spacing(&tokens); + let normalized = normalize_footnote_ref_spacing(&tokens, &mut None); prop_assert_eq!( normalized.len() + removed_spacing_count(&tokens), @@ -191,8 +197,8 @@ mod tests { #[test] fn normalizing_is_idempotent(tokens in token_stream_strategy()) { - let normalized = normalize_footnote_ref_spacing(&tokens); - let renormalized = normalize_footnote_ref_spacing(&normalized); + let normalized = normalize_footnote_ref_spacing(&tokens, &mut None); + let renormalized = normalize_footnote_ref_spacing(&normalized, &mut None); prop_assert_eq!( renormalized.as_ref(), @@ -212,7 +218,7 @@ mod tests { tokens.extend([punctuated.clone(), whitespace, reference.clone()]); tokens.extend(suffix); - let normalized = normalize_footnote_ref_spacing(&tokens); + let normalized = normalize_footnote_ref_spacing(&tokens, &mut None); prop_assert!( normalized @@ -234,7 +240,7 @@ mod tests { tokens.extend([punctuated.clone(), whitespace.clone(), definition.clone()]); tokens.extend(suffix); - let normalized = normalize_footnote_ref_spacing(&tokens); + let normalized = normalize_footnote_ref_spacing(&tokens, &mut None); prop_assert!( normalized diff --git a/src/wrap/inline/predicates.rs b/src/wrap/inline/predicates.rs index e37ca0eb..7bdbcaa8 100644 --- a/src/wrap/inline/predicates.rs +++ b/src/wrap/inline/predicates.rs @@ -9,7 +9,7 @@ //! tokens, and four-digit year tokens before wrapping. pub(crate) use super::month_names::MONTH_NAMES; -use crate::wrap::observer::{Event, Observer}; +use crate::wrap::observer::{Event, ObserverHandle}; pub(in crate::wrap::inline) fn is_opening_punct(c: char) -> bool { matches!(c, '(' | '[' | '"') || "“‘([【《「『".contains(c) @@ -108,7 +108,7 @@ pub(in crate::wrap::inline) fn looks_like_link(token: &str) -> bool { /// Returns whether `token` looks like a complete GFM footnote reference. pub(in crate::wrap::inline) fn looks_like_footnote_ref( token: &str, - observer: &mut Option<&mut dyn Observer>, + observer: &mut ObserverHandle<'_>, ) -> bool { let result = token .strip_prefix("[^") @@ -123,7 +123,7 @@ pub(in crate::wrap::inline) fn looks_like_footnote_ref( /// Returns whether `token` ends with an inline footnote reference. pub(in crate::wrap::inline) fn ends_with_footnote_ref( token: &str, - observer: &mut Option<&mut dyn Observer>, + observer: &mut ObserverHandle<'_>, ) -> bool { let Some(start) = token.rfind("[^") else { return false; @@ -208,6 +208,62 @@ pub(in crate::wrap::inline) fn fragment_is_link(text: &str) -> bool { #[path = "predicate_date_props.rs"] mod predicate_date_props; +#[cfg(test)] +mod tracing_tests { + //! Traced-event tests for the footnote-reference predicates. + //! + //! Verify that `looks_like_footnote_ref` and `ends_with_footnote_ref` emit + //! the TRACE `footnote reference checked` event through the tracing adapter, + //! including its `token_length` and `result` fields, and that the raw token + //! text never reaches the log. + + use tracing_test::traced_test; + + use super::{ends_with_footnote_ref, looks_like_footnote_ref}; + use crate::wrap::tracing_adapter::TracingObserver; + + #[traced_test] + #[test] + fn looks_like_footnote_ref_logs_positive_check() { + let mut observer = TracingObserver; + assert!(looks_like_footnote_ref("[^note]", &mut Some(&mut observer))); + assert!(logs_contain("footnote reference checked")); + assert!(logs_contain("token_length=7")); + assert!(logs_contain("result=true")); + assert!(!logs_contain("[^note]")); + } + + #[traced_test] + #[test] + fn looks_like_footnote_ref_logs_negative_check() { + let mut observer = TracingObserver; + assert!(!looks_like_footnote_ref("plain", &mut Some(&mut observer))); + assert!(logs_contain("footnote reference checked")); + assert!(logs_contain("token_length=5")); + assert!(logs_contain("result=false")); + } + + #[traced_test] + #[test] + fn ends_with_footnote_ref_logs_positive_check() { + let mut observer = TracingObserver; + assert!(ends_with_footnote_ref( + "word.[^1]", + &mut Some(&mut observer) + )); + assert!(logs_contain("footnote reference checked")); + assert!(logs_contain("token_length=4")); + assert!(logs_contain("result=true")); + assert!(!logs_contain("[^1]")); + } + + #[test] + fn footnote_ref_check_does_not_require_subscriber() { + assert!(looks_like_footnote_ref("[^1]", &mut None)); + assert!(!looks_like_footnote_ref("plain", &mut None)); + } +} + #[cfg(test)] mod tests { //! Unit tests for inline-token predicates. diff --git a/src/wrap/inline/span_helpers.rs b/src/wrap/inline/span_helpers.rs index 27ddb178..524d6645 100644 --- a/src/wrap/inline/span_helpers.rs +++ b/src/wrap/inline/span_helpers.rs @@ -23,6 +23,7 @@ use super::predicates::{ looks_like_footnote_ref, looks_like_link, }; +use crate::wrap::observer::{Event, ObserverHandle}; /// Marks how a grouped token span should behave during wrapping. #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -67,8 +68,15 @@ pub(in crate::wrap::inline) fn try_match_date_sequence( pub(in crate::wrap::inline) fn date_token_span( tokens: &[String], start: usize, + observer: &mut ObserverHandle<'_>, ) -> Option<(usize, usize)> { let date_end = try_match_date_sequence(tokens, start)?; + if let Some(observer) = observer.as_deref_mut() { + observer.observe(Event::DateSequenceMatched { + start, + end: date_end, + }); + } let mut date_width = tokens[start..date_end] .iter() .map(|token| UnicodeWidthStr::width(token.as_str())) @@ -78,7 +86,7 @@ pub(in crate::wrap::inline) fn date_token_span( date_end, SpanKind::General, &mut date_width, - &mut None, + observer, ) { return Some((footnote_end, date_width)); } @@ -142,6 +150,7 @@ pub(in crate::wrap::inline) fn should_couple_whitespace( kind: SpanKind, next_token: Option<&String>, following_token: Option<&String>, + observer: &mut ObserverHandle<'_>, ) -> bool { match (kind, next_token, following_token) { (SpanKind::Link, Some(next), _) @@ -153,7 +162,7 @@ pub(in crate::wrap::inline) fn should_couple_whitespace( } (SpanKind::Code, Some(next), _) if is_trailing_punctuation_token(next) => true, (SpanKind::General, Some(next), Some(following)) - if looks_like_footnote_ref(next, &mut None) && following == ":" => + if looks_like_footnote_ref(next, observer) && following == ":" => { true } @@ -220,7 +229,7 @@ pub(in crate::wrap::inline) fn try_couple_footnote_reference( end: usize, kind: SpanKind, width: &mut usize, - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, + observer: &mut ObserverHandle<'_>, ) -> Option<(SpanKind, usize)> { let token = tokens.get(end)?; if !looks_like_footnote_ref(token, observer) { @@ -254,3 +263,32 @@ pub(in crate::wrap::inline) fn try_couple_footnote_reference( #[cfg(test)] #[path = "span_helper_props.rs"] mod span_helper_props; + +#[cfg(test)] +mod tracing_tests { + //! Traced-event test for date-sequence grouping. + //! + //! Verifies that `date_token_span` emits the DEBUG `matched date sequence` + //! event with its `start` and `end` fields through the tracing adapter. + + use tracing_test::traced_test; + + use super::date_token_span; + use crate::wrap::tracing_adapter::TracingObserver; + + #[traced_test] + #[test] + fn date_token_span_logs_matched_sequence() { + let tokens: Vec = ["1st", " ", "January", " ", "2020"] + .iter() + .map(ToString::to_string) + .collect(); + let mut observer = TracingObserver; + let span = date_token_span(&tokens, 0, &mut Some(&mut observer)); + + assert_eq!(span.map(|(end, _)| end), Some(5)); + assert!(logs_contain("matched date sequence")); + assert!(logs_contain("start=0")); + assert!(logs_contain("end=5")); + } +} diff --git a/src/wrap/inline/wrapping.rs b/src/wrap/inline/wrapping.rs new file mode 100644 index 00000000..74f2035a --- /dev/null +++ b/src/wrap/inline/wrapping.rs @@ -0,0 +1,388 @@ +//! Observer-aware inline wrapping orchestration. +//! +//! This submodule owns the span-grouping and line-fitting pipeline that turns a +//! segmented inline token stream into wrapped Markdown lines while keeping code +//! spans, links, and footnote references atomic. It is separated from +//! `inline.rs` so that module can stay focused on the public wrapping surface +//! and remain within the repository's 400-line file limit. + +use std::ops::Range; + +use textwrap::wrap_algorithms::wrap_first_fit; +use unicode_width::UnicodeWidthStr; + +use super::{ + fragment::{InlineFragment, width_as_f64}, + normalize::normalize_footnote_ref_spacing, + postprocess::{merge_whitespace_only_lines, rebalance_atomic_tails}, + predicates::{ + ends_with_hyphen_prefix, + is_inline_code_token, + is_opening_punct, + is_trailing_punctuation_token, + is_whitespace_token, + looks_like_footnote_ref, + looks_like_link, + }, + span_helpers::{ + SpanKind, + absorb_token_and_trailing_punctuation, + date_token_span, + extend_punctuation, + merge_code_span, + should_couple_whitespace, + try_couple_footnote_reference, + try_couple_inline_link_after_opener, + }, +}; +use crate::wrap::{ + observer::{FragmentKind, ObserverHandle}, + tokenize, +}; + +/// Returns whether `token` begins with a matched inline code fence, optionally +/// followed by a non-whitespace suffix such as an inflectional affix. +fn has_inline_code_structure(token: &str) -> bool { + super::fragment::has_inline_code_structure(token) +} + +fn is_code_token(token: &str) -> bool { + is_inline_code_token(token) || has_inline_code_structure(token) +} + +fn initial_token_span( + tokens: &[String], + start: usize, + observer: &mut ObserverHandle<'_>, +) -> (usize, usize, SpanKind) { + let mut end = start + 1; + let mut width = UnicodeWidthStr::width(tokens[start].as_str()); + let mut kind = SpanKind::General; + + // Forward-couple opening punctuation to the next atomic span so wrapping + // never leaves a lone `(` at the end of a line before inline code or a link. + if tokens[start].chars().all(is_opening_punct) + && let Some(next) = tokens.get(start + 1) + { + if is_code_token(next) { + kind = SpanKind::Code; + end += 1; + width += UnicodeWidthStr::width(next.as_str()); + end = extend_punctuation(tokens, end, &mut width); + } else if looks_like_link(next) { + kind = SpanKind::Link; + end += 1; + width += UnicodeWidthStr::width(next.as_str()); + end = extend_punctuation(tokens, end, &mut width); + } + } + + // Forward-couple a hyphen-prefix token to the next inline code span so + // wrapping never splits compounds such as `pre-`code`` at the hyphen. + if kind == SpanKind::General + && ends_with_hyphen_prefix(&tokens[start]) + && let Some(next) = tokens.get(end) + && is_code_token(next) + { + kind = SpanKind::Code; + width += UnicodeWidthStr::width(next.as_str()); + end += 1; + end = extend_punctuation(tokens, end, &mut width); + } + + if tokens[start] == "`" { + kind = SpanKind::Code; + end = merge_code_span(tokens, start, &mut width); + } else if is_code_token(&tokens[start]) { + kind = SpanKind::Code; + end = extend_punctuation(tokens, end, &mut width); + } else if looks_like_link(&tokens[start]) { + kind = SpanKind::Link; + end = extend_punctuation(tokens, end, &mut width); + } else if looks_like_footnote_ref(&tokens[start], observer) { + kind = SpanKind::FootnoteRef; + end = extend_punctuation(tokens, end, &mut width); + } + + (end, width, kind) +} + +/// Finds the next logical token group starting at `start`. +/// +/// `tokens` is the segmented inline token stream and `start` is the first +/// token in the next candidate group. The return value is `(end, width)`, +/// where `end` is the exclusive end index of the grouped inline code span, +/// link, or plain fragment, and `width` is its Unicode display width. This +/// helper assumes `start < tokens.len()` and will panic if called out of +/// bounds. +#[cfg(test)] +pub(in crate::wrap) fn determine_token_span(tokens: &[String], start: usize) -> (usize, usize) { + determine_token_span_observed(tokens, start, &mut None) +} + +fn determine_token_span_observed( + tokens: &[String], + start: usize, + observer: &mut ObserverHandle<'_>, +) -> (usize, usize) { + if let Some((end, width)) = date_token_span(tokens, start, observer) { + return (end, width); + } + + let (mut end, mut width, mut kind) = initial_token_span(tokens, start, observer); + + while end < tokens.len() { + let token = &tokens[end]; + if is_whitespace_token(token) { + let next_token = tokens.get(end + 1); + let following_token = tokens.get(end + 2); + let should_couple = + should_couple_whitespace(kind, next_token, following_token, observer); + if should_couple { + width += UnicodeWidthStr::width(token.as_str()); + end += 1; + continue; + } + + break; + } + + if is_trailing_punctuation_token(token) { + if matches!( + kind, + SpanKind::Code | SpanKind::Link | SpanKind::FootnoteRef + ) { + width += UnicodeWidthStr::width(token.as_str()); + end += 1; + continue; + } + break; + } + + let is_link = looks_like_link(token); + let is_code = is_code_token(token); + if let Some((next_kind, next_end)) = + try_couple_inline_link_after_opener(tokens, end, &mut width) + { + kind = next_kind; + end = next_end; + continue; + } + + // Footnote markers must be coupled before consecutive link/code chaining; + // otherwise `[^N]` stays a separate wrap token even when punctuation is + // already attached to the preceding atomic span. + let footnote_coupling = + try_couple_footnote_reference(tokens, end, kind, &mut width, observer); + if let Some((next_kind, next_end)) = footnote_coupling { + kind = next_kind; + end = next_end; + continue; + } + + if kind == SpanKind::Link && is_link { + end = absorb_token_and_trailing_punctuation(tokens, end, &mut width); + continue; + } + + if kind == SpanKind::Code && is_code { + end = absorb_token_and_trailing_punctuation(tokens, end, &mut width); + continue; + } + + break; + } + + (end, width) +} + +/// Appends the token span into the rendered fragment buffer `text`. +/// +/// `tokens` supplies the source tokens and `span` identifies the grouped range +/// to copy. This helper mutates `text` in place and preserves the invariant +/// that punctuation after code spans keeps its original Markdown spacing. +fn push_span_text(text: &mut String, tokens: &[String], span: Range) { + for token in &tokens[span] { + if token.len() == 1 && ".?!,:;".contains(token) && text.trim_end().ends_with('`') { + text.truncate(text.trim_end_matches(char::is_whitespace).len()); + } + text.push_str(token); + } +} + +/// Builds Markdown-aware fragments from the segmented token stream `tokens`. +/// +/// The return value preserves token order while grouping inline code, links, +/// and whitespace runs into `InlineFragment` values with precomputed widths. +/// This helper never panics when `tokens` is well-formed. +fn build_fragments(tokens: &[String], observer: &mut ObserverHandle<'_>) -> Vec { + let mut fragments: Vec = Vec::new(); + let mut i = 0; + + while i < tokens.len() { + let (group_end, _group_width) = determine_token_span_observed(tokens, i, observer); + let span = i..group_end; + let text = if tokens[i..group_end] + .iter() + .all(|token| is_whitespace_token(token)) + { + tokens[span].join("") + } else { + let mut text = String::new(); + push_span_text(&mut text, tokens, span); + text + }; + fragments.push(InlineFragment::new_observed(text, observer)); + i = group_end; + } + + fragments +} + +/// Returns whether `line` contains one link fragment. +fn is_single_link_line(line: &[InlineFragment]) -> bool { + line.len() == 1 && line[0].kind == FragmentKind::Link +} + +/// Returns the total display width of a fragment line. +fn fragment_line_width(line: &[InlineFragment]) -> usize { + line.iter().map(|fragment| fragment.width).sum() +} + +/// Splits a link first fragment from trailing prose after a boundary wrap. +fn split_boundary_link_line( + previous_line: &[InlineFragment], + line: &[InlineFragment], + width: usize, +) -> Option<(Vec, Vec)> { + let previous_width = fragment_line_width(previous_line); + if !(previous_width == width || previous_width + 1 == width) + || !line + .first() + .is_some_and(|fragment| fragment.kind == FragmentKind::Link) + || !line + .get(1) + .is_some_and(|fragment| fragment.is_whitespace() || fragment.is_plain()) + { + return None; + } + + Some((vec![line[0].clone()], line[1..].to_vec())) +} + +/// Returns whether a boundary link fragment should be finalized now. +fn should_flush_boundary_link( + lines: &[String], + buffer: &[InlineFragment], + next: &InlineFragment, + width: usize, +) -> bool { + lines.last().is_some_and(|line| { + let rendered_width = UnicodeWidthStr::width(line.as_str()); + rendered_width == width || rendered_width + 1 == width + }) && is_single_link_line(buffer) + && (next.is_whitespace() || next.is_plain()) +} + +/// Renders one wrapped fragment line back into Markdown text. +/// +/// `line` supplies the fragments to render. `is_final_output_line` determines +/// whether a single trailing space may be trimmed. When +/// `strip_leading_carry_whitespace` is set, carry whitespace from the wrap +/// pipeline is removed from continuation lines only. The return value is the +/// emitted text for that line, and this helper preserves the invariant that +/// hard-break double spaces survive on the final output line. +fn render_line( + line: &[InlineFragment], + is_final_output_line: bool, + strip_leading_carry_whitespace: bool, +) -> String { + let mut text = line + .iter() + .map(|fragment| fragment.text.as_str()) + .collect::(); + + if !is_final_output_line && text.ends_with(' ') && !text.ends_with(" ") { + text.pop(); + } + + if strip_leading_carry_whitespace { + text = text.trim_start().to_string(); + } + + text +} + +/// Wraps inline Markdown `text` without splitting code spans or links. +/// +/// `text` is tokenised into `InlineFragment`s, fitted with +/// `textwrap::wrap_algorithms::wrap_first_fit`, normalized with +/// `merge_whitespace_only_lines` plus `rebalance_atomic_tails`, and then +/// rendered back into `Vec` output lines. `width` is measured in +/// Unicode display columns and must be at least one effective column after any +/// caller prefix handling. This helper never panics for valid input. +#[cfg(test)] +pub(in crate::wrap) fn wrap_preserving_code(text: &str, width: usize) -> Vec { + let mut observer = crate::wrap::observer::NoOpObserver; + wrap_preserving_code_observed(text, width, &mut Some(&mut observer)) +} + +pub(in crate::wrap) fn wrap_preserving_code_observed( + text: &str, + width: usize, + observer: &mut ObserverHandle<'_>, +) -> Vec { + let tokens = tokenize::segment_inline_observed(text, observer); + if tokens.is_empty() { + return Vec::new(); + } + + let tokens = normalize_footnote_ref_spacing(&tokens, observer); + let fragments = build_fragments(&tokens, observer); + let mut lines = Vec::new(); + let mut buffer: Vec = Vec::new(); + + for fragment in fragments { + if should_flush_boundary_link(&lines, &buffer, &fragment, width) { + lines.push(render_line(&buffer, false, !lines.is_empty())); + buffer.clear(); + if fragment.is_whitespace() { + continue; + } + } + + buffer.push(fragment); + let wrapped = wrap_first_fit(&buffer, &[width_as_f64(width)]); + let raw_lines = wrapped.iter().map(|line| line.to_vec()).collect::>(); + let mut grouped_lines = merge_whitespace_only_lines(&raw_lines, width); + rebalance_atomic_tails(&mut grouped_lines, width); + + if grouped_lines.len() == 1 { + continue; + } + + if let Some((link_line, remaining_line)) = grouped_lines + .get(grouped_lines.len() - 2) + .zip(grouped_lines.last()) + .and_then(|(previous, line)| split_boundary_link_line(previous, line, width)) + { + for line in &grouped_lines[..grouped_lines.len() - 1] { + lines.push(render_line(line, false, !lines.is_empty())); + } + lines.push(render_line(&link_line, false, !lines.is_empty())); + buffer = remaining_line; + continue; + } + + for line in &grouped_lines[..grouped_lines.len() - 1] { + lines.push(render_line(line, false, !lines.is_empty())); + } + buffer = grouped_lines.pop().unwrap_or_default(); + } + + if !buffer.is_empty() { + lines.push(render_line(&buffer, true, !lines.is_empty())); + } + + lines +} diff --git a/src/wrap/observer.rs b/src/wrap/observer.rs index ccd64c1e..3542afdf 100644 --- a/src/wrap/observer.rs +++ b/src/wrap/observer.rs @@ -18,43 +18,61 @@ pub(crate) enum FragmentKind { } /// An observable outcome from inline parsing or classification. +/// +/// Every variant carries only cheap, borrowed data (indices, flags, and string +/// slices). Any derived value that costs more than a copy — a Unicode scan such +/// as `chars().count()`, or a truncated snippet — is deliberately left for the +/// [`Observer`] to compute so a disabled observer pays nothing. Emitters must +/// not pre-compute those values before handing an event to an observer. #[derive(Debug, Clone, Copy)] pub(crate) enum Event<'a> { /// A complete footnote reference was parsed. - FootnoteReferenceParsed { token_length: usize }, + FootnoteReferenceParsed { token: &'a str }, /// A Markdown link or image was parsed. - LinkOrImageParsed { token_length: usize, is_image: bool }, + LinkOrImageParsed { token: &'a str, is_image: bool }, /// A footnote label could not be completed. FootnoteEndNotFound { start: usize, reason: &'static str }, /// A footnote label span was recognised. FootnoteLabelRecognized { start: usize, end: usize, - token_length: usize, + token: &'a str, }, /// A footnote-reference predicate was evaluated. FootnoteRefChecked { token: &'a str, result: bool }, + /// A contiguous day–month–year run was grouped into one atomic span. + DateSequenceMatched { start: usize, end: usize }, /// A rendered fragment was classified. - FragmentClassified { - token: &'a str, - truncated: bool, - kind: FragmentKind, - }, + FragmentClassified { token: &'a str, kind: FragmentKind }, } /// Receives domain-level classification events. +/// +/// The `Observer` port is the sole boundary between inline-wrapping domain +/// logic and any diagnostics backend. Domain modules emit [`Event`] values and +/// never reference a logging vendor directly; an adapter such as +/// `TracingObserver` translates events into concrete log records. See +/// `docs/developers-guide.md` ("Inline classification observer boundary") for +/// the ownership and reuse policy governing this port. pub(crate) trait Observer { /// Records one event. fn observe(&mut self, event: Event<'_>); } +/// A borrowed, optional handle to an [`Observer`] threaded through the inline +/// wrapping pipeline. +/// +/// Passing `&mut ObserverHandle<'_>` lets a caller opt out of observation with +/// `None` while keeping a single mutable observer borrow flowing through the +/// classification helpers without repeating the verbose option-of-trait-object +/// type at every signature. +pub(crate) type ObserverHandle<'a> = Option<&'a mut dyn Observer>; + /// Discards every event. -#[allow( - dead_code, - reason = "The domain boundary provides a no-op observer for direct callers." -)] +#[cfg(test)] pub(crate) struct NoOpObserver; +#[cfg(test)] impl Observer for NoOpObserver { fn observe(&mut self, _: Event<'_>) {} } diff --git a/src/wrap/paragraph.rs b/src/wrap/paragraph.rs index 4986b8db..bd8cb4fc 100644 --- a/src/wrap/paragraph.rs +++ b/src/wrap/paragraph.rs @@ -33,6 +33,16 @@ pub(super) use pending::{ #[path = "paragraph_tests.rs"] mod tests; +/// Wraps `text` at `available` columns, translating inline-classification +/// events through a `TracingObserver`. +/// +/// This centralises the observer wiring shared by the prefixed-wrapping helpers +/// so the `TracingObserver` construction and `wrap_preserving_code_observed` +/// call live in exactly one place. +fn wrap_observed(text: &str, available: usize) -> Vec { + let mut observer = TracingObserver; + wrap_preserving_code_observed(text, available, &mut Some(&mut observer)) +} /// Carries the parsed prefix metadata for a line that should be wrapped. pub(super) struct PrefixLine<'a> { /// Stores the literal prefix emitted on the first wrapped line. @@ -142,8 +152,7 @@ impl<'a> ParagraphWriter<'a> { fn wrap_with_prefix(&mut self, prefix: &str, continuation_prefix: &str, text: &str) { let prefix_width = UnicodeWidthStr::width(prefix); let available = self.width.saturating_sub(prefix_width).max(1); - let mut observer = TracingObserver; - let lines = wrap_preserving_code_observed(text, available, &mut Some(&mut observer)); + let lines = wrap_observed(text, available); if lines.is_empty() { self.out.push(prefix.to_string()); return; @@ -180,8 +189,7 @@ impl<'a> ParagraphWriter<'a> { let continuation_prefix = continuation_prefix_for(prefix, line.repeat_prefix, line.outer_prefix.as_deref()); - let mut observer = TracingObserver; - let lines = wrap_preserving_code_observed(line.rest, available, &mut Some(&mut observer)); + let lines = wrap_observed(line.rest, available); if lines.is_empty() { self.out.push(prefix.to_string()); return; diff --git a/src/wrap/tokenize/mod.rs b/src/wrap/tokenize/mod.rs index 158654ba..204e16f8 100644 --- a/src/wrap/tokenize/mod.rs +++ b/src/wrap/tokenize/mod.rs @@ -83,7 +83,7 @@ pub(super) fn segment_inline(text: &str) -> Vec { pub(super) fn segment_inline_observed( text: &str, - observer: &mut Option<&mut dyn crate::wrap::observer::Observer>, + observer: &mut crate::wrap::observer::ObserverHandle<'_>, ) -> Vec { let mut tokens = Vec::new(); let bytes = text.as_bytes(); diff --git a/src/wrap/tokenize/parsing.rs b/src/wrap/tokenize/parsing.rs index 02c5922c..fe031655 100644 --- a/src/wrap/tokenize/parsing.rs +++ b/src/wrap/tokenize/parsing.rs @@ -1,7 +1,7 @@ //! Higher-level inline Markdown parsing helpers, isolated from tokenizer entry points. use super::scanning::{collect_range, position_after_close, scan_while}; -use crate::wrap::observer::{Event, Observer}; +use crate::wrap::observer::{Event, ObserverHandle}; /// Parse a Markdown link or image starting at `i`. /// @@ -22,7 +22,7 @@ use crate::wrap::observer::{Event, Observer}; pub(super) fn parse_link_or_image( text: &str, mut idx: usize, - observer: &mut Option<&mut dyn Observer>, + observer: &mut ObserverHandle<'_>, ) -> (String, usize) { let start = idx; @@ -31,7 +31,7 @@ pub(super) fn parse_link_or_image( { if let Some(observer) = observer.as_deref_mut() { observer.observe(Event::FootnoteReferenceParsed { - token_length: text[start..text_end].chars().count(), + token: &text[start..text_end], }); } return (collect_range(text, start, text_end), text_end); @@ -49,7 +49,7 @@ pub(super) fn parse_link_or_image( if let Some(url_end) = parse_link_url(text, text_end) { if let Some(observer) = observer.as_deref_mut() { observer.observe(Event::LinkOrImageParsed { - token_length: text[start..url_end].chars().count(), + token: &text[start..url_end], is_image: text[start..].starts_with('!'), }); } @@ -70,11 +70,7 @@ pub(super) fn parse_link_or_image( fallback_single_char(text, start) } -fn find_footnote_end( - text: &str, - idx: usize, - observer: &mut Option<&mut dyn Observer>, -) -> Option { +fn find_footnote_end(text: &str, idx: usize, observer: &mut ObserverHandle<'_>) -> Option { if idx >= text.len() || !text[idx..].starts_with("[^") { if let Some(observer) = observer.as_deref_mut() { observer.observe(Event::FootnoteEndNotFound { @@ -102,7 +98,7 @@ fn find_footnote_end( observer.observe(Event::FootnoteLabelRecognized { start: idx, end: cursor, - token_length: text[idx..cursor].chars().count(), + token: &text[idx..cursor], }); } return Some(cursor); diff --git a/src/wrap/tracing_adapter.rs b/src/wrap/tracing_adapter.rs index 2055d155..ec60769c 100644 --- a/src/wrap/tracing_adapter.rs +++ b/src/wrap/tracing_adapter.rs @@ -5,33 +5,46 @@ use tracing::{debug, trace}; use super::observer::{Event, Observer}; /// Translates domain events into the crate's existing `tracing` events. +/// +/// The adapter owns every vendor-specific concern: the `tracing` level gate and +/// any derived value that costs more than a copy (Unicode length counts and +/// bounded snippets). Domain emitters hand over only borrowed slices, so a +/// disabled subscriber pays nothing beyond the branch below. pub(crate) struct TracingObserver; impl Observer for TracingObserver { fn observe(&mut self, event: Event<'_>) { match event { - Event::FootnoteReferenceParsed { token_length } + Event::FootnoteReferenceParsed { token } if tracing::enabled!(tracing::Level::DEBUG) => { - debug!(token_length, "footnote reference parsed"); + debug!( + token_length = token.chars().count(), + "footnote reference parsed" + ); } - Event::LinkOrImageParsed { - token_length, - is_image, - } if tracing::enabled!(tracing::Level::DEBUG) => { - debug!(token_length, is_image, "link or image parsed"); + Event::LinkOrImageParsed { token, is_image } + if tracing::enabled!(tracing::Level::DEBUG) => + { + debug!( + token_length = token.chars().count(), + is_image, "link or image parsed" + ); } Event::FootnoteEndNotFound { start, reason } if tracing::enabled!(tracing::Level::TRACE) => { trace!(start, reason, "footnote end not found"); } - Event::FootnoteLabelRecognized { - start, - end, - token_length, - } if tracing::enabled!(tracing::Level::TRACE) => { - trace!(start, end, token_length, "footnote label span recognized"); + Event::FootnoteLabelRecognized { start, end, token } + if tracing::enabled!(tracing::Level::TRACE) => + { + trace!( + start, + end, + token_length = token.chars().count(), + "footnote label span recognized" + ); } Event::FootnoteRefChecked { token, result } if tracing::enabled!(tracing::Level::TRACE) => @@ -41,14 +54,89 @@ impl Observer for TracingObserver { result, "footnote reference checked" ); } - Event::FragmentClassified { - token, - truncated, - kind, - } if tracing::enabled!(tracing::Level::DEBUG) => { - debug!(token = %token, truncated, kind = ?kind, "fragment classified"); + Event::DateSequenceMatched { start, end } + if tracing::enabled!(tracing::Level::DEBUG) => + { + debug!(start, end, "matched date sequence"); + } + Event::FragmentClassified { token, kind } + if tracing::enabled!(tracing::Level::DEBUG) => + { + let (snippet, truncated) = trace_text_snippet(token); + debug!(token = %snippet, truncated, kind = ?kind, "fragment classified"); } _ => {} } } } + +/// Returns a UTF-8-safe prefix of `text` for debug logging. +/// +/// The prefix contains at most 80 bytes and never splits a multi-byte +/// character. The second tuple element is `true` when `text` was shortened. +/// This lives in the adapter so the truncation scan only runs once the level +/// gate above has confirmed the event will be emitted. +fn trace_text_snippet(text: &str) -> (&str, bool) { + const MAX_TRACE_BYTES: usize = 80; + if text.len() <= MAX_TRACE_BYTES { + return (text, false); + } + + let mut byte_end = 0; + for (idx, ch) in text.char_indices() { + let next_end = idx + ch.len_utf8(); + if next_end > MAX_TRACE_BYTES { + break; + } + byte_end = next_end; + } + + (&text[..byte_end], true) +} + +#[cfg(test)] +mod trace_snippet_tests { + //! Tests for the `trace_text_snippet` helper. + //! + //! Verifies that the UTF-8-safe truncation helper produces a slice at a + //! valid character boundary and sets the truncation flag correctly. + + use super::trace_text_snippet; + + #[test] + fn trace_text_snippet_truncates_on_char_boundary() { + let ascii = "a".repeat(79); + let text = format!("{ascii}étail"); + let (snippet, truncated) = trace_text_snippet(&text); + + assert!(truncated); + assert_eq!(snippet, ascii.as_str()); + assert!(snippet.is_char_boundary(snippet.len())); + } +} + +#[cfg(test)] +mod trace_snippet_proptests { + //! Property tests for `trace_text_snippet` invariants. + //! + //! Verifies on arbitrary Unicode input that the helper never panics, the + //! result is a valid UTF-8 slice of at most 80 bytes, and the truncation + //! flag accurately reflects whether the input exceeded that limit. + + use proptest::prelude::*; + + use super::trace_text_snippet; + + proptest! { + #[test] + fn trace_text_snippet_never_panics(s in "\\PC*") { + let (snippet, truncated) = trace_text_snippet(&s); + // Invariant 1: result is always valid UTF-8 at a char boundary. + assert!(snippet.is_char_boundary(snippet.len())); + // Invariant 2: result never exceeds 80 bytes. + assert!(snippet.len() <= 80); + // Invariant 3: truncation flag is accurate. + assert_eq!(truncated, s.len() > 80); + } + } +} diff --git a/tests/wrap_properties.rs b/tests/wrap_properties.rs index 6ccd7401..4d4c02dd 100644 --- a/tests/wrap_properties.rs +++ b/tests/wrap_properties.rs @@ -245,8 +245,12 @@ proptest! { "wrapped checklist item inserted a space after the opening fence: {:?}", output ); + // Check the actual closing fence (immediately after `--flag`). Matching + // on `format!("{command} `")` would collide with the opening fence when + // `before` ends with the same letters as `command` (e.g. before "aqu", + // command "qu" both yield "qu `" around the opener), a false positive. prop_assert!( - !rendered.contains(format!("{command} `").as_str()), + !rendered.contains("--flag `"), "wrapped checklist item inserted a space before the closing fence: {:?}", output ); From bf1f2b7b6ac85db50bcec3de1b2571ea13370f00 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 26 Jul 2026 23:54:38 +0200 Subject: [PATCH 3/4] Add observer-boundary wrapping benchmarks (#309) Prove that the observer/tracing adapter adds no derived-payload work on the inline wrapping hot path when tracing is disabled, and establish a baseline for large realistic wrapping workloads. - Add Criterion (minimal features) as a dev-dependency and register a `wrap_observer` bench target gated on a new `bench-internals` feature. - Add `src/wrap/bench_internals.rs`: feature-gated, `#[doc(hidden)]` shims exposing the crate-internal `wrap_preserving_code_observed` for the observer-`None` and `TracingObserver`-disabled paths, plus deterministic large fixtures mixing prose, links, inline code, and footnote references. Never compiled into production builds. - Benchmark the public `wrap_text` document path and the inline path with the observer disabled (`None`) versus `TracingObserver` with no subscriber installed; the latter two must match. - Add `tests/bench_fixtures.rs` proving the fixtures exercise links, code spans, and footnote references and that wrapping preserves them (content assertions only, no timing). - Add a `make bench` target and a developer-guide section documenting the command and the disabled-tracing performance invariant. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 10 +++++ Makefile | 5 ++- benches/wrap_observer.rs | 57 +++++++++++++++++++++++++ docs/developers-guide.md | 32 ++++++++++++++ src/wrap.rs | 4 ++ src/wrap/bench_internals.rs | 84 +++++++++++++++++++++++++++++++++++++ tests/bench_fixtures.rs | 64 ++++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 benches/wrap_observer.rs create mode 100644 src/wrap/bench_internals.rs create mode 100644 tests/bench_fixtures.rs diff --git a/Cargo.toml b/Cargo.toml index 0dfd8cff..b721c9a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,11 @@ description = """ `mdtablefix` unb0rks and reflows Markdown tables so that each column has a uniform width. When \ the `--wrap` option is used, it also wraps paragraphs and list items to 80 columns.""" +[features] +# Exposes internal inline-wrapping shims and fixtures used only by +# `benches/wrap_observer.rs`. Never enable in production; it is not part of the +# public API and only affects `#[doc(hidden)]` benchmark support code. +bench-internals = [] [package.metadata.binstall] [package.metadata.binstall.overrides.'cfg(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64"), target_env = "gnu"))'] @@ -44,6 +49,11 @@ predicates = "3" trybuild = "1" tracing-test = "0.2" test-macros = { path = "test-macros" } +criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } +[[bench]] +name = "wrap_observer" +harness = false +required-features = ["bench-internals"] [lints.clippy] pedantic = "warn" diff --git a/Makefile b/Makefile index fe353d71..eaa71fb9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test build release lint typecheck fmt check-fmt check-ripgrep check-static-regexes markdownlint nixie +.PHONY: help all clean test bench build release lint typecheck fmt check-fmt check-ripgrep check-static-regexes markdownlint nixie APP ?= mdtablefix CARGO ?= $(or $(shell command -v cargo 2>/dev/null),$(HOME)/.cargo/bin/cargo) @@ -19,6 +19,9 @@ clean: ## Remove build artifacts test: ## Run tests with warnings treated as errors RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) +bench: ## Run the observer-boundary wrapping benchmarks + $(CARGO) bench --features bench-internals --bench wrap_observer + target/%/$(APP): ## Build binary in debug or release mode $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) diff --git a/benches/wrap_observer.rs b/benches/wrap_observer.rs new file mode 100644 index 00000000..cded9a65 --- /dev/null +++ b/benches/wrap_observer.rs @@ -0,0 +1,57 @@ +//! Benchmarks for the inline-wrapping observer boundary. +//! +//! These benchmarks protect the performance invariant introduced with the +//! observer/tracing adapter (see `docs/adrs/0006-observer-boundary-for-tracing.md`): +//! with tracing disabled, `TracingObserver` must add no derived-payload work — +//! Unicode length counts or snippet truncation — beyond a single +//! `tracing::enabled!` branch per event. Comparing the `observer_none` and +//! `tracing_observer_disabled` inline cases makes any regression in that +//! invariant visible as a growing gap between the two. +//! +//! Run with: +//! +//! ```sh +//! cargo bench --features bench-internals --bench wrap_observer +//! ``` + +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use mdtablefix::{ + wrap::bench_internals::{ + BENCH_WIDTH, + large_inline_paragraph, + realistic_markdown_document, + wrap_with_tracing_observer, + wrap_without_observer, + }, + wrap_text, +}; + +/// Benchmarks the public `wrap_text` path over a large realistic document. +/// +/// `wrap_text` always drives the wrapping pipeline through `TracingObserver`, +/// so this measures the library boundary with no subscriber installed. +fn bench_public_document(c: &mut Criterion) { + let document = realistic_markdown_document(); + c.bench_function("wrap_text_realistic_document", |b| { + b.iter(|| wrap_text(black_box(&document), black_box(BENCH_WIDTH))); + }); +} + +/// Benchmarks the inline hot path with the observer disabled (`None`) and with +/// `TracingObserver` while no subscriber is installed. +fn bench_inline_observer_paths(c: &mut Criterion) { + let paragraph = large_inline_paragraph(); + let mut group = c.benchmark_group("inline_wrapping"); + group.bench_function("observer_none", |b| { + b.iter(|| wrap_without_observer(black_box(¶graph), black_box(BENCH_WIDTH))); + }); + group.bench_function("tracing_observer_disabled", |b| { + b.iter(|| wrap_with_tracing_observer(black_box(¶graph), black_box(BENCH_WIDTH))); + }); + group.finish(); +} + +criterion_group!(benches, bench_public_document, bench_inline_observer_paths); +criterion_main!(benches); diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 301a7d82..ffb4d55a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -696,6 +696,38 @@ that derived work, such as Unicode length counts and bounded-snippet truncation, only inside the guarded match arm, so a disabled subscriber pays nothing beyond the initial branch. + +#### Benchmarking the observer boundary + +The `benches/wrap_observer.rs` Criterion benchmark protects that invariant. +Run it with: + +```sh +make bench + +# or, equivalently: +cargo bench --features bench-internals --bench wrap_observer +``` + +The `bench-internals` feature exposes `#[doc(hidden)]` shims in +`src/wrap/bench_internals.rs`; it is never enabled in production. The benchmark +covers three cases over large, realistic wrapping inputs (prose mixed with +inline links, inline code, and footnote references): + +- `wrap_text_realistic_document` — the public `wrap_text` path over a + multi-paragraph document, which always runs through `TracingObserver`. +- `inline_wrapping/observer_none` — the inline hot path with no observer + attached (`ObserverHandle` is `None`), the pure-domain baseline. +- `inline_wrapping/tracing_observer_disabled` — the same inline input through + `TracingObserver` with no DEBUG or TRACE subscriber installed. + +The last two cases should measure the same: with tracing disabled, the derived +Unicode length counts and snippet truncation must stay inside `TracingObserver` +behind its `tracing::enabled!` gates, so the adapter adds no per-event work +beyond one branch. A widening gap between `observer_none` and +`tracing_observer_disabled` signals that derived-payload work has leaked back +onto the hot path. + ### Security considerations Tracing events must not include raw document content. Record bounded metadata diff --git a/src/wrap.rs b/src/wrap.rs index fe2f2c76..ec85177d 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -403,3 +403,7 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { #[cfg(test)] mod tests; + +#[cfg(feature = "bench-internals")] +#[doc(hidden)] +pub mod bench_internals; diff --git a/src/wrap/bench_internals.rs b/src/wrap/bench_internals.rs new file mode 100644 index 00000000..851e7d6f --- /dev/null +++ b/src/wrap/bench_internals.rs @@ -0,0 +1,84 @@ +//! Benchmark-only shims and fixtures for the inline-wrapping observer boundary. +//! +//! This module is compiled only under the `bench-internals` feature so +//! [`benches/wrap_observer.rs`](../../benches/wrap_observer.rs) can measure the +//! observer-disabled (`ObserverHandle` is `None`) domain path against the +//! [`TracingObserver`](super::tracing_adapter::TracingObserver) path with no +//! DEBUG or TRACE subscriber installed. Nothing here is part of the public API +//! or compiled into production builds. +//! +//! The two wrapping shims expose the crate-internal +//! `wrap_preserving_code_observed` entry point so the benchmark can prove that, +//! with tracing disabled, the adapter adds no derived-payload work (Unicode +//! length counts and snippet truncation) beyond a single `tracing::enabled!` +//! branch per event. + +use super::{inline::wrap_preserving_code_observed, tracing_adapter::TracingObserver}; + +/// Wrap width used by the benchmarks, matching the crate's default column +/// budget of 80 display columns. +pub const BENCH_WIDTH: usize = 80; + +/// Wraps `text` with no observer attached (`ObserverHandle` is `None`). +/// +/// This is the pure-domain baseline: no event is ever emitted, so no adapter +/// work runs at all. +#[must_use] +pub fn wrap_without_observer(text: &str, width: usize) -> Vec { + wrap_preserving_code_observed(text, width, &mut None) +} + +/// Wraps `text` through [`TracingObserver`] while no DEBUG or TRACE subscriber +/// is installed, exercising the library boundary's disabled-tracing path. +#[must_use] +pub fn wrap_with_tracing_observer(text: &str, width: usize) -> Vec { + let mut observer = TracingObserver; + wrap_preserving_code_observed(text, width, &mut Some(&mut observer)) +} + +/// Number of paragraphs in [`realistic_markdown_document`]. +const DOCUMENT_PARAGRAPHS: usize = 60; + +/// Number of clauses in [`large_inline_paragraph`]. +const INLINE_CLAUSES: usize = 120; + +/// Builds a large, realistic multi-line Markdown document that mixes ordinary +/// prose with inline links, inline code, and footnote references. +/// +/// Paragraphs are separated by blank lines so `wrap_text` treats each as its +/// own reflow block. The content is deterministic: the same document is +/// produced on every call. +#[must_use] +pub fn realistic_markdown_document() -> Vec { + let mut lines = Vec::with_capacity(DOCUMENT_PARAGRAPHS * 2); + for index in 0..DOCUMENT_PARAGRAPHS { + lines.push(format!( + "Paragraph {index}: the `wrap_text` routine reflows prose while \ + keeping the [textwrap {index}](https://example.com/crate/{index}) \ + link, the `inline-code-{index}` span, and the trailing footnote \ + reference intact.[^note{index}] Downstream tooling relies on the \ + stable column budget and content-free diagnostics." + )); + lines.push(String::new()); + } + lines +} + +/// Builds one large inline paragraph (a single logical line) that mixes prose, +/// inline links, inline code, and footnote references. +/// +/// This drives the inline hot path — tokenization, span grouping, fragment +/// classification, and observer-event emission — repeatedly within a single +/// wrap call. The content is deterministic. +#[must_use] +pub fn large_inline_paragraph() -> String { + let mut clauses = Vec::with_capacity(INLINE_CLAUSES); + for index in 0..INLINE_CLAUSES { + clauses.push(format!( + "the `code-{index}` value links to \ + [reference {index}](https://example.com/path/{index}) and cites \ + the footnote [^fn{index}]" + )); + } + clauses.join(", ") +} diff --git a/tests/bench_fixtures.rs b/tests/bench_fixtures.rs new file mode 100644 index 00000000..4c913d9a --- /dev/null +++ b/tests/bench_fixtures.rs @@ -0,0 +1,64 @@ +//! Proves the benchmark fixtures exercise links, inline code, and footnote +//! references, and that the public wrapping path preserves those constructs. +//! +//! These tests are content assertions only — they never assert on elapsed time +//! — and run under `make test` because that target enables all features, +//! including `bench-internals`. +#![cfg(feature = "bench-internals")] + +use mdtablefix::{ + wrap::bench_internals::{BENCH_WIDTH, large_inline_paragraph, realistic_markdown_document}, + wrap_text, +}; + +/// Asserts `haystack` contains a Markdown link, an inline code span, and a GFM +/// footnote reference. +fn assert_covers_link_code_footnote(haystack: &str, context: &str) { + assert!( + haystack.contains("]("), + "{context} should contain a Markdown link" + ); + assert!( + haystack.contains('`'), + "{context} should contain an inline code span" + ); + assert!( + haystack.contains("[^"), + "{context} should contain a footnote reference" + ); +} + +#[test] +fn realistic_document_fixture_covers_and_preserves_constructs() { + let document = realistic_markdown_document(); + let source = document.join("\n"); + assert_covers_link_code_footnote(&source, "the document fixture"); + + let wrapped = wrap_text(&document, BENCH_WIDTH).join("\n"); + assert_covers_link_code_footnote(&wrapped, "the wrapped document"); + // The specific constructs survive wrapping intact. + assert!( + wrapped.contains("`inline-code-0`"), + "code span split: {wrapped}" + ); + assert!( + wrapped.contains("(https://example.com/crate/0)"), + "link split: {wrapped}" + ); + assert!(wrapped.contains("[^note0]"), "footnote split: {wrapped}"); +} + +#[test] +fn large_inline_paragraph_fixture_covers_and_preserves_constructs() { + let paragraph = large_inline_paragraph(); + assert_covers_link_code_footnote(¶graph, "the inline paragraph fixture"); + + let wrapped = wrap_text(&[paragraph], BENCH_WIDTH).join("\n"); + assert_covers_link_code_footnote(&wrapped, "the wrapped inline paragraph"); + assert!(wrapped.contains("`code-0`"), "code span split: {wrapped}"); + assert!( + wrapped.contains("(https://example.com/path/0)"), + "link split: {wrapped}" + ); + assert!(wrapped.contains("[^fn0]"), "footnote split: {wrapped}"); +} From 84b2a9c6393687b8ae321e2b592dcdfc6278ca99 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Jul 2026 20:16:46 +0200 Subject: [PATCH 4/4] Integrate observer boundary with main's paragraph split Reconcile the observer/tracing work with main's restructuring of the paragraph module (now split into hard_break, pending, spanning_code, and tail_reflow submodules) after rebasing onto origin/main. - Make `wrap_preserving_code` the production inline entry point again, wiring up a `TracingObserver` internally, so main's new call sites in `paragraph/spanning_code.rs` and `paragraph/tail_reflow.rs` route through the observer boundary. This replaces the branch's test-only `wrap_preserving_code` and the redundant `paragraph::wrap_observed` helper, which is removed. - Gate the `wrap_preserving_code_observed` re-export behind the `bench-internals` feature, its only remaining external caller. - Rebuild Cargo.lock from main's, re-adding the criterion dev-dependency. - Repair an architecture.md merge artifact that mangled the footnote before/after example, and drop stray blank lines flagged by markdownlint in the merged docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 153 ++++++++++++++++++++++++++++++++++++ docs/architecture.md | 13 +-- docs/developers-guide.md | 2 - src/wrap/inline.rs | 9 ++- src/wrap/inline/wrapping.rs | 12 ++- src/wrap/paragraph.rs | 21 +---- 6 files changed, 180 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9f59611..e7e7f67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -163,12 +169,45 @@ dependencies = [ "rustix", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.1" @@ -226,6 +265,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -251,6 +324,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "difflib" version = "0.4.0" @@ -396,6 +475,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -417,6 +507,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "html5ever" version = "0.39.0" @@ -485,12 +581,32 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -583,6 +699,7 @@ dependencies = [ "camino", "cap-std", "clap", + "criterion", "html5ever", "insta", "libc", @@ -650,6 +767,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1012,6 +1135,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1031,6 +1163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1229,6 +1362,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1434,6 +1577,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/docs/architecture.md b/docs/architecture.md index f3f7d347..46dde27b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -266,15 +266,17 @@ Text. ## Footnotes - [^1]: First note + 1. First note - [^2]: Second note + 2. Second note -[^10]: Final note +10. Final note ``` -`convert_footnotes` only processes the final contiguous numeric list that -immediately follows an H2 heading when these conditions are met. +After: + +```markdown +Text. ## Footnotes @@ -611,7 +613,6 @@ lines bypass the inline wrapping path and are emitted unchanged. The helper `html_table_to_markdown` is retained for backward compatibility but is deprecated. New code should call `convert_html_tables` instead. - ### Observer boundary for inline diagnostics The tokenizing and classification helpers in the wrap sequence above — diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ffb4d55a..bf585bed 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -585,7 +585,6 @@ global subscriber or metrics recorder. Executables and test harnesses that want log output must install their own subscriber (e.g. `tracing_subscriber::fmt::init()` in `main`). - ### Inline classification observer boundary The `Observer` trait in @@ -696,7 +695,6 @@ that derived work, such as Unicode length counts and bounded-snippet truncation, only inside the guarded match arm, so a disabled subscriber pays nothing beyond the initial branch. - #### Benchmarking the observer boundary The `benches/wrap_observer.rs` Criterion benchmark protects that invariant. diff --git a/src/wrap/inline.rs b/src/wrap/inline.rs index 862d5a8e..2e9efc30 100644 --- a/src/wrap/inline.rs +++ b/src/wrap/inline.rs @@ -38,11 +38,16 @@ pub(in crate::wrap::inline) use predicates::{ /// line when `current` is empty. #[cfg(test)] pub(super) use test_support::attach_punctuation_to_previous_line; +#[cfg(test)] +pub(super) use wrapping::determine_token_span; // Public wrapping entry points, defined in `wrapping` and surfaced here so // `paragraph` and the wrap test suites keep using `inline::…` paths. +pub(super) use wrapping::wrap_preserving_code; +// The observer-threaded entry point is only reached directly by the +// benchmark-only shims in `bench_internals`; production code goes through +// `wrap_preserving_code`, which wires up the `TracingObserver`. +#[cfg(feature = "bench-internals")] pub(super) use wrapping::wrap_preserving_code_observed; -#[cfg(test)] -pub(super) use wrapping::{determine_token_span, wrap_preserving_code}; #[cfg(test)] mod date_strategies; diff --git a/src/wrap/inline/wrapping.rs b/src/wrap/inline/wrapping.rs index 74f2035a..580eb007 100644 --- a/src/wrap/inline/wrapping.rs +++ b/src/wrap/inline/wrapping.rs @@ -321,9 +321,17 @@ fn render_line( /// rendered back into `Vec` output lines. `width` is measured in /// Unicode display columns and must be at least one effective column after any /// caller prefix handling. This helper never panics for valid input. -#[cfg(test)] +/// +/// This is the production entry point used across the paragraph-wrapping +/// helpers. It centralises the observer wiring by translating inline +/// classification events through a [`TracingObserver`], so callers never +/// construct an observer themselves and every wrapped paragraph passes through +/// the same boundary. With no DEBUG or TRACE subscriber installed the adapter's +/// level gates suppress all derived work. +/// +/// [`TracingObserver`]: crate::wrap::tracing_adapter::TracingObserver pub(in crate::wrap) fn wrap_preserving_code(text: &str, width: usize) -> Vec { - let mut observer = crate::wrap::observer::NoOpObserver; + let mut observer = crate::wrap::tracing_adapter::TracingObserver; wrap_preserving_code_observed(text, width, &mut Some(&mut observer)) } diff --git a/src/wrap/paragraph.rs b/src/wrap/paragraph.rs index bd8cb4fc..b9ba14d1 100644 --- a/src/wrap/paragraph.rs +++ b/src/wrap/paragraph.rs @@ -9,11 +9,7 @@ use code_span_trim::trim_code_span_edge_spaces; use tracing::trace; use unicode_width::UnicodeWidthStr; -use super::{ - inline::wrap_preserving_code_observed, - tokenize::parse_open_code_span, - tracing_adapter::TracingObserver, -}; +use super::{inline::wrap_preserving_code, tokenize::parse_open_code_span}; mod code_span_trim; mod hard_break; @@ -32,17 +28,6 @@ pub(super) use pending::{ #[cfg(test)] #[path = "paragraph_tests.rs"] mod tests; - -/// Wraps `text` at `available` columns, translating inline-classification -/// events through a `TracingObserver`. -/// -/// This centralises the observer wiring shared by the prefixed-wrapping helpers -/// so the `TracingObserver` construction and `wrap_preserving_code_observed` -/// call live in exactly one place. -fn wrap_observed(text: &str, available: usize) -> Vec { - let mut observer = TracingObserver; - wrap_preserving_code_observed(text, available, &mut Some(&mut observer)) -} /// Carries the parsed prefix metadata for a line that should be wrapped. pub(super) struct PrefixLine<'a> { /// Stores the literal prefix emitted on the first wrapped line. @@ -152,7 +137,7 @@ impl<'a> ParagraphWriter<'a> { fn wrap_with_prefix(&mut self, prefix: &str, continuation_prefix: &str, text: &str) { let prefix_width = UnicodeWidthStr::width(prefix); let available = self.width.saturating_sub(prefix_width).max(1); - let lines = wrap_observed(text, available); + let lines = wrap_preserving_code(text, available); if lines.is_empty() { self.out.push(prefix.to_string()); return; @@ -189,7 +174,7 @@ impl<'a> ParagraphWriter<'a> { let continuation_prefix = continuation_prefix_for(prefix, line.repeat_prefix, line.outer_prefix.as_deref()); - let lines = wrap_observed(line.rest, available); + let lines = wrap_preserving_code(line.rest, available); if lines.is_empty() { self.out.push(prefix.to_string()); return;