7 Commits
Author SHA1 Message Date
oscar 1bd727a327 chore: release v2.0.0 2026-07-06 10:34:27 +02:00
oscar 2300957931 docs(render): remove doc comments that narrate, restate, or inventory callers 2026-07-06 10:31:51 +02:00
oscar b3fc9a8bc0 docs(render): remove ordered_delimiter doc comment 2026-07-06 10:23:42 +02:00
oscar 65acfbce4c docs(render): remove doc comments that narrate or restate code 2026-07-06 10:20:18 +02:00
oscar d31c967045 docs(render): trim doc comments that restate or narrate 2026-07-06 10:08:31 +02:00
oscar adac3babc8 fix(cli): clarify the refuse-to-write message 2026-07-06 10:00:26 +02:00
oscar bdf34f9428 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.
2026-07-06 09:59:49 +02:00
5 changed files with 52 additions and 151 deletions
Generated
+1 -1
View File
@@ -16,7 +16,7 @@ checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413"
[[package]]
name = "md-fmt"
version = "1.0.1"
version = "2.0.0"
dependencies = [
"lexopt",
"pulldown-cmark",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "md-fmt"
version = "1.0.1"
version = "2.0.0"
edition = "2024"
[dependencies]
+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
+2 -18
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),
@@ -141,8 +125,8 @@ fn refuse_unsafe_write(input: &str, output: &str, path: Option<&Path>) -> bool {
}
let prefix = path.map(|p| format!("{}: ", p.display())).unwrap_or_default();
eprintln!(
"md-fmt: {prefix}refusing to write, formatting would change the \
document"
"md-fmt: {prefix}refusing to write: formatting would change the \
document's structure"
);
true
}
+47 -127
View File
@@ -11,9 +11,6 @@ use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
/// How to treat line breaks inside paragraphs: kept as written,
/// joined onto one line, or reflowed to a column limit. Headings,
/// tables, and code are never wrapped.
#[derive(Clone, Copy, Default)]
pub(crate) enum Wrap {
#[default]
@@ -22,28 +19,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.
#[derive(Clone, Copy, Default)]
pub(crate) enum Ordered {
#[default]
@@ -54,25 +29,9 @@ 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`,
/// `*` emphasis, fenced code blocks, and padded tables.
///
/// Reference-style links keep their labels. The parser reports the
/// definitions detached from their source position, so they are
/// re-emitted in canonical form at the first block boundary after
/// their source position, staying inside the blockquote or list item
/// they were written in. Unused definitions are kept.
///
/// Character entities keep their source spelling. Link destinations
/// and titles are kept as the author spelled them, including quote
/// style, falling back to a canonical serialization of the decoded
/// values when the source form is not recoverable.
pub(crate) fn format_markdown(input: &str, style: Style) -> String {
let parser = Parser::new_ext(input, parser_options());
let mut definitions: Vec<RefDef> = parser
@@ -114,19 +73,10 @@ fn parser_options() -> Options {
options
}
/// True when `output` parses to the same document as `input`, so a
/// formatter bug cannot silently change content. Differences the
/// formatter makes on purpose are normalized away before comparing.
pub(crate) fn parses_same(input: &str, output: &str) -> bool {
normalized_events(input) == normalized_events(output)
}
/// Normalizations, each matching a documented formatter behavior:
/// text runs are coalesced (the parser splits them at arbitrary
/// points, such as around escapes), soft breaks compare as spaces
/// (wrap modes move them, and headings absorb them), space and tab
/// runs collapse to one space, and indented code blocks compare as
/// fenced.
fn normalized_events(source: &str) -> Vec<Event<'_>> {
let mut events: Vec<Event<'_>> = Vec::new();
let mut run: Option<String> = None;
@@ -173,10 +123,6 @@ fn normalized_events(source: &str) -> Vec<Event<'_>> {
events
}
/// A link reference definition ready for emission: `dest` and `title`
/// hold output spellings (source-verbatim when recoverable), and
/// `start` is the definition's source offset, used to reconstruct its
/// placement.
struct RefDef {
start: usize,
label: String,
@@ -184,9 +130,6 @@ struct RefDef {
title: Option<String>,
}
/// Line prefix contributed by one enclosing container block. `first` is
/// written on the container's first line (the list marker), `rest` on
/// every following line (the continuation indent).
struct Prefix {
first: String,
rest: String,
@@ -384,7 +327,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 +427,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;
@@ -509,10 +452,6 @@ impl Renderer {
self.flags.at_content_start = true;
}
/// Chooses the delimiter for the emphasis span at `range`: `*`,
/// falling back to `_` and lastly to the source spelling in
/// positions where the earlier choices would not re-parse as this
/// same span.
fn emphasis_delimiter(
&self,
source: &str,
@@ -687,9 +626,6 @@ impl Renderer {
}
}
/// Emits every pending reference definition whose source position
/// lies before `position`. Consecutive definitions form one block.
///
/// Directly inside a tight list item, blank lines around the
/// definition are suppressed. An empty line there would loosen the
/// list on re-parse, and adjacency is what the source had, since a
@@ -898,9 +834,6 @@ impl Renderer {
}
}
/// Moves the line's last space-separated atom onto a new line, or
/// changes nothing when the line has no break position or the atom
/// is empty or must not begin a line.
fn move_last_atom_to_new_line(&mut self) {
let Some(space_pos) = self.last_break else {
return;
@@ -917,9 +850,7 @@ impl Renderer {
}
/// Like `put`, but the spaces in `s` are wrap opportunities when a
/// wrap mode is active. Used for escaped text content. Everything
/// routed through plain `put` (inline code, link syntax, entities)
/// glues to the current line unbroken.
/// wrap mode is active.
fn put_wrappable(&mut self, s: &str) {
if !self.wrappable() {
self.put(s);
@@ -988,8 +919,7 @@ impl Renderer {
self.flags.at_line_start = true;
}
/// Emits a captured block body line by line, ignoring the trailing
/// newline the parser includes. An empty body emits nothing.
/// The parser appends a trailing newline to a captured body, which this drops.
fn raw_body(&mut self, body: &str) {
let body = body.strip_suffix('\n').unwrap_or(body);
if body.is_empty() {
@@ -1059,9 +989,8 @@ impl Renderer {
}
}
/// Computes, in one pass and in document order, whether each list is
/// loose (items separated by blank lines). A list is loose when a
/// paragraph is a direct child of one of its items.
/// A list is loose when a paragraph is a direct child of one of its
/// items.
fn compute_list_looseness(events: &[(Event<'_>, Range<usize>)]) -> Vec<bool> {
enum Frame {
List(usize),
@@ -1096,8 +1025,28 @@ fn compute_list_looseness(events: &[(Event<'_>, Range<usize>)]) -> Vec<bool> {
loose
}
/// Longest run of consecutive backticks in `s`, used to size fences and
/// inline code delimiters so they cannot collide with the content.
/// 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('-')
}
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('.')
}
fn max_backtick_run(s: &str) -> usize {
let mut run = 0usize;
let mut max_run = 0usize;
@@ -1117,8 +1066,6 @@ fn escape_text(
mut line_start: bool,
in_table: bool,
) -> Cow<'_, str> {
/// Switches to owned output on the first change, seeded with the
/// untouched prefix of `text`.
fn modify<'b>(
out: &'b mut Option<String>,
text: &str,
@@ -1219,8 +1166,6 @@ fn escape_text(
}
}
/// True when `source` spells a character entity reference such as
/// `&copy;` or `&#169;`.
fn is_entity(source: &str) -> bool {
source
.strip_prefix('&')
@@ -1231,10 +1176,6 @@ fn is_entity(source: &str) -> bool {
})
}
/// True when `rest`, the text after an ampersand, looks like the body
/// of an entity reference such as `copy;`, `#169;`, or `#xA9;`, which
/// would make the ampersand re-parse as an entity. The 48 byte cap
/// bounds the scan, real entity names are far shorter.
fn entity_follows(rest: &str) -> bool {
let mut seen_name = false;
for byte in rest.bytes().take(48) {
@@ -1249,9 +1190,6 @@ fn entity_follows(rest: &str) -> bool {
false
}
/// Picks the closing form written when the link or image ends:
/// reference styles keep their label, everything else becomes an
/// inline link.
fn pending_link(
link_type: LinkType,
dest: &str,
@@ -1276,12 +1214,6 @@ fn pending_link(
PendingLink::Reference { suffix }
}
/// True when `text`, placed at the start of a line by wrapping, would
/// re-parse as block structure: a blockquote marker, a bullet or
/// ordered-list marker followed by a space, or a line consisting of a
/// run that could open a heading, thematic break, or setext
/// underline. Words like `1.5` or `#tag` are safe because block
/// markers need a following space.
fn hazardous_line_start(text: &str) -> bool {
let word = text.split(' ').next().unwrap_or("");
if word.starts_with('>') {
@@ -1303,7 +1235,7 @@ fn hazardous_line_start(text: &str) -> bool {
&& matches!(word.as_bytes()[word.len() - 1], b'.' | b')')
}
/// Byte offset of the first unescaped `target` in `s`.
/// Byte offset, not char index: callers slice `s` with it.
fn find_unescaped(s: &str, target: char) -> Option<usize> {
let mut escaped = false;
for (i, c) in s.char_indices() {
@@ -1318,11 +1250,6 @@ fn find_unescaped(s: &str, target: char) -> Option<usize> {
None
}
/// Splits the source text following a link's `](` or a definition's
/// `]:` into the destination and optional title exactly as the author
/// spelled them, so entity references and quote style survive.
/// Returns None when the shape is unexpected, and the caller falls
/// back to re-serializing the decoded values.
fn parse_dest_title(inner: &str) -> Option<(String, Option<String>)> {
let inner = inner.trim();
let (dest, rest) = if let Some(after) = inner.strip_prefix('<') {
@@ -1460,7 +1387,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 +1423,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 +1525,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 +1585,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"
);
}