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();
if has_balanced_flanking_underscores(bytes) {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + 2);
out.push_str(prefix);
let mut in_link_text = false;
let mut in_backticks = false;
let mut prev_raw: Option<u8> = None;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b >= 0x80 {
let seq_len = utf8_seq_len(b);
out.push_str(std::str::from_utf8(&bytes[i..i + seq_len]).unwrap_or(""));
prev_raw = Some(b);
i += seq_len;
continue;
}
match b {
b'[' if !in_link_text && !in_backticks => in_link_text = true,
b']' if in_link_text => in_link_text = false,
b'`' => in_backticks = !in_backticks,
_ => {}
}
if b == b'_' && !in_link_text && !in_backticks {
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);
i += 1;
}
out
}
fn has_balanced_flanking_underscores(bytes: &[u8]) -> bool {
let mut count = 0usize;
let mut in_link_text = false;
let mut in_backticks = false;
let mut prev_raw: Option<u8> = None;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b >= 0x80 {
let seq_len = utf8_seq_len(b);
prev_raw = Some(b);
i += seq_len;
continue;
}
match b {
b'[' if !in_link_text && !in_backticks => in_link_text = true,
b']' if in_link_text => in_link_text = false,
b'`' => in_backticks = !in_backticks,
_ => {}
}
if b == b'_' && !in_link_text && !in_backticks {
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);
let flanking = if left_is_letter != right_is_letter {
true
} 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);
(prev_is_nonspace && next_is_space_or_none)
|| (prev_is_space_or_none && next_is_nonspace)
} else {
false
};
if flanking {
count += 1;
}
}
}
prev_raw = Some(b);
i += 1;
}
count >= 2 && count.is_multiple_of(2)
}
fn utf8_seq_len(first_byte: u8) -> usize {
if first_byte < 0x80 {
1
} else if first_byte < 0xC0 {
1
} else if first_byte < 0xE0 {
2
} else if first_byte < 0xF0 {
3
} else {
4
}
}
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 == '_')
&& trimmed[3..].eq_ignore_ascii_case("elm"));
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 doc_preserve_all = doc_comment_forces_preserve_all(&lines);
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 block_mixes =
super::reformat::block_mixes_decls_and_bare_exprs(&lines[block_start..=block_end]);
let preserve_this_block = doc_preserve_all || block_mixes;
let needs_reformat =
!preserve_this_block && 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 = if preserve_this_block {
block.join("\n")
} else {
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
}
fn doc_comment_forces_preserve_all(lines: &[&str]) -> bool {
let mut any_decl_block = false;
let mut any_bare_block = false;
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 {
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 block = &lines[block_start..=block_end];
if block_has_result_arrow_comment(block) {
return true;
}
if block_has_internal_ellipsis_placeholder(block) {
return true;
}
let decl_gate_ok = !super::reformat::code_block_has_structural_reformat_signal(block);
if decl_gate_ok && super::reformat::block_looks_decl_only(block) {
any_decl_block = true;
} else if super::reformat::block_looks_bare_only(block) {
any_bare_block = true;
}
if any_decl_block && any_bare_block {
return true;
}
i = block_end + 1;
}
false
}
fn block_has_result_arrow_comment(block_lines: &[&str]) -> bool {
for &line in block_lines {
let trimmed = line.trim();
if trimmed.starts_with("-->") {
return true;
}
}
false
}
fn block_has_internal_ellipsis_placeholder(block_lines: &[&str]) -> bool {
let mut any_internal_ellipsis = false;
let mut any_decl_flavor = false;
for &line in block_lines {
let trimmed = line.trim_start();
if super::predicates::has_internal_ellipsis(trimmed) {
any_internal_ellipsis = true;
}
if super::predicates::looks_like_type_annotation(trimmed)
|| super::predicates::is_single_line_value_decl(trimmed)
{
any_decl_flavor = true;
}
}
any_internal_ellipsis && any_decl_flavor
}