use super::*;
pub(in crate::print) fn normalize_markdown_lists(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let mut result = String::with_capacity(text.len());
let mut in_code_block = false;
let mut list_indent: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
if i > 0 {
result.push('\n');
}
if line.starts_with(" ") {
if i == 0 || lines[i - 1].trim().is_empty() {
in_code_block = true;
}
} else if !line.trim().is_empty() && !line.starts_with(" ") {
in_code_block = false;
}
if in_code_block {
result.push_str(line);
} else if line.trim().is_empty() {
list_indent = None;
result.push_str(line);
} else if line.starts_with("- ") || *line == "-" {
if starts_list_after_prose(&lines, i, list_indent) {
result.push('\n');
}
result.push_str(" ");
result.push_str(&escape_bullet_leading_underscore(line, 2));
list_indent = Some(4);
} else if line.starts_with(" - ") {
if starts_list_after_prose(&lines, i, list_indent) {
result.push('\n');
}
result.push_str(&escape_bullet_leading_underscore(line, 4));
list_indent = Some(4);
} else if let Some(rest) = strip_ordered_list_prefix(line) {
if starts_list_after_prose(&lines, i, list_indent) {
result.push('\n');
}
let trimmed = line.trim_start();
let prefix_len = trimmed.len() - rest.len();
let number_part = &trimmed[..prefix_len]; let number_dot = number_part.trim_end(); result.push_str(number_dot);
result.push_str(" ");
result.push_str(rest);
list_indent = Some(number_dot.len() + 2);
} else if let Some(indent_width) = list_indent {
let trimmed = line.trim_start();
if trimmed.starts_with("@docs") || trimmed.starts_with('#') {
list_indent = None;
result.push_str(line);
} else {
for _ in 0..indent_width {
result.push(' ');
}
result.push_str(trimmed);
}
} else {
result.push_str(line);
}
}
result
}
pub(in crate::print) fn escape_bullet_leading_underscore(line: &str, marker_len: usize) -> String {
if line.len() <= marker_len {
return line.to_string();
}
let (prefix, content) = line.split_at(marker_len);
let bytes = content.as_bytes();
let mut out = String::with_capacity(line.len() + 2);
out.push_str(prefix);
let mut in_link_text = false;
let mut prev_raw: Option<u8> = None;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'[' if !in_link_text => in_link_text = true,
b']' if in_link_text => in_link_text = false,
_ => {}
}
if b == b'_' && !in_link_text {
let already_escaped = prev_raw == Some(b'\\');
if !already_escaped {
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
let next = if i + 1 < bytes.len() {
Some(bytes[i + 1])
} else {
None
};
let left_is_letter = prev.map(|c| c.is_ascii_alphanumeric()).unwrap_or(false);
let right_is_letter = next.map(|c| c.is_ascii_alphanumeric()).unwrap_or(false);
if left_is_letter != right_is_letter {
out.push('\\');
} else if !left_is_letter && !right_is_letter {
let prev_is_nonspace = prev.map(|c| !c.is_ascii_whitespace()).unwrap_or(false);
let next_is_space_or_none =
next.map(|c| c.is_ascii_whitespace()).unwrap_or(true);
let prev_is_space_or_none =
prev.map(|c| c.is_ascii_whitespace()).unwrap_or(true);
let next_is_nonspace = next.map(|c| !c.is_ascii_whitespace()).unwrap_or(false);
if (prev_is_nonspace && next_is_space_or_none)
|| (prev_is_space_or_none && next_is_nonspace)
{
out.push('\\');
}
}
}
}
out.push(b as char);
prev_raw = Some(b);
}
out
}
pub(in crate::print) fn normalize_fenced_code_blocks(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let mut result = String::with_capacity(text.len());
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
let is_fence_open = trimmed == "```"
|| (trimmed.starts_with("```")
&& trimmed.len() > 3
&& !trimmed[3..].contains('`')
&& trimmed[3..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
if is_fence_open {
let mut end = i + 1;
let mut found_close = false;
while end < lines.len() {
if lines[end].trim() == "```" {
found_close = true;
break;
}
end += 1;
}
if found_close {
let in_list_context = fence_is_in_list_context(&lines, i);
if in_list_context {
} else {
#[allow(clippy::needless_range_loop)]
for j in (i + 1)..end {
if !result.is_empty() || j > i + 1 {
result.push('\n');
}
if lines[j].is_empty() {
} else {
result.push_str(" ");
result.push_str(lines[j]);
}
}
i = end + 1;
continue;
}
}
}
if i > 0 {
result.push('\n');
}
result.push_str(lines[i]);
i += 1;
}
result
}
pub(in crate::print) fn fence_is_in_list_context(lines: &[&str], fence_idx: usize) -> bool {
if fence_idx == 0 {
return false;
}
let mut k = fence_idx;
while k > 0 {
k -= 1;
let line = lines[k];
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
let trimmed = line.trim_start();
if trimmed.starts_with("- ")
|| trimmed == "-"
|| strip_ordered_list_prefix(trimmed).is_some()
{
return true;
}
if indent >= 2 {
continue;
}
return false;
}
false
}
pub(in crate::print) fn starts_list_after_prose(
lines: &[&str],
i: usize,
list_indent: Option<usize>,
) -> bool {
if list_indent.is_some() {
return false;
}
if i == 0 {
return false;
}
let prev = lines[i - 1];
if prev.trim().is_empty() {
return false;
}
let prev_trimmed = prev.trim_start();
if prev_trimmed.starts_with("- ")
|| prev_trimmed == "-"
|| strip_ordered_list_prefix(prev_trimmed).is_some()
{
return false;
}
if prev_trimmed.starts_with('#') || prev_trimmed.starts_with("@docs") {
return false;
}
true
}
pub(in crate::print) fn strip_ordered_list_prefix(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let mut chars = trimmed.char_indices();
let first = chars.next()?;
if !first.1.is_ascii_digit() {
return None;
}
let mut after_digits = first.0 + 1;
for (pos, ch) in chars {
if ch.is_ascii_digit() {
after_digits = pos + 1;
} else {
break;
}
}
let rest = &trimmed[after_digits..];
let after_dot = rest.strip_prefix('.')?;
if !after_dot.starts_with(' ') {
return None;
}
Some(after_dot.trim_start())
}
pub(in crate::print) fn normalize_code_block_indent(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let mut result = String::with_capacity(text.len());
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let starts_code = line.starts_with(" ") && (i == 0 || lines[i - 1].trim().is_empty());
if !starts_code {
result.push_str(line);
if i + 1 < lines.len() {
result.push('\n');
}
i += 1;
continue;
}
let block_start = i;
let mut block_end = i; while block_end + 1 < lines.len() {
let next = lines[block_end + 1];
if next.trim().is_empty() {
if block_end + 2 < lines.len() && lines[block_end + 2].starts_with(" ") {
block_end += 1;
continue;
}
break;
} else if next.starts_with(" ") {
block_end += 1;
} else {
break;
}
}
let needs_reformat = code_block_needs_reformat(&lines[block_start..=block_end]);
let reformatted = if needs_reformat {
try_reformat_code_block(&lines[block_start..=block_end])
} else {
None
};
if let Some(reformatted) = reformatted {
if block_has_comment_paragraph(&lines[block_start..=block_end]) {
result.push('\n');
}
result.push_str(&reformatted);
if block_end < lines.len() - 1 {
result.push('\n');
}
} else {
let block = &lines[block_start..=block_end];
let transformed = transform_assertion_paragraphs(block);
let transformed = insert_loose_paragraph_breaks(&transformed);
let end_idx = result.len();
result.push_str(&transformed);
let _ = end_idx;
if block_end < lines.len() - 1 {
result.push('\n');
}
if block_is_all_comments(block) {
let mut k = block_end + 1;
while k < lines.len() && lines[k].trim().is_empty() {
k += 1;
}
result.push('\n');
result.push('\n');
result.push('\n');
i = k;
continue;
}
}
i = block_end + 1;
}
result
}