feat(cli)!: preserve source list markers instead of normalizing

BREAKING CHANGE: the --bullet option is removed; list markers now follow
the source instead of being normalized.
This commit is contained in:
2026-07-06 09:59:49 +02:00
parent 101c2d2fd7
commit bdf34f9428
3 changed files with 46 additions and 68 deletions
+1 -4
View File
@@ -16,12 +16,9 @@ Options:
--wrap MODE keep paragraph line breaks as written (keep, the
default), join each paragraph onto one line (no), or
reflow paragraphs to a column width (a number)
--bullet MARKER
write bullet list items with "-" (the default),
"*", or "+"
--ordered STYLE
number ordered list items counting up from the first
item's number (increment, the default) or with "1."
item's number (increment, the default) or with "1"
for every item after the first (one)
-h, --help print this help and exit
-v, --version
-16
View File
@@ -9,7 +9,6 @@ use std::process::ExitCode;
use lexopt::prelude::*;
use render::Bullet;
use render::Ordered;
use render::Style;
use render::Wrap;
@@ -26,9 +25,6 @@ Options:
--wrap MODE keep paragraph line breaks as written (keep, the
default), join each paragraph onto one line (no), or
reflow paragraphs to a column width (a number)
--bullet MARKER
write bullet list items with \"-\" (the default),
\"*\", or \"+\"
--ordered STYLE
number ordered list items counting up from the first
item's number (increment, the default) or with \"1.\"
@@ -90,9 +86,6 @@ fn parse_args() -> Result<Option<Cli>, lexopt::Error> {
Long("wrap") => {
style.wrap = parser.value()?.parse_with(parse_wrap)?;
}
Long("bullet") => {
style.bullet = parser.value()?.parse_with(parse_bullet)?;
}
Long("ordered") => {
style.ordered = parser.value()?.parse_with(parse_ordered)?;
}
@@ -118,15 +111,6 @@ fn parse_wrap(value: &str) -> Result<Wrap, String> {
}
}
fn parse_bullet(value: &str) -> Result<Bullet, String> {
match value {
"-" => Ok(Bullet::Dash),
"*" => Ok(Bullet::Star),
"+" => Ok(Bullet::Plus),
_ => Err(String::from(r#"expected "-", "*", or "+""#)),
}
}
fn parse_ordered(value: &str) -> Result<Ordered, String> {
match value {
"increment" => Ok(Ordered::Increment),
+45 -48
View File
@@ -22,25 +22,6 @@ pub(crate) enum Wrap {
Columns(usize),
}
/// Marker written before every unordered list item.
#[derive(Clone, Copy, Default)]
pub(crate) enum Bullet {
#[default]
Dash,
Star,
Plus,
}
impl Bullet {
fn marker(self) -> &'static str {
match self {
Bullet::Dash => "- ",
Bullet::Star => "* ",
Bullet::Plus => "+ ",
}
}
}
/// Numbering written on ordered list items after the first. The
/// first item always keeps its source number, which sets the list's
/// start.
@@ -54,13 +35,12 @@ pub(crate) enum Ordered {
#[derive(Clone, Copy, Default)]
pub(crate) struct Style {
pub(crate) wrap: Wrap,
pub(crate) bullet: Bullet,
pub(crate) ordered: Ordered,
}
/// Parses `input` as `CommonMark` (plus tables, footnotes, strikethrough,
/// task lists, and YAML metadata blocks) and re-emits it in one
/// consistent style: ATX headings, uniform list markers per `style`,
/// consistent style: ATX headings, source list markers preserved,
/// `*` emphasis, fenced code blocks, and padded tables.
///
/// Reference-style links keep their labels. The parser reports the
@@ -384,7 +364,7 @@ impl Renderer {
first_item: true,
});
}
Tag::Item => self.start_item(),
Tag::Item => self.start_item(&source[range.clone()]),
Tag::FootnoteDefinition(name) => {
self.block_start();
self.push_prefix(format!("[^{name}]: "), " ".to_owned());
@@ -484,18 +464,18 @@ impl Renderer {
}
}
fn start_item(&mut self) {
fn start_item(&mut self, item_source: &str) {
let list = self.lists.last_mut().expect("item event outside a list");
let marker = match list.next_number.as_mut() {
Some(number) => {
let marker = format!("{number}. ");
let marker = format!("{number}{} ", ordered_delimiter(item_source));
*number = match self.style.ordered {
Ordered::Increment => *number + 1,
Ordered::One => 1,
};
marker
}
None => String::from(self.style.bullet.marker()),
None => format!("{} ", bullet_marker(item_source)),
};
let separate = list.loose && !list.first_item;
list.first_item = false;
@@ -1096,6 +1076,30 @@ fn compute_list_looseness(events: &[(Event<'_>, Range<usize>)]) -> Vec<bool> {
loose
}
/// A marker change is what separates two adjacent lists, so the source
/// marker is read and preserved rather than normalized, which would
/// risk merging adjacent lists.
fn bullet_marker(item_source: &str) -> char {
item_source
.trim_start()
.chars()
.next()
.filter(|c| matches!(c, '-' | '*' | '+'))
.unwrap_or('-')
}
/// The delimiter (`.` or `)`) after an ordered item's number, read from
/// its source for the same reason as [`bullet_marker`].
fn ordered_delimiter(item_source: &str) -> char {
item_source
.trim_start()
.trim_start_matches(|c: char| c.is_ascii_digit())
.chars()
.next()
.filter(|c| matches!(c, '.' | ')'))
.unwrap_or('.')
}
/// Longest run of consecutive backticks in `s`, used to size fences and
/// inline code delimiters so they cannot collide with the content.
fn max_backtick_run(s: &str) -> usize {
@@ -1460,7 +1464,6 @@ fn delimiter_line(widths: &[usize], alignments: &[Alignment]) -> String {
#[cfg(test)]
mod tests {
use super::Bullet;
use super::Ordered;
use super::Style;
use super::Wrap;
@@ -1497,29 +1500,23 @@ mod tests {
}
#[test]
fn bullets_normalize_to_dashes() {
assert_eq!(format_markdown("* a\n+ b\n"), "- a\n\n- b\n");
assert_eq!(format_markdown("* a\n* b\n"), "- a\n- b\n");
fn bullet_markers_are_preserved() {
assert_eq!(format_markdown("* a\n* b\n"), "* a\n* b\n");
assert_eq!(format_markdown("+ a\n+ b\n"), "+ a\n+ b\n");
assert_eq!(format_markdown("- [ ] todo\n"), "- [ ] todo\n");
assert_eq!(format_markdown("* a\n + b\n"), "* a\n + b\n");
}
#[test]
fn bullet_marker_is_selectable() {
let star = Style {
bullet: Bullet::Star,
..Style::default()
};
let plus = Style {
bullet: Bullet::Plus,
..Style::default()
};
assert_eq!(
super::format_markdown("- a\n + b\n", star),
"* a\n * b\n"
);
assert_eq!(
super::format_markdown("* [ ] todo\n", plus),
"+ [ ] todo\n"
);
fn adjacent_lists_with_distinct_markers_stay_separate() {
assert_reparses_same("- a\n\n* b\n\n- c\n");
assert_reparses_same("1. a\n\n1) b\n");
assert_eq!(format_markdown("* a\n+ b\n"), "* a\n\n+ b\n");
}
#[test]
fn ordered_delimiters_are_preserved() {
assert_eq!(format_markdown("1) a\n2) b\n"), "1) a\n2) b\n");
}
#[test]
@@ -1605,7 +1602,7 @@ mod tests {
#[test]
fn nested_tight_list() {
assert_eq!(format_markdown("- a\n * b\n- c\n"), "- a\n - b\n- c\n");
assert_eq!(format_markdown("- a\n * b\n- c\n"), "- a\n * b\n- c\n");
}
#[test]
@@ -1665,7 +1662,7 @@ mod tests {
assert_eq!(format_markdown("~~gone~~\n"), "~~gone~~\n");
assert_eq!(
format_markdown("* [X] done\n* [ ] todo\n"),
"- [x] done\n- [ ] todo\n"
"* [x] done\n* [ ] todo\n"
);
}