use textwrap::core::Fragment;
use tracing::debug;
use unicode_width::UnicodeWidthStr;
use super::{
ends_with_footnote_ref,
fragment_is_link,
is_inline_code_token,
is_opening_punct,
is_trailing_punct,
is_whitespace_token,
looks_like_bracketed_reference,
looks_like_footnote_ref,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum FragmentKind {
Whitespace,
InlineCode,
Link,
FootnoteRef,
BracketedRef,
Plain,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct InlineFragment {
pub(super) text: String,
pub(super) width: usize,
pub(super) kind: FragmentKind,
}
impl InlineFragment {
pub(super) fn new(text: String) -> Self {
let width = UnicodeWidthStr::width(text.as_str());
let kind = classify_fragment(text.as_str());
log_fragment_classification(text.as_str(), &kind);
Self { text, width, kind }
}
pub(super) fn is_whitespace(&self) -> bool { self.kind == FragmentKind::Whitespace }
pub(super) fn is_atomic(&self) -> bool {
matches!(
self.kind,
FragmentKind::InlineCode
| FragmentKind::Link
| FragmentKind::FootnoteRef
| FragmentKind::BracketedRef
)
}
pub(super) fn is_plain(&self) -> bool { self.kind == FragmentKind::Plain }
}
impl Fragment for InlineFragment {
fn width(&self) -> f64 { width_as_f64(self.width) }
fn whitespace_width(&self) -> f64 { 0.0 }
fn penalty_width(&self) -> f64 { 0.0 }
}
pub(super) fn width_as_f64(width: usize) -> f64 {
f64::from(u32::try_from(width).unwrap_or(u32::MAX))
}
pub(super) fn has_inline_code_structure(text: &str) -> bool {
fn matched_fence(text: &str) -> bool {
let fence_len = text.chars().take_while(|&ch| ch == '`').count();
if fence_len == 0 {
return false;
}
let fence = &text[..fence_len];
text[fence_len..].contains(fence)
}
let trimmed = text.trim_end_matches(is_trailing_punct);
let without_opening = trimmed.trim_start_matches(is_opening_punct);
matched_fence(text) || matched_fence(trimmed) || matched_fence(without_opening)
}
fn contains_link_with_trailing_punctuation(text: &str) -> bool {
let mut candidate = text;
loop {
if fragment_is_link(candidate) {
return true;
}
let Some(ch) = candidate.chars().next_back() else {
return false;
};
if !is_trailing_punct(ch) {
return false;
}
candidate = &candidate[..candidate.len() - ch.len_utf8()];
}
}
fn classify_fragment(text: &str) -> FragmentKind {
if is_whitespace_token(text) {
return FragmentKind::Whitespace;
}
let trimmed = text.trim_end_matches(is_trailing_punct);
let without_opening = trimmed.trim_start_matches(is_opening_punct);
if contains_link_with_trailing_punctuation(text) {
FragmentKind::Link
} else if is_inline_code_token(text)
|| is_inline_code_token(trimmed)
|| is_inline_code_token(without_opening)
|| has_inline_code_structure(text)
{
FragmentKind::InlineCode
} else if looks_like_footnote_ref(text)
|| looks_like_footnote_ref(trimmed)
|| ends_with_footnote_ref(text)
{
FragmentKind::FootnoteRef
} else if looks_like_bracketed_reference(text) || looks_like_bracketed_reference(trimmed) {
FragmentKind::BracketedRef
} else {
FragmentKind::Plain
}
}
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)
}
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 {
use proptest::prelude::*;
use super::*;
proptest! {
#[test]
fn has_inline_code_structure_never_panics(text in "\\PC*") {
let _ = has_inline_code_structure(&text);
}
#[test]
fn has_inline_code_structure_detects_simple_fence(
inner in "[^`]{1,40}",
) {
let text = format!("`{inner}`");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_detects_opening_punct_prefix(
inner in "[^`]{1,40}",
) {
let text = format!("(`{inner}`)");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_detects_trailing_punct_suffix(
inner in "[^`]{1,40}",
) {
let text = format!("`{inner}`.");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_detects_multi_char_fence(
inner in "[^`]{1,20}",
fence_len in 2usize..=4usize,
) {
let fence: String = "`".repeat(fence_len);
let text = format!("{fence}{inner}{fence}");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_with_possessive_suffix(
inner in "[^`]{1,20}",
) {
let text = format!("`{inner}`'s");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_with_hyphen_suffix(
inner in "[^`]{1,20}",
word in "[a-z]{2,10}",
) {
let text = format!("`{inner}`-{word}");
prop_assert!(has_inline_code_structure(&text));
}
}
proptest! {
#[test]
fn has_inline_code_structure_does_not_match_plain_link(
label in "[a-zA-Z]{1,20}",
url in "https: ) {
let text = format!("[{label}]({url})");
assert!(!has_inline_code_structure(&text));
}
}
}
#[cfg(test)]
mod trace_snippet_tests {
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 {
use rstest::rstest;
use test_macros::traced_test;
use super::{FragmentKind, InlineFragment};
#[traced_test]
#[rstest]
#[case("[^1]", "FootnoteRef")]
#[case("`code`", "InlineCode")]
#[case("[text](https://example.com)", "Link")]
#[case(" ", "Whitespace")]
#[case("plain", "Plain")]
fn fragment_classification_logs_kind(#[case] input: &str, #[case] expected: &str) {
let _fragment = InlineFragment::new(input.to_string());
assert!(logs_contain("fragment classified"));
assert!(logs_contain(&format!("kind={expected}")));
assert!(logs_contain("token="));
assert!(logs_contain("truncated="));
}
#[test]
fn fragment_classification_does_not_require_subscriber() {
let fragment = InlineFragment::new("[^1]".to_string());
assert_eq!(fragment.kind, FragmentKind::FootnoteRef);
}
}
#[cfg(test)]
mod proptests {
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);
assert!(snippet.is_char_boundary(snippet.len()));
assert!(snippet.len() <= 80);
assert_eq!(truncated, s.len() > 80);
}
}
}
#[cfg(test)]
#[path = "fragment_tracing_snapshots.rs"]
mod fragment_tracing_snapshots;