Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd727a327 | ||
|
|
2300957931 | ||
|
|
b3fc9a8bc0 | ||
|
|
65acfbce4c | ||
|
|
d31c967045 | ||
|
|
adac3babc8 | ||
|
|
bdf34f9428 |
Generated
+1
-1
@@ -16,7 +16,7 @@ checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413"
|
||||
|
||||
[[package]]
|
||||
name = "md-fmt"
|
||||
version = "1.0.1"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"lexopt",
|
||||
"pulldown-cmark",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "md-fmt"
|
||||
version = "1.0.1"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
/// `©` or `©`.
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user