Skip to content

Commit 2751a4b

Browse files
authored
simplify link transformation
Remove `LinkTransform::Reference`, replacing it with `LinkTransform::NeverInline`. This keeps collapsed and shortcut links as they were (unlike `Reference`, which replaced them with `[full references][1]`). Additionally, collapsed and shortcut links whose display text is just a number (e.g. `[1][]` or `[2]`) will now reserve that number, rather than having it be renumbered as a full-style link. Otherwise, the link transformer would change something like `[3]` to `[3][1]` (if that shortcut `[3]` link happened to be the first link in the document), which would result in confusing markdown. Full-style links like `[some text][4]` will still be renumbered. This simplifies link transformation, and also provides for a nicer user experience. Resolves #390. ## Breaking changes - `LinkTransform::Reference` has been removed
1 parent 2e75806 commit 2751a4b

File tree

13 files changed

+450
-470
lines changed

13 files changed

+450
-470
lines changed

src/md_elem/tree.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,24 @@ impl MdContext {
4747
self.footnotes.get(footnote_id).unwrap_or(&self.empty_md_elems)
4848
}
4949

50+
/// Creates a new MdContext with a default guess as to allocations and
5051
fn new() -> Self {
5152
Self {
5253
footnotes: HashMap::with_capacity(4), // total guess
5354
empty_md_elems: Vec::new(),
5455
}
5556
}
57+
58+
/// Creates an empty context, which will not allocate.
59+
///
60+
/// This is intentionally not a `Default::default()`, because I want to make it explicit that it is a non-allocating
61+
/// function.
62+
pub(crate) fn empty() -> Self {
63+
Self {
64+
footnotes: HashMap::with_capacity(0),
65+
empty_md_elems: Vec::new(),
66+
}
67+
}
5668
}
5769

