Repository: yshavit/mdq (rust)

Issue:
CLI switch for whether to refactor all links
ie, turn them all inline, or turn them all into referenes

Pull request description:
CLI switch to canonicalize links
> [!note]
> **Draft pending:**
> - [x] write unit tests

Add a CLI switch and `fmt_md` logic to canonicalize links.

- `keep` -> links stay as they are
- `inline` -> all links become `[foo](https://example.com)`
- `reference` -> all links become `[foo][1]`

This resolves #76

Patch:
diff --git a/src/fmt_md.rs b/src/fmt_md.rs
index 9c1f420..4109f87 100644
--- a/src/fmt_md.rs
+++ b/src/fmt_md.rs
@@ -1,19 +1,23 @@
 use clap::ValueEnum;
-use std::borrow::Borrow;
+use std::borrow::{Borrow, Cow};
 use std::cmp::max;
 use std::collections::{HashMap, HashSet};
-use std::fmt::{Alignment, Display};
+use std::fmt::Alignment;
+use std::ops::Deref;
 
-use crate::fmt_str::inlines_to_plain_string;
+use crate::link_transform::{LinkLabel, LinkTransform, LinkTransformer};
 use crate::output::{Block, Output, SimpleWrite};
 use crate::str_utils::{pad_to, standard_align, CountingWriter};
 use crate::tree::*;
 use crate::tree_ref::{ListItemRef, MdElemRef};
 
-#[derive(Default)]
 pub struct MdOptions {
     pub link_reference_placement: ReferencePlacement,
     pub footnote_reference_placement: ReferencePlacement,
+    pub link_canonicalization: LinkTransform,
+    /// note: this is not exposed through the CLI, but is used in [crate::link_transform::inlines_to_string] to
+    /// suppress the thematic breaks between inlines that are otherwise usually present.
+    pub add_thematic_breaks: bool,
 }
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
@@ -79,8 +83,9 @@ where
             links: HashMap::with_capacity(pending_refs_capacity),
             footnotes: HashMap::with_capacity(pending_refs_capacity),
         },
+        link_transformer: LinkTransformer::from(options.link_canonicalization),
     };
-    writer_state.write_md(out, nodes, true);
+    writer_state.write_md(out, nodes, options.add_thematic_breaks);
 
     // Always write the pending definitions at the end of the doc. If there were no sections, then BottomOfSection
     // won't have been triggered, but we still want to write them
@@ -93,6 +98,7 @@ struct MdWriterState<'a> {
     seen_links: HashSet<LinkLabel<'a>>,
     seen_footnotes: HashSet<&'a String>,
     pending_references: PendingReferences<'a>,
+    link_transformer: LinkTransformer,
 }
 
 struct PendingReferences<'a> {
@@ -106,33 +112,6 @@ struct UrlAndTitle<'a> {
     title: &'a Option<String>,
 }
 
-#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
-enum LinkLabel<'a> {
-    Identifier(&'a String),
-    Inline(&'a Vec<Inline>),
-}
-
-impl<'a> LinkLabel<'a> {
-    fn write_to<'b, W: SimpleWrite>(&self, writer: &mut MdWriterState<'b>, out: &mut Output<W>)
-    where
-        'a: 'b,
-    {
-        match self {
-            LinkLabel::Identifier(text) => out.write_str(text),
-            LinkLabel::Inline(text) => writer.write_line(out, text),
-        }
-    }
-}
-
-impl<'a> Display for LinkLabel<'a> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            LinkLabel::Identifier(s) => f.write_str(*s),
-            LinkLabel::Inline(inlines) => f.write_str(&inlines_to_plain_string(*inlines)),
-        }
-    }
-}
-
 impl<'a> MdWriterState<'a> {
     fn write_md<I, W>(&mut self, out: &mut Output<W>, nodes: I, add_break: bool)
     where
@@ -199,11 +178,12 @@ impl<'a> MdWriterState<'a> {
                 self.write_inline_element(out, inline);
             }
             MdElemRef::Link(link) => {
-                self.write_link_inline_portion(out, LinkLabel::Inline(&link.text), &link.link_definition);
+                self.write_link_inline_portion(out, LinkLabel::Inline(&link.text), &link.link_definition)
             }
             MdElemRef::Image(image) => {
                 out.write_char('!');
-                self.write_link_inline_portion(out, LinkLabel::Identifier(&image.alt), &image.link);
+                let alt_text = &image.alt;
+                self.write_link_inline_portion(out, LinkLabel::Text(Cow::from(alt_text)), &image.link);
             }
         }
     }
@@ -472,9 +452,16 @@ impl<'a> MdWriterState<'a> {
         W: SimpleWrite,
     {
         out.write_char('[');
-        label.write_to(self, out);
+        match &label {
+            LinkLabel::Text(text) => out.write_str(text),
+            LinkLabel::Inline(text) => self.write_line(out, text),
+        }
         out.write_char(']');
-        let reference_to_add = match &link.reference {
+
+        let transformed = self.link_transformer.transform(&link.reference, &label);
+        let link_ref_owned = transformed.into_owned();
+
+        let reference_to_add = match link_ref_owned {
             LinkReference::Inline => {
                 out.write_char('(');
                 out.write_str(&link.url);
@@ -484,18 +471,15 @@ impl<'a> MdWriterState<'a> {
             }
             LinkReference::Full(identifier) => {
                 out.write_char('[');
-                out.write_str(identifier);
+                out.write_str(&identifier);
                 out.write_char(']');
-                Some(LinkLabel::Identifier(identifier))
+                Some(LinkLabel::Text(Cow::from(identifier)))
             }
             LinkReference::Collapsed => {
                 out.write_str("[]");
                 Some(label)
             }
-            LinkReference::Shortcut => {
-                // no write
-                Some(label)
-            }
+            LinkReference::Shortcut => Some(label),
         };
 
         if let Some(reference_label) = reference_to_add {
@@ -552,7 +536,7 @@ impl<'a> MdWriterState<'a> {
                 for (link_ref, link_def) in defs_to_write {
                     out.write_char('[');
                     match link_ref {
-                        LinkLabel::Identifier(identifier) => out.write_str(identifier),
+                        LinkLabel::Text(identifier) => out.write_str(identifier.deref()),
                         LinkLabel::Inline(text) => self.write_line(out, text),
                     }
                     out.write_str("]: ");
@@ -660,6 +644,7 @@ pub mod tests {
     use indoc::indoc;
 
     use crate::fmt_md::MdOptions;
+    use crate::link_transform::LinkTransform;
     use crate::m_node;
     use crate::md_elem;
     use crate::md_elems;
@@ -668,7 +653,7 @@ pub mod tests {
     use crate::tree::*;
     use crate::tree_ref::MdElemRef;
 
-    use super::write_md;
+    use super::{write_md, ReferencePlacement};
 
     crate::variants_checker!(VARIANTS_CHECKER = MdElemRef {
         Doc(..),
@@ -1856,6 +1841,33 @@ pub mod tests {
                 [link text two](https://example.com/2)"#},
             );
         }
+
+        /// see [crate::link_transform::tests] for more extensive tests
+        #[test]
+        fn reference_transform_smoke_test() {
+            check_render_refs_with(
+                &MdOptions {
+                    link_reference_placement: ReferencePlacement::Section,
+                    footnote_reference_placement: ReferencePlacement::Section,
+                    link_canonicalization: LinkTransform::Reference,
+                    add_thematic_breaks: true,
+                },
+                vec![MdElemRef::Link(&Link {
+                    text: vec![mdq_inline!("link text")],
+                    link_definition: LinkDefinition {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Inline, // note! inline, but will be transformed to full
+                    },
+                })],
+                indoc! {r#"
+                    [link text][1]
+
+                       -----
+
+                    [1]: https://example.com"#},
+            );
+        }
     }
 
     mod image {
@@ -1915,6 +1927,33 @@ pub mod tests {
                     [2]: https://example.com/2.png"#},
             );
         }
+
+        /// see [crate::link_transform::tests] for more extensive tests
+        #[test]
+        fn reference_transform_smoke_test() {
+            check_render_refs_with(
+                &MdOptions {
+                    link_reference_placement: ReferencePlacement::Section,
+                    footnote_reference_placement: ReferencePlacement::Section,
+                    link_canonicalization: LinkTransform::Reference,
+                    add_thematic_breaks: true,
+                },
+                vec![MdElemRef::Image(&Image {
+                    alt: "alt text".to_string(),
+                    link: LinkDefinition {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Inline, // note! inline, but will be transformed to full
+                    },
+                })],
+                indoc! {r#"
+                    ![alt text][1]
+
+                       -----
+
+                    [1]: https://example.com"#},
+            );
+        }
     }
 
     mod footnote {
@@ -1962,7 +2001,7 @@ pub mod tests {
 
     mod annotation_and_footnote_layouts {
         use super::*;
-        use crate::fmt_md::ReferencePlacement;
+        use crate::link_transform::LinkTransform;
 
         #[test]
         fn link_and_footnote() {
@@ -2002,6 +2041,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Section,
                     footnote_reference_placement: ReferencePlacement::Section,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 link_and_footnote_markdown(),
                 indoc! {r#"
@@ -2026,6 +2067,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Section,
                     footnote_reference_placement: ReferencePlacement::Doc,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 link_and_footnote_markdown(),
                 indoc! {r#"
@@ -2053,6 +2096,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Section,
                     footnote_reference_placement: ReferencePlacement::Section,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 md_elems![Block::LeafBlock::Paragraph {
                     body: vec![m_node!(Inline::Link {
@@ -2079,6 +2124,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Doc,
                     footnote_reference_placement: ReferencePlacement::Section,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 link_and_footnote_markdown(),
                 indoc! {r#"
@@ -2106,6 +2153,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Doc,
                     footnote_reference_placement: ReferencePlacement::Doc,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 link_and_footnote_markdown(),
                 indoc! {r#"
@@ -2132,6 +2181,8 @@ pub mod tests {
                 &MdOptions {
                     link_reference_placement: ReferencePlacement::Doc,
                     footnote_reference_placement: ReferencePlacement::Doc,
+                    link_canonicalization: LinkTransform::Keep,
+                    add_thematic_breaks: true,
                 },
                 // Define them in the opposite order that we'd expect them
                 md_elems![Block::LeafBlock::Paragraph {
@@ -2208,7 +2259,7 @@ pub mod tests {
     }
 
     fn check_render(nodes: Vec<MdElem>, expect: &str) {
-        check_render_with(&MdOptions::default(), nodes, expect);
+        check_render_with(&default_opts(), nodes, expect);
     }
 
     fn check_render_with(options: &MdOptions, nodes: Vec<MdElem>, expect: &str) {
@@ -2220,7 +2271,7 @@ pub mod tests {
     }
 
     fn check_render_refs(nodes: Vec<MdElemRef>, expect: &str) {
-        check_render_refs_with(&MdOptions::default(), nodes, expect)
+        check_render_refs_with(&default_opts(), nodes, expect)
     }
 
     fn check_render_refs_with<'a>(options: &MdOptions, nodes: Vec<MdElemRef<'a>>, expect: &str) {
@@ -2231,4 +2282,13 @@ pub mod tests {
         let actual = out.take_underlying().unwrap();
         assert_eq!(&actual, expect);
     }
+
+    fn default_opts() -> MdOptions {
+        MdOptions {
+            link_reference_placement: Default::default(),
+            footnote_reference_placement: Default::default(),
+            link_canonicalization: LinkTransform::Keep,
+            add_thematic_breaks: true,
+        }
+    }
 }
diff --git a/src/link_transform.rs b/src/link_transform.rs
new file mode 100644
index 0000000..abba7b8
--- /dev/null
+++ b/src/link_transform.rs
@@ -0,0 +1,429 @@
+use crate::fmt_md::{write_md, MdOptions, ReferencePlacement};
+use crate::output::Output;
+use crate::tree::{Inline, LinkReference};
+use crate::tree_ref::MdElemRef;
+use clap::ValueEnum;
+use std::borrow::Cow;
+use std::collections::hash_map::Entry;
+use std::collections::HashMap;
+use std::fmt::Display;
+use std::ops::Deref;
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
+pub enum LinkTransform {
+    /// Keep links as they were in the original
+    Keep,
+
+    /// Turn all links into inlined form: `[link text](https://example.com)`
+    Inline,
+
+    /// Turn all links into reference form: `[link text][1]`
+    ///
+    /// Links that weren't already in reference form will be auto-assigned a reference id. Links that were in reference
+    /// form will have the link number be reordered.
+    Reference,
+}
+
+pub struct LinkTransformer {
+    delegate: LinkTransformState,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone)]
+pub enum LinkLabel<'a> {
+    Text(Cow<'a, str>),
+    Inline(&'a Vec<Inline>),
+}
+
+impl<'a> Display for LinkLabel<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            LinkLabel::Text(s) => f.write_str(s),
+            LinkLabel::Inline(inlines) => f.write_str(&inlines_to_string(*inlines)),
+        }
+    }
+}
+
+impl From<LinkTransform> for LinkTransformer {
+    fn from(value: LinkTransform) -> Self {
+        let delegate = match value {
+            LinkTransform::Keep => LinkTransformState::Keep,
+            LinkTransform::Inline => LinkTransformState::Inline,
+            LinkTransform::Reference => LinkTransformState::Reference(ReferenceAssigner::new()),
+        };
+        Self { delegate }
+    }
+}
+
+enum LinkTransformState {
+    Keep,
+    Inline,
+    Reference(ReferenceAssigner),
+}
+
+impl LinkTransformer {
+    pub fn transform<'a>(&mut self, link: &'a LinkReference, label: &LinkLabel<'a>) -> Cow<'a, LinkReference> {
+        match &mut self.delegate {
+            LinkTransformState::Keep => Cow::Borrowed(&link),
+            LinkTransformState::Inline => Cow::Owned(LinkReference::Inline),
+            LinkTransformState::Reference(assigner) => assigner.assign(link, label),
+        }
+    }
+}
+
+struct ReferenceAssigner {
+    /// Let's not worry about overflow. The minimum size for each link is 5 bytes (`[][1]`), so u64 of those is about
+    ///  80 exabytes of markdown -- and it would be even bigger than that, since the digits get bigger. It's just not a
+    /// case I'm too worried about right now.
+    next_index: u64,
+    /// Mappings from old reference id to new. We store these as an Strings (not ints) so that we don't need to worry
+    /// about overflow.
+    reorderings: HashMap<String, u64>,
+}
+
+impl ReferenceAssigner {
+    fn new() -> Self {
+        Self {
+            next_index: 1,
+            reorderings: HashMap::with_capacity(16), // arbitrary
+        }
+    }
+
+    fn assign<'a>(&mut self, link: &'a LinkReference, label: &LinkLabel<'a>) -> Cow<'a, LinkReference> {
+        match &link {
+            LinkReference::Inline => self.assign_new(),
+            LinkReference::Full(prev) => self.assign_if_numeric(prev).unwrap_or_else(|| Cow::Borrowed(link)),
+            LinkReference::Collapsed | LinkReference::Shortcut => {
+                let text_cow = match label {
+                    LinkLabel::Text(text) => Cow::from(text.deref()),
+                    LinkLabel::Inline(text) => Cow::Owned(inlines_to_string(text)),
+                };
+                self.assign_if_numeric(text_cow.deref()).unwrap_or_else(|| {
+                    let ref_text_owned = String::from(text_cow.deref());
+                    Cow::Owned(LinkReference::Full(ref_text_owned))
+                })
+            }
+        }
+    }
+
+    fn assign_if_numeric<'a>(&mut self, prev: &str) -> Option<Cow<'a, LinkReference>> {
+        if prev.chars().all(|ch| ch.is_numeric()) {
+            match self.reorderings.entry(String::from(prev)) {
+                Entry::Occupied(map_to) => Some(Cow::Owned(LinkReference::Full(map_to.get().to_string()))),
+                Entry::Vacant(e) => {
+                    e.insert(self.next_index);
+                    Some(self.assign_new())
+                }
+            }
+        } else {
+            None
+        }
+    }
+
+    fn assign_new<'a>(&mut self) -> Cow<'a, LinkReference> {
+        let idx_str = self.next_index.to_string();
+        self.next_index += 1;
+        Cow::Owned(LinkReference::Full(idx_str))
+    }
+}
+
+/// Turns the inlines into a String. Unlike [crate::fmt_str::inlines_to_plain_string], this respects formatting spans
+/// like emphasis, strong, etc.
+fn inlines_to_string(inlines: &Vec<Inline>) -> String {
+    // see: https://github.com/yshavit/mdq/issues/87
+    let mut string_writer = Output::new(String::with_capacity(32)); // guess at capacity
+    write_md(
+        &MdOptions {
+            link_reference_placement: ReferencePlacement::Section,
+            footnote_reference_placement: ReferencePlacement::Section,
+            link_canonicalization: LinkTransform::Keep,
+            add_thematic_breaks: false,
+        },
+        &mut string_writer,
+        inlines.iter().map(|inline| MdElemRef::Inline(inline)),
+    );
+    string_writer
+        .take_underlying()
+        .expect("internal error while parsing collapsed- or shortcut-style link")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::utils_for_test::*;
+    use crate::variants_checker;
+
+    enum Combo {
+        Of(LinkTransform, LinkReference),
+    }
+
+    variants_checker!(VARIANTS_CHECKER = Combo {
+        Of(LinkTransform::Keep, LinkReference::Inline),
+        Of(LinkTransform::Keep, LinkReference::Collapsed),
+        Of(LinkTransform::Keep, LinkReference::Full(_)),
+        Of(LinkTransform::Keep, LinkReference::Shortcut),
+
+        Of(LinkTransform::Inline, LinkReference::Shortcut),
+        Of(LinkTransform::Inline, LinkReference::Collapsed),
+        Of(LinkTransform::Inline, LinkReference::Full(_)),
+        Of(LinkTransform::Inline, LinkReference::Inline),
+
+        Of(LinkTransform::Reference, LinkReference::Collapsed),
+        Of(LinkTransform::Reference, LinkReference::Full(_)),
+        Of(LinkTransform::Reference, LinkReference::Inline),
+        Of(LinkTransform::Reference, LinkReference::Shortcut),
+    });
+
+    mod keep {
+        use super::*;
+
+        #[test]
+        fn inline() {
+            check_keep(&LinkReference::Inline);
+        }
+
+        #[test]
+        fn collapsed() {
+            check_keep(&LinkReference::Collapsed);
+        }
+
+        #[test]
+        fn full() {
+            check_keep(&LinkReference::Full("5".to_string()));
+        }
+
+        #[test]
+        fn shortcut() {
+            check_keep(&LinkReference::Shortcut);
+        }
+
+        fn check_keep(link_ref: &LinkReference) {
+            check_one(
+                LinkTransform::Keep,
+                link_ref,
+                &LinkLabel::Text(Cow::Borrowed("text label")),
+                &Cow::Borrowed(link_ref),
+            );
+        }
+    }
+
+    mod inline {
+        use super::*;
+
+        #[test]
+        fn inline() {
+            // We could in principle have this return a Borrowed Cow, since the input and output are both Inline.
+            // But it's not really worth it, given that Inline is just a stateless enum variant and thus as cheap
+            // (or potentially even cheaper!) than a pointer.
+            check_inline(&LinkReference::Inline);
+        }
+
+        #[test]
+        fn collapsed() {
+            check_inline(&LinkReference::Collapsed);
+        }
+
+        #[test]
+        fn full() {
+            check_inline(&LinkReference::Full("5".to_string()));
+        }
+
+        #[test]
+        fn shortcut() {
+            check_inline(&LinkReference::Shortcut);
+        }
+
+        fn check_inline(link_ref: &LinkReference) {
+            check_one(
+                LinkTransform::Inline,
+                link_ref,
+                &LinkLabel::Text(Cow::Borrowed("text label")),
+                &Cow::Owned(LinkReference::Inline),
+            );
+        }
+    }
+
+    mod reference {
+        use super::*;
+        use crate::tree::{Formatting, FormattingVariant, Text, TextVariant};
+
+        #[test]
+        fn inline() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Inline,
+                &LinkLabel::Text(Cow::Borrowed("doesn't matter")),
+                &Cow::Owned(LinkReference::Full("1".to_string())),
+            )
+        }
+
+        #[test]
+        fn collapsed_label_not_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Collapsed,
+                &LinkLabel::Text(Cow::Borrowed("not a number")),
+                &Cow::Owned(LinkReference::Full("not a number".to_string())),
+            )
+        }
+
+        #[test]
+        fn collapsed_label_is_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Collapsed,
+                &LinkLabel::Text(Cow::Borrowed("5")),
+                &Cow::Owned(LinkReference::Full("1".to_string())), // always count from 1
+            )
+        }
+
+        #[test]
+        fn full_ref_id_not_number() {
+            let reference = LinkReference::Full("non-number".to_string());
+            let reference_cow: Cow<LinkReference> = Cow::Borrowed(&reference);
+
+            check_one(
+                LinkTransform::Reference,
+                &reference,
+                &LinkLabel::Text(Cow::Borrowed("doesn't matter")),
+                &reference_cow,
+            )
+        }
+
+        #[test]
+        fn full_ref_id_is_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Full("321".to_string()), // what number it is doesn't matter
+                &LinkLabel::Text(Cow::Borrowed("doesn't matter")),
+                &Cow::Owned(LinkReference::Full("1".to_string())),
+            )
+        }
+
+        #[test]
+        fn full_ref_id_is_huge_number() {
+            let huge_num_str = format!("{}00000", u128::MAX);
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Full(huge_num_str), // what number it is doesn't matter
+                &LinkLabel::Text(Cow::Borrowed("doesn't matter")),
+                &Cow::Owned(LinkReference::Full("1".to_string())), // always count from 1
+            )
+        }
+
+        #[test]
+        fn shortcut_label_not_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Shortcut,
+                &LinkLabel::Text(Cow::Borrowed("not a number")),
+                &Cow::Owned(LinkReference::Full("not a number".to_string())),
+            )
+        }
+
+        #[test]
+        fn shortcut_label_is_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Shortcut,
+                &LinkLabel::Text(Cow::Borrowed("5")),
+                &Cow::Owned(LinkReference::Full("1".to_string())), // always count from 1
+            )
+        }
+
+        /// The label isn't even close to a number.
+        ///
+        /// _c.f._ [shortcut_label_inlines_are_emphasized_number]
+        #[test]
+        fn shortcut_label_inlines_not_number_like() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Shortcut,
+                &LinkLabel::Inline(&vec![Inline::Text(Text {
+                    variant: TextVariant::Plain,
+                    value: "hello world".to_string(),
+                })]),
+                &Cow::Owned(LinkReference::Full("hello world".to_string())),
+            )
+        }
+
+        /// The label is kind of like a number, except that it's emphasized: `_123_`. This makes it not a number.
+        ///
+        /// _c.f._ [shortcut_label_inlines_not_number_like]
+        #[test]
+        fn shortcut_label_inlines_are_emphasized_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Shortcut,
+                &LinkLabel::Inline(&vec![Inline::Formatting(Formatting {
+                    variant: FormattingVariant::Emphasis,
+                    children: vec![Inline::Text(Text {
+                        variant: TextVariant::Plain,
+                        value: "123".to_string(),
+                    })],
+                })]),
+                &Cow::Owned(LinkReference::Full("_123_".to_string())), // note: the emphasis makes it not a number!
+            )
+        }
+
+        #[test]
+        fn shortcut_label_inlines_are_number() {
+            check_one(
+                LinkTransform::Reference,
+                &LinkReference::Shortcut,
+                &LinkLabel::Inline(&vec![Inline::Text(Text {
+                    variant: TextVariant::Plain,
+                    value: "123".to_string(),
+                })]),
+                &Cow::Owned(LinkReference::Full("1".to_string())),
+            )
+        }
+    }
+
+    /// A smoke test basically to ensure that we increment values correctly. We won't test every transformation type,
+    /// since the sibling sub-modules already do that.
+    #[test]
+    fn smoke_test_multi() {
+        let mut transformer = LinkTransformer::from(LinkTransform::Reference);
+
+        // [alpha](https://example.com) ==> [alpha][1]
+        assert_eq_cow(
+            &transformer.transform(&LinkReference::Inline, &LinkLabel::Text(Cow::Borrowed("alpha"))),
+            &Cow::Owned(LinkReference::Full("1".to_string())),
+        );
+
+        // [bravo][1] ==> [bravo][2]
+        assert_eq_cow(
+            &transformer.transform(
+                &LinkReference::Full("1".to_string()),
+                &LinkLabel::Text(Cow::Borrowed("bravo")),
+            ),
+            &Cow::Owned(LinkReference::Full("2".to_string())),
+        );
+
+        // [charlie][3] ==> [charlie][3]
+        // Note that in this case, we could return a Borrowed cow, but we return a new Owned one anyway for simplicity
+        assert_eq_cow(
+            &transformer.transform(
+                &LinkReference::Full("3".to_string()),
+                &LinkLabel::Text(Cow::Borrowed("charlie")),
+            ),
+            &Cow::Owned(LinkReference::Full("3".to_string())),
+        );
+
+        // [delta][] ==> [delta][delta]
+        // Note that in this case, we could return a Borrowed cow, but we return a new Owned one anyway for simplicity
+        assert_eq_cow(
+            &transformer.transform(&LinkReference::Collapsed, &LinkLabel::Text(Cow::Borrowed("delta"))),
+            &Cow::Owned(LinkReference::Full("delta".to_string())),
+        );
+    }
+
+    fn check_one(transform: LinkTransform, link_ref: &LinkReference, label: &LinkLabel, expected: &Cow<LinkReference>) {
+        let mut transformer = LinkTransformer::from(transform);
+        let actual = transformer.transform(&link_ref, &label);
+
+        VARIANTS_CHECKER.see(&Combo::Of(transform, link_ref.clone()));
+
+        assert_eq_cow(&actual, &expected);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 4faf326..b955d76 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ use std::io::{stdin, Read};
 use std::process::ExitCode;
 
 use crate::fmt_md::{MdOptions, ReferencePlacement};
+use crate::link_transform::LinkTransform;
 use crate::output::Stream;
 use crate::select::ParseError;
 use crate::tree::{MdElem, ReadOptions};
@@ -12,6 +13,7 @@ use select::MdqRefSelector;
 
 mod fmt_md;
 mod fmt_str;
+mod link_transform;
 mod matcher;
 mod output;
 mod parse_common;
@@ -37,6 +39,9 @@ struct Cli {
     #[arg(long, value_enum)]
     footnote_pos: Option<ReferencePlacement>,
 
+    #[arg(long, short, value_enum, default_value_t=LinkTransform::Reference)]
+    link_canonicalization: LinkTransform,
+
     /// The selector string
     selectors: Option<String>,
 }
@@ -65,9 +70,12 @@ fn main() -> ExitCode {
         pipeline_nodes = new_pipeline;
     }
 
-    let mut md_options = MdOptions::default();
-    md_options.link_reference_placement = cli.link_pos;
-    md_options.footnote_reference_placement = cli.footnote_pos.unwrap_or(md_options.link_reference_placement);
+    let md_options = MdOptions {
+        link_reference_placement: cli.link_pos,
+        footnote_reference_placement: cli.footnote_pos.unwrap_or(cli.link_pos),
+        link_canonicalization: cli.link_canonicalization,
+        add_thematic_breaks: true,
+    };
 
     fmt_md::write_md(&md_options, &mut out, pipeline_nodes.into_iter());
     out.write_str("\n");


Test patch:
diff --git a/src/utils_for_test.rs b/src/utils_for_test.rs
index a76b43f..fc99473 100644
--- a/src/utils_for_test.rs
+++ b/src/utils_for_test.rs
@@ -1,8 +1,12 @@
+#[cfg(test)]
+pub use test_utils::*;
+
 // We this file's contents from prod by putting them in a submodule guarded by cfg(test), but then "pub use" it to
 // export its contents.
 #[cfg(test)]
 mod test_utils {
-
+    use std::borrow::Cow;
+    use std::fmt::Debug;
     /// Turn a pattern match into an `if let ... { else panic! }`.
     #[macro_export]
     macro_rules! unwrap {
@@ -18,14 +22,14 @@ mod test_utils {
     /// Creates a static object named `$name` that looks for all the variants of enum `E`.
     ///
     /// ```
-    /// variants_checker(CHECKER_NAME = MyEnum: { Variant1, Variant2(_), ... })
+    /// variants_checker(CHECKER_NAME = MyEnum { Variant1, Variant2(_), ... })
     /// ```
     ///
     /// You can also mark some variants as ignored; these will be added to the pattern match, but not be required to
     /// be seen:
     ///
     /// ```
-    /// variants_checker(CHECKER_NAME = MyEnum: { Variant1, ... } ignore { Variant2, ... } )
+    /// variants_checker(CHECKER_NAME = MyEnum { Variant1, ... } ignore { Variant2, ... } )
     /// ```
     ///
     /// If you see a compilation failure here, it means the call site is missing variants (or has an unknown
@@ -47,7 +51,7 @@ mod test_utils {
                 }
 
                 impl [<VariantsChecker $name:lower:camel>] {
-                    pub fn see(&self, node: &$enum_type) {
+                    fn see(&self, node: &$enum_type) {
                         let node_str = match node {
                             $($enum_type::$variant => stringify!($variant),)*
                             $($($enum_type::$ignore_variant => {
@@ -57,7 +61,7 @@ mod test_utils {
                         self.require.lock().map(|mut set| set.remove(node_str)).unwrap();
                     }
 
-                    pub fn wait_for_all(&self) {
+                    fn wait_for_all(&self) {
                         use std::{thread, time};
 
                         let timeout = time::Duration::from_millis(500);
@@ -102,4 +106,16 @@ mod test_utils {
             }
         };
     }
+
+    pub fn assert_eq_cow<T>(actual: &Cow<T>, expected: &Cow<T>)
+    where
+        T: Clone + PartialEq + Debug,
+    {
+        assert_eq!(actual, expected);
+        match (actual, expected) {
+            (Cow::Borrowed(_), Cow::Owned(_)) => panic!("expected Cow::Owned({:?}) but saw Borrowed", expected),
+            (Cow::Owned(_), Cow::Borrowed(_)) => panic!("expected Cow::Borrowed({:?}) but saw Owned", expected),
+            (Cow::Borrowed(_), Cow::Borrowed(_)) | (Cow::Owned(_), Cow::Owned(_)) => {}
+        }
+    }
 }