#[cfg(test)]
mod footnote_tests;
mod fragment;
mod month_names;
mod normalize;
mod postprocess;
mod predicates;
mod span_helpers;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
mod tracing_events;
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;
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 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) {
let mut end = start + 1;
let mut width = UnicodeWidthStr::width(tokens[start].as_str());
let mut kind = SpanKind::General;
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);
}
}
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]) {
kind = SpanKind::FootnoteRef;
end = extend_punctuation(tokens, end, &mut width);
}
(end, width, kind)
}
pub(super) fn determine_token_span(tokens: &[String], start: usize) -> (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);
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);
emit_whitespace_footnote_coupling(kind, next_token, following_token, should_couple);
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;
}
let footnote_coupling = try_couple_footnote_reference(tokens, end, kind, &mut width);
emit_footnote_reference_coupling(tokens, end, kind, footnote_coupling.is_some());
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)
}
#[cfg(test)]
pub(super) use test_support::attach_punctuation_to_previous_line;
fn push_span_text(text: &mut String, tokens: &[String], span: Range<usize>) {
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);
}
}
fn build_fragments(tokens: &[String]) -> Vec<InlineFragment> {
let mut fragments: Vec<InlineFragment> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let (group_end, _group_width) = determine_token_span(tokens, i);
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(text));
i = group_end;
}
fragments
}
fn is_single_link_line(line: &[InlineFragment]) -> bool {
line.len() == 1 && line[0].kind == fragment::FragmentKind::Link
}
fn fragment_line_width(line: &[InlineFragment]) -> usize {
line.iter().map(|fragment| fragment.width).sum()
}
fn split_boundary_link_line(
previous_line: &[InlineFragment],
line: &[InlineFragment],
width: usize,
) -> Option<(Vec<InlineFragment>, Vec<InlineFragment>)> {
let previous_width = fragment_line_width(previous_line);
if !(previous_width == width || previous_width + 1 == width)
|| !line
.first()
.is_some_and(|fragment| fragment.kind == fragment::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()))
}
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())
}
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::<String>();
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
}
pub(super) fn wrap_preserving_code(text: &str, width: usize) -> Vec<String> {
let tokens = tokenize::segment_inline(text);
if tokens.is_empty() {
return Vec::new();
}
let tokens = normalize_footnote_ref_spacing(&tokens);
let fragments = build_fragments(&tokens);
let mut lines = Vec::new();
let mut buffer: Vec<InlineFragment> = 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::<Vec<_>>();
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
}
#[cfg(test)]
mod date_strategies;