5870
/// A fully parsed Markdown document.
@@ -1987,13 +1999,6 @@ mod tests {
19871999
use crate::util::utils_for_test::*;
19882000

19892001
impl MdContext {
1990-
pub(crate) fn empty() -> Self {
1991-
Self {
1992-
footnotes: Default::default(),
1993-
empty_md_elems: vec![],
1994-
}
1995-
}
1996-
19972002
pub(crate) fn with<S: Into<FootnoteId>>(mut self, footnote_id: S, body: Vec<MdElem>) -> Self {
19982003
self.footnotes.insert(footnote_id.into(), body);
19992004
self

src/output/find_numbered_links.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
use crate::md_elem::elem::{FootnoteId, Inline, Link, LinkReference, StandardLink};
2+
use crate::md_elem::{MdContext, MdElem};
3+
use crate::output::{inlines_to_string, InlineElemOptions, LinkTransform, MdInlinesWriter};
4+
use std::collections::{BTreeSet, HashSet};
5+
use std::str::FromStr;
6+
7+
/// Find all numbers that should not be used when generating `[full links][123]`.
8+
pub(crate) fn find_reserved_link_numbers<'md>(
9+
nodes: impl IntoIterator<Item = &'md MdElem>,
10+
ctx: &'md MdContext,
11+
) -> BTreeSet<u64> {
12+
let mut finder = ReservedLinkNumbers {
13+
result: BTreeSet::new(),
14+
seen_footnotes: HashSet::new(),
15+
ctx,
16+
};
17+
finder.build_from_nodes(nodes);
18+
finder.result
19+
}
20+
21+
struct ReservedLinkNumbers<'md> {
22+
result: BTreeSet<u64>,
23+
seen_footnotes: HashSet<&'md FootnoteId>,
24+
ctx: &'md MdContext,
25+
}
26+
27+
impl<'md> ReservedLinkNumbers<'md> {
28+
fn build_from_nodes(&mut self, nodes: impl IntoIterator<Item = &'md MdElem>) {
29+
for node in nodes.into_iter() {
30+
match node {
31+
MdElem::Doc(doc) => self.build_from_nodes(doc),
32+
MdElem::BlockQuote(block) => self.build_from_nodes(&block.body),
33+
MdElem::List(list) => {
34+
for li in &list.items {
35+
self.build_from_nodes(&li.item);
36+
}
37+
}
38+
MdElem::Section(section) => {
39+
self.build_from_inlines(&section.title);
40+
self.build_from_nodes(&section.body);
41+
}
42+
MdElem::Paragraph(p) => self.build_from_inlines(&p.body),
43+
MdElem::Table(table) => {
44+
for row in &table.rows {
45+
for col in row {
46+
self.build_from_inlines(col);
47+
}
48+
}
49+
}
50+
MdElem::Inline(inline) => self.build_from_inlines(std::iter::once(inline)),
51+
MdElem::ThematicBreak | MdElem::CodeBlock(_) | MdElem::FrontMatter(_) | MdElem::BlockHtml(_) => {}
52+
}
53+
}
54+
}
55+
56+
fn build_from_inlines(&mut self, inline: impl IntoIterator<Item = &'md Inline>) {
57+
for inline in inline.into_iter() {
58+
match inline {
59+
Inline::Footnote(footnote) => {
60+
if self.seen_footnotes.insert(footnote) {
61+
self.build_from_nodes(self.ctx.get_footnote(footnote));
62+
}
63+
}
64+
Inline::Span(span) => self.build_from_inlines(&span.children),
65+
Inline::Link(link) => {
66+
match link {
67+
Link::Standard(link) => self.build_from_link(link),
68+
Link::Autolink(_) => {} // autolinks are never just numeric
69+
}
70+
}
71+
Inline::Image(_) | Inline::Text(_) => {}
72+
}
73+
}
74+
}
75+
76+
fn build_from_link(&mut self, link: &StandardLink) {
77+
match &link.link.reference {
78+
LinkReference::Inline => {
79+
// inline links are never numeric
80+
}
81+
LinkReference::Full(_) | LinkReference::Collapsed | LinkReference::Shortcut => {
82+
// For full links, collapsed links, and shortcuts: if the display text is numeric, we'll reserve it.
83+
// (that will prevent output like `[123][4]`, which could be confusing. Otherwise, we won't.
84+
// Note that something like `[foo bar][1]` will not reserve the 1. That's because we'll be renumbering
85+
// it anyway.
86+
static OPTIONS: InlineElemOptions = InlineElemOptions {
87+
link_format: LinkTransform::Keep,
88+
renumber_footnotes: false,
89+
};
90+
// This is slightly inefficient; we could use a lighter-weight inlines writer mechanism, in principle.
91+
// But this is good enough for now.
92+
let empty_context = MdContext::empty();
93+
let mut writer = MdInlinesWriter::new(&empty_context, OPTIONS, &[]);
94+
let text = inlines_to_string(&mut writer, &link.display);
95+
self.build_from_text(&text);
96+
}
97+
}
98+
}
99+
100+
fn build_from_text(&mut self, text: &str) {
101+
if let Ok(num) = u64::from_str(text) {
102+
self.result.insert(num);
103+
}
104+
}
105+
}
106+
107+
#[cfg(test)]
108+
mod tests {
109+
use super::*;
110+
use crate::md_elem::{MdDoc, ParseOptions};
111+
use indoc::indoc;
112+
113+
#[test]
114+
fn no_links() {
115+
check_link_nums("some markdown", []);
116+
}
117+
118+
#[test]
119+
fn autolink() {
120+
check_link_nums("<https://example.com>", []);
121+
}
122+
123+
#[test]
124+
fn collapsed_not_num() {
125+
check_link_nums(
126+
indoc! {r#"
127+
[apple][]
128+
129+
[apple]: https://example.com"#},
130+
[],
131+
);
132+
}
133+
134+
#[test]
135+
fn collapsed_with_num() {
136+
check_link_nums(
137+
indoc! {r#"
138+
[123][]
139+
140+
[123]: https://example.com"#},
141+
[123],
142+
);
143+
}
144+
145+
#[test]
146+
fn shortcut_not_num() {
147+
check_link_nums(
148+
indoc! {r#"
149+
[apple]
150+
151+
[apple]: https://example.com"#},
152+
[],
153+
);
154+
}
155+
156+
#[test]
157+
fn shortcut_with_num() {
158+
check_link_nums(
159+
indoc! {r#"
160+
[123]
161+
162+
[123]: https://example.com"#},
163+
[123],
164+
);
165+
}
166+
167+
#[test]
168+
fn full_not_num() {
169+
check_link_nums(
170+
indoc! {r#"
171+
[display text][a]
172+
173+
[a]: https://example.com"#},
174+
[],
175+
);
176+
}
177+
178+
#[test]
179+
fn full_with_num() {
180+
check_link_nums(
181+
indoc! {r#"
182+
[display text][123]
183+
184+
[123]: https://example.com"#},
185+
[],
186+
);
187+
}
188+
189+
#[test]
190+
fn full_with_num_display() {
191+
check_link_nums(
192+
indoc! {r#"
193+
[123][a]
194+
195+
[456][7]
196+
197+
[a]: https://example.com
198+
[7]: https://example.com"#},
199+
[123, 456], // but not 7; that link will turn into a collapsed
200+
);
201+
}
202+
203+
#[test]
204+
fn within_some_nested_items() {
205+
check_link_nums(
206+
indoc! {r#"
207+
> Some quoting
208+
>
209+
> - list item including [123][] collapsed link
210+
211+
# Section with [456] shortcut link
212+
213+
Some text under it.
214+
215+
[123]: https://example.com/a
216+
[456]: https://example.com/b
217+
"#},
218+
[123, 456],
219+
);
220+
}
221+
222+
/// Simple little check based on parsed markdown. This makes it not quite unit-y, but very easy to create tests.
223+
fn check_link_nums<const N: usize>(md_text: &str, expected: [u64; N]) {
224+
let MdDoc { roots, ctx } = MdDoc::parse(md_text, &ParseOptions::gfm()).unwrap();
225+
let actual = find_reserved_link_numbers(&roots, &ctx);
226+
assert_eq!(actual, expected.into());
227+
}
228+
}

src/output/fmt_md.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,7 +1976,7 @@ pub(crate) mod tests {
19761976
fn two_links_doc_pos_no_thematic_break() {
19771977
let mut options = MdWriterOptions::default_for_tests();
19781978
options.include_thematic_breaks = false;
1979-
options.inline_options.link_format = LinkTransform::Reference;
1979+
options.inline_options.link_format = LinkTransform::NeverInline;
19801980
options.link_reference_placement = ReferencePlacement::Doc;
19811981
check_render_refs_with(
19821982
options,
@@ -2011,7 +2011,7 @@ pub(crate) mod tests {
20112011
#[test]
20122012
fn reference_transform_smoke_test() {
20132013
check_render_refs_with(
2014-
MdWriterOptions::new_with(|mdo| mdo.inline_options.link_format = LinkTransform::Reference),
2014+
MdWriterOptions::new_with(|mdo| mdo.inline_options.link_format = LinkTransform::NeverInline),
20152015
vec![link_elem(Link::Standard(StandardLink {
20162016
display: vec![mdq_inline!("link text")],
20172017
link: LinkDefinition {
@@ -2091,7 +2091,7 @@ pub(crate) mod tests {
20912091
#[test]
20922092
fn reference_transform_smoke_test() {
20932093
check_render_refs_with(
2094-
MdWriterOptions::new_with(|mdo| mdo.inline_options.link_format = LinkTransform::Reference),
2094+
MdWriterOptions::new_with(|mdo| mdo.inline_options.link_format = LinkTransform::NeverInline),
20952095
vec![image_elem(Image {
20962096
alt: "alt text".to_string(),
20972097
link: LinkDefinition {

src/output/fmt_md_inlines.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::md_elem::elem::*;
22
use crate::md_elem::*;
33
use crate::output::footnote_transform::FootnoteTransformer;
4-
use crate::output::link_transform::{LinkLabel, LinkTransform, LinkTransformation, LinkTransformer};
4+
use crate::output::link_transform::{LinkLabel, LinkTransform, LinkTransformer};
55
use crate::util::output::{Output, SimpleWrite};
66
use derive_builder::Builder;
77
use serde::Serialize;
@@ -80,7 +80,7 @@ impl<'md> MdInlinesWriter<'md> {
8080
seen_links: HashSet::with_capacity(pending_refs_capacity),
8181
seen_footnotes: HashSet::with_capacity(pending_refs_capacity),
8282
pending_references: PendingReferences::with_capacity(pending_refs_capacity),
83-
link_transformer: LinkTransformer::new(options.link_format, nodes),
83+
link_transformer: LinkTransformer::new(options.link_format, nodes, ctx),
8484
footnote_transformer: FootnoteTransformer::new(options.renumber_footnotes),
8585
}
8686
}
@@ -312,8 +312,7 @@ impl<'md> MdInlinesWriter<'md> {
312312

313313
out.write_char(']');
314314

315-
let link_ref = LinkTransformation::new(self.link_transformer.transform_variant(), self, link_like)
316-
.apply(&mut self.link_transformer, &link.reference);
315+
let link_ref = self.link_transformer.apply(&link.reference);
317316
let reference_to_add = match link_ref {
318317
LinkReference::Inline => {
319318
out.write_char('(');

0 commit comments

Comments
 (0